package gui;
import face.FaceManager;
import face.IconEnum;
import face.Icons;
import gui.forms.GUIMain;
import gui.forms.GUIViewerList;
import irc.Donor;
import irc.Subscriber;
import irc.message.Message;
import irc.message.MessageQueue;
import irc.message.MessageWrapper;
import lib.pircbot.User;
import util.Constants;
import util.Utils;
import util.misc.Donation;
import util.settings.Settings;
import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.DefaultCaret;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import javax.swing.text.html.HTML;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* All channels are stored in this format.
*/
public class ChatPane implements DocumentListener {
private JFrame poppedOutPane = null;
// The timestamp of when we decided to wait to scroll back down
private long scrollbarTimestamp = -1;
public void setPoppedOutPane(JFrame pane) {
poppedOutPane = pane;
}
public JFrame getPoppedOutPane() {
return poppedOutPane;
}
public void createPopOut() {
if (poppedOutPane == null) {
JFrame frame = new JFrame(getPoppedOutTitle());
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
getScrollPane().setViewportView(scrollablePanel);
scrollToBottom();
setPoppedOutPane(null);
}
});
JScrollPane pane = new JScrollPane();
frame.setIconImage(new ImageIcon(getClass().getResource("/image/icon.png")).getImage());
pane.setViewportView(scrollablePanel);
pane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
pane.getVerticalScrollBar().setPreferredSize(new Dimension(0, 0));
frame.add(pane);
frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
frame.pack();
frame.setSize(750, 420);
frame.setVisible(true);
setPoppedOutPane(frame);
}
}
/**
* Keeps track of how many subs this channel gets.
* TODO: make this a statistic that the user can output to a file ("yesterday sub #")
*/
private int subCount = 0;
private int viewerCount = -1;
private int viewerPeak = 0;
public void setViewerCount(int newCount) {
if (newCount > viewerPeak) viewerPeak = newCount;
viewerCount = newCount;
if (getPoppedOutPane() != null) poppedOutPane.setTitle(getPoppedOutTitle());
if (GUIMain.channelPane.getSelectedIndex() == index) GUIMain.updateTitle(getViewerCountString());
}
public String getPoppedOutTitle() {
return chan + " | " + getViewerCountString();
}
public String getViewerCountString() {
if (chan.equalsIgnoreCase("system logs")) return null;
if (viewerCount == -1) return "Viewer count: Offline";
return String.format("Viewer count: %s (%s)",
NumberFormat.getInstance().format(viewerCount),
NumberFormat.getInstance().format(viewerPeak));
}
/**
* This is the main boolean to check to see if this tab should pulse.
* <p>
* This boolean checks to see if the tab wasn't toggled, if it's visible (not in a combined tab),
* and if it's not selected.
* <p>
* The global setting will override this.
*
* @return True if this tab should pulse, else false.
*/
public boolean shouldPulse() {
boolean shouldPulseLocal = (this instanceof CombinedChatPane) ?
((CombinedChatPane) this).getActiveChatPane().shouldPulseLoc() : shouldPulseLoc;
return Settings.showTabPulses.getValue() && shouldPulseLocal && isTabVisible() &&
GUIMain.channelPane.getSelectedIndex() != index && index != 0;
}
private boolean shouldPulseLoc = true;
/**
* Determines if this tab should pulse.
*
* @return True if this tab is not toggled off, else false. ("Tab Pulsing OFF")
*/
public boolean shouldPulseLoc() {
return shouldPulseLoc;
}
/**
* Sets the value for if this tab should pulse or not.
*
* @param newBool True (default) if tab pulsing should happen, else false if you wish to
* toggle tab pulsing off.
*/
public void setShouldPulse(boolean newBool) {
shouldPulseLoc = newBool;
}
/**
* Sets the pulsing boolean if this tab is starting to pulse.
* <p>
* Used by the TabPulse class.
*
* @param isPulsing True if the tab is starting to pulse, else false to stop pulsing.
*/
public void setPulsing(boolean isPulsing) {
this.isPulsing = isPulsing;
}
/**
* Used by the TabPulse class.
*
* @return true if the chat pane is currently pulsing, else false.
*/
public boolean isPulsing() {
return isPulsing;
}
private boolean isPulsing = false;
//credit to http://stackoverflow.com/a/4047794 for the below
public boolean isScrollBarFullyExtended(JScrollBar vScrollBar) {
BoundedRangeModel model = vScrollBar.getModel();
return (model.getExtent() + model.getValue()) == model.getMaximum();
}
public void doScrollToBottom() {
if (textPane.isVisible()) {
Rectangle visibleRect = textPane.getVisibleRect();
visibleRect.y = textPane.getHeight() - visibleRect.height;
textPane.scrollRectToVisible(visibleRect);
} else {
textPane.setCaretPosition(textPane.getDocument().getLength());
}
}
private boolean messageOut = false;
@Override
public void insertUpdate(DocumentEvent e) {
maybeScrollToBottom();
if (Settings.cleanupChat.getValue()) {
try {
if (e.getDocument().getText(e.getOffset(), e.getLength()).contains("\n")) {
cleanupCounter++;
}
} catch (Exception ignored) {
}
if (cleanupCounter > Settings.chatMax.getValue()) {
/* cleanup every n messages */
if (!messageOut) {
MessageQueue.addMessage(new Message().setType(Message.MessageType.CLEAR_TEXT).setExtra(this));
messageOut = true;
}
}
}
}
@Override
public void removeUpdate(DocumentEvent e) {
maybeScrollToBottom();
}
@Override
public void changedUpdate(DocumentEvent e) {
maybeScrollToBottom();
}
/**
* Used to queue a scrollToBottom only if the scroll bar is already at the bottom
* OR
* It's been more than 10 seconds since we've been scrolled up and have been receiving messages
*/
private void maybeScrollToBottom() {
JScrollBar scrollBar = scrollPane.getVerticalScrollBar();
boolean scrollBarAtBottom = isScrollBarFullyExtended(scrollBar);
if (scrollBarAtBottom) {
// We're back at the bottom, reset timer
scrollbarTimestamp = -1;
scrollToBottom();
} else if (scrollbarTimestamp != -1) {
if (System.currentTimeMillis() - scrollbarTimestamp >= 10 * 1000L) {
// If the time difference is more than 10 seconds, scroll to bottom anyway after resetting time
scrollbarTimestamp = -1;
scrollToBottom();
}
} else {
scrollbarTimestamp = System.currentTimeMillis();
}
}
public void scrollToBottom() {
// Push the call to "scrollToBottom" back TWO PLACES on the
// AWT-EDT queue so that it runs *after* Swing has had an
// opportunity to "react" to the appending of new text:
// this ensures that we "scrollToBottom" only after a new
// bottom has been recalculated during the natural
// revalidation of the GUI that occurs after having
// appending new text to the JTextArea.
EventQueue.invokeLater(() -> EventQueue.invokeLater(this::doScrollToBottom));
}
private String chan;
public String getChannel() {
return chan;
}
private int index;
public void setIndex(int newIndex) {
index = newIndex;
}
public int getIndex() {
return index;
}
private ScrollablePanel scrollablePanel;
private JTextPane textPane;
public JTextPane getTextPane() {
return textPane;
}
private JScrollPane scrollPane;
public JScrollPane getScrollPane() {
return scrollPane;
}
public void setScrollPane(JScrollPane pane) {
scrollPane = pane;
}
private boolean isTabVisible = true;
public boolean isTabVisible() {
return isTabVisible;
}
public void setTabVisible(boolean newBool) {
isTabVisible = newBool;
}
private int cleanupCounter = 0;
public void resetCleanupCounter() {
cleanupCounter = 0;
}
//TODO make this be in 24 hour if they want
final SimpleDateFormat format = new SimpleDateFormat("[h:mm a]", Locale.getDefault());
public String getTime() {
return format.format(new Date(System.currentTimeMillis()));
}
/**
* You initialize this class with the channel it's for and the text pane you'll be editing.
*
* @param channel The channel ("name") of this chat pane. Ex: "System Logs" or "#gocnak"
* @param scrollPane The scroll pane for the tab.
* @param pane The text pane that shows the messages for the given channel.
* @param index The index of the pane in the main GUI.
*/
public ChatPane(String channel, JScrollPane scrollPane, JTextPane pane, ScrollablePanel panel, int index) {
chan = channel;
textPane = pane;
((DefaultCaret) textPane.getCaret()).setUpdatePolicy(DefaultCaret.NEVER_UPDATE);
this.index = index;
this.scrollPane = scrollPane;
this.scrollablePanel = panel;
textPane.getDocument().addDocumentListener(this);
}
public ChatPane() {
//Used by the CombinedChatPane class, which calls its super anyways.
}
/**
* This is the main message method when somebody sends a message to the channel.
*
* @param m The message from the chat.
*/
public void onMessage(MessageWrapper m, boolean showChannel) {
if (textPane == null) return;
Message message = m.getLocal();
String sender = message.getSender().toLowerCase();
String channel = message.getChannel();
String mess = message.getContent();
boolean isMe = (message.getType() == Message.MessageType.ACTION_MESSAGE);
try {
print(m, "\n" + getTime(), GUIMain.norm);
User u = Settings.channelManager.getUser(sender, true);
SimpleAttributeSet user = getUserSet(u);
if (channel.substring(1).equals(sender)) {
insertIcon(m, IconEnum.BROADCASTER, null);
}
if (u.isOp(channel)) {
if (!channel.substring(1).equals(sender) && !u.isStaff() && !u.isAdmin() && !u.isGlobalMod()) {//not the broadcaster again
insertIcon(m, IconEnum.MOD, null);
}
}
if (u.isGlobalMod()) {
insertIcon(m, IconEnum.GLOBAL_MOD, null);
}
if (u.isStaff()) {
insertIcon(m, IconEnum.STAFF, null);
}
if (u.isAdmin()) {
insertIcon(m, IconEnum.ADMIN, null);
}
boolean isSubscriber = u.isSubscriber(channel);
if (isSubscriber) {
insertIcon(m, IconEnum.SUBSCRIBER, channel);
} else {
if (Utils.isMainChannel(channel)) {
Optional<Subscriber> sub = Settings.subscriberManager.getSubscriber(sender);
if (sub.isPresent() && !sub.get().isActive()) {
insertIcon(m, IconEnum.EX_SUBSCRIBER, channel);
}
}
}
if (u.isTurbo()) {
insertIcon(m, IconEnum.TURBO, null);
}
if (u.isPrime())
insertIcon(m, IconEnum.PRIME, null);
if (u.isVerified())
insertIcon(m, IconEnum.VERIFIED, null);
//Cheering
int cheerTotal = u.getCheer(channel);
if (cheerTotal > 0)
{
insertIcon(m, Donor.getCheerStatus(cheerTotal), null);
}
// Third party donor
if (Settings.showDonorIcons.getValue())
{
if (u.isDonor())
{
insertIcon(m, u.getDonationStatus(), null);
}
}
//name stuff
print(m, " ", GUIMain.norm);
SimpleAttributeSet userColor = new SimpleAttributeSet(user);
FaceManager.handleNameFaces(sender, user);
print(m, u.getDisplayName(), user);
if (showChannel) {
print(m, " (" + channel.substring(1) + ")" + (isMe ? " " : ": "), GUIMain.norm);
} else {
print(m, (!isMe ? ": " : " "), userColor);
}
//keyword?
SimpleAttributeSet set;
if (Utils.mentionsKeyword(mess)) {
set = Utils.getSetForKeyword(mess);
} else {
set = (isMe ? userColor : GUIMain.norm);
}
//URL, Faces, rest of message
printMessage(m, mess, set, u);
if (BotnakTrayIcon.shouldDisplayMentions() && !Utils.isTabSelected(index)) {
if (mess.toLowerCase().contains(Settings.accountManager.getUserAccount().getName().toLowerCase())) {
GUIMain.getSystemTrayIcon().displayMention(m.getLocal());
}
}
if (Utils.isMainChannel(channel))
//check status of the sub, has it been a month?
Settings.subscriberManager.updateSubscriber(u, channel, isSubscriber);
if (shouldPulse())
GUIMain.instance.pulseTab(this);
} catch (Exception e) {
GUIMain.log(e);
}
}
/**
* Credit: TDuva
* <p>
* Cycles through message data, tagging things like Faces and URLs.
*
* @param text The message
* @param style The default message style to use.
*/
protected void printMessage(MessageWrapper m, String text, SimpleAttributeSet style, User u) {
// Where stuff was found
TreeMap<Integer, Integer> ranges = new TreeMap<>();
// The style of the stuff (basically metadata)
HashMap<Integer, SimpleAttributeSet> rangesStyle = new HashMap<>();
findLinks(text, ranges, rangesStyle);
findEmoticons(text, ranges, rangesStyle, u, m.getLocal().getChannel());
// Actually print everything
int lastPrintedPos = 0;
for (Map.Entry<Integer, Integer> range : ranges.entrySet()) {
int start = range.getKey();
int end = range.getValue();
if (start > lastPrintedPos) {
// If there is anything between the special stuff, print that
// first as regular text
print(m, text.substring(lastPrintedPos, start), style);
}
print(m, text.substring(start, end + 1), rangesStyle.get(start));
lastPrintedPos = end + 1;
}
// If anything is left, print that as well as regular text
if (lastPrintedPos < text.length()) {
print(m, text.substring(lastPrintedPos), style);
}
}
private void findLinks(String text, Map<Integer, Integer> ranges, Map<Integer, SimpleAttributeSet> rangesStyle) {
// Find links
Constants.urlMatcher.reset(text);
while (Constants.urlMatcher.find()) {
int start = Constants.urlMatcher.start();
int end = Constants.urlMatcher.end() - 1;
if (!Utils.inRanges(start, ranges) && !Utils.inRanges(end, ranges)) {
String foundUrl = Constants.urlMatcher.group();
if (Utils.checkURL(foundUrl)) {
ranges.put(start, end);
rangesStyle.put(start, Utils.URLStyle(foundUrl));
}
}
}
}
private void findEmoticons(String text, Map<Integer, Integer> ranges, Map<Integer, SimpleAttributeSet> rangesStyle, User u, String channel) {
FaceManager.handleFaces(ranges, rangesStyle, text, FaceManager.FACE_TYPE.NORMAL_FACE, null, null);
if (u != null && u.getEmotes() != null) {
FaceManager.handleFaces(ranges, rangesStyle, text, FaceManager.FACE_TYPE.TWITCH_FACE, null, u.getEmotes());
}
if (Settings.ffzFacesEnable.getValue() && channel != null) {
channel = channel.replaceAll("#", "");
FaceManager.handleFaces(ranges, rangesStyle, text, FaceManager.FACE_TYPE.FRANKER_FACE, channel, null);
}
}
protected void print(MessageWrapper wrapper, String string, SimpleAttributeSet set) {
if (textPane == null) return;
Runnable r = () -> {
try {
textPane.getStyledDocument().insertString(textPane.getStyledDocument().getLength(), string, set);
} catch (Exception e) {
GUIMain.log(e);
}
};
wrapper.addPrint(r);
}
/**
* Handles inserting icons before and after the message.
*
* @param m The message itself.
* @param status IconEnum.Subscriber for sub message, else pass Donor#getDonationStatus(d#getAmount())
*/
public void onIconMessage(MessageWrapper m, IconEnum status) {
try {
Message message = m.getLocal();
print(m, "\n", GUIMain.norm);
for (int i = 0; i < 3; i++) {
insertIcon(m, status, (status == IconEnum.SUBSCRIBER ? message.getChannel() : null));
}
print(m, " " + message.getContent() + (status == IconEnum.SUBSCRIBER ? (" (" + (subCount + 1) + ") ") : " "), GUIMain.norm);
for (int i = 0; i < 3; i++) {
insertIcon(m, status, (status == IconEnum.SUBSCRIBER ? message.getChannel() : null));
}
} catch (Exception e) {
GUIMain.log(e);
}
boolean shouldIncrement = ((status == IconEnum.SUBSCRIBER) && (m.getLocal().getExtra() == null));//checking for repeat messages
if (shouldIncrement) subCount++;
}
public void onSub(MessageWrapper m) {
onIconMessage(m, IconEnum.SUBSCRIBER);
}
public void onWhisper(MessageWrapper m) {
SimpleAttributeSet senderSet, receiverSet;
String sender = m.getLocal().getSender();
String receiver = (String) m.getLocal().getExtra();
print(m, "\n" + getTime(), GUIMain.norm);
User senderUser = Settings.channelManager.getUser(sender, true);
User receiverUser = Settings.channelManager.getUser(receiver, true);
senderSet = getUserSet(senderUser);
receiverSet = getUserSet(receiverUser);
//name stuff
print(m, " ", GUIMain.norm);
FaceManager.handleNameFaces(sender, senderSet);
FaceManager.handleNameFaces(receiverUser.getNick(), receiverSet);
print(m, senderUser.getDisplayName(), senderSet);
print(m, " (whisper)-> ", GUIMain.norm);
print(m, receiverUser.getDisplayName(), receiverSet);
print(m, ": ", GUIMain.norm);
printMessage(m, m.getLocal().getContent(), GUIMain.norm, senderUser);
}
private SimpleAttributeSet getUserSet(User u) {
SimpleAttributeSet user = new SimpleAttributeSet();
StyleConstants.setFontFamily(user, Settings.font.getValue().getFamily());
StyleConstants.setFontSize(user, Settings.font.getValue().getSize());
StyleConstants.setForeground(user, Utils.getColorFromUser(u));
user.addAttribute(HTML.Attribute.NAME, u.getDisplayName());
return user;
}
public void onDonation(MessageWrapper m) {
Donation d = (Donation) m.getLocal().getExtra();
onIconMessage(m, Donor.getDonationStatus(d.getAmount()));
}
public void onCheer(MessageWrapper m)
{
int bitsAmount = (int) m.getLocal().getExtra();
String bitString = "" + bitsAmount + " bit" + (bitsAmount > 1 ? "s" : "") + "!";
String cheerMessage = m.getLocal().getSender() + " just cheered " + bitString;
String originalMessage = m.getLocal().getContent().replaceAll("(^|\\s?)cheer\\d+(\\s?|$)", " ").trim().replaceAll("\\s+", " ");
//We're first going to send a "hey they cheered" message, then immediately follow it with their message
// This requires overriding the content to be the cheer message first (for the icons), then replacing it back
m.getLocal().setContent(cheerMessage);
onIconMessage(m, Donor.getCheerAmountStatus(bitsAmount));
//Let's get this message out there too, if they have one.
if (originalMessage.length() > 0)
{
m.getLocal().setContent(originalMessage);
onMessage(m, false);
}
}
public void insertIcon(MessageWrapper m, IconEnum type, String channel) {
SimpleAttributeSet attrs = new SimpleAttributeSet();
Icons.BotnakIcon icon = Icons.getIcon(type, channel);
StyleConstants.setIcon(attrs, icon.getImage());
try {
print(m, " ", null);
print(m, icon.getType().type, attrs);
} catch (Exception e) {
GUIMain.log("Exception in insertIcon: ");
GUIMain.log(e);
}
}
public String getText() {
return (textPane != null && textPane.getText() != null) ? textPane.getText() : "";
}
// Source: http://stackoverflow.com/a/4628879
// by http://stackoverflow.com/users/131872/camickr & Community
public void cleanupChat() {
if (scrollablePanel == null || scrollablePanel.getParent() == null) return;
if (!(scrollablePanel.getParent() instanceof JViewport)) return;
JViewport viewport = ((JViewport) scrollablePanel.getParent());
Point startPoint = viewport.getViewPosition();
// we are not deleting right before the visible area, but one screen behind
// for convenience, otherwise flickering.
if (startPoint == null) return;
final int start = textPane.viewToModel(startPoint);
if (start > 0) // not equal zero, because then we don't have to delete anything
{
final StyledDocument doc = textPane.getStyledDocument();
try {
if (Settings.logChat.getValue() && chan != null) {
String[] toRemove = doc.getText(0, start).split("\\n");
Utils.logChat(toRemove, chan, 1);
}
doc.remove(0, start);
resetCleanupCounter();
} catch (Exception e) {
GUIMain.log("Failed clearing chat: ");
GUIMain.log(e);
}
}
messageOut = false;
}
/**
* Creates a pane of the given channel.
*
* @param channel The channel, also used as the key for the hashmap.
* @return The created ChatPane.
*/
public static ChatPane createPane(String channel) {
JScrollPane scrollPane = new JScrollPane();
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
JTextPane pane = new JTextPane();
pane.setContentType("text/html; charset=UTF-8");
pane.setEditorKit(Constants.wrapEditorKit);
pane.setEditable(false);
pane.setFocusable(false);
pane.setMargin(new Insets(0, 0, 0, 0));
pane.setBackground(Color.black);
pane.setFont(Settings.font.getValue());
pane.addMouseListener(Constants.listenerURL);
pane.addMouseListener(Constants.listenerName);
pane.addMouseListener(Constants.listenerFace);
ScrollablePanel sp = new ScrollablePanel();
sp.add(pane, BorderLayout.SOUTH);
scrollPane.setViewportView(sp);
return new ChatPane(channel, scrollPane, pane, sp, GUIMain.channelPane.getTabCount() - 1);
}
/**
* Deletes the pane and removes the tab from the tabbed pane.
*/
public void deletePane() {
if (Settings.logChat.getValue()) {
Utils.logChat(getText().split("\\n"), chan, 2);
}
GUIViewerList list = GUIMain.viewerLists.get(chan);
if (list != null) {
list.dispose();
}
if (getPoppedOutPane() != null) {
getPoppedOutPane().dispose();
}
GUIMain.channelPane.removeTabAt(index);
GUIMain.channelPane.setSelectedIndex(index - 1);
}
/**
* Logs a message to this chat pane.
*
* @param message The message itself.
* @param isSystem Whether the message is a system log message or not.
*/
public void log(MessageWrapper message, boolean isSystem) {
print(message, "\n" + getTime(), GUIMain.norm);
print(message, " " + (isSystem ? "SYS: " : "") + message.getLocal().getContent(), GUIMain.norm);
}
}