diff --git a/build.gradle b/build.gradle index 84435c95a..e79a02735 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ dependencies { implementation fileTree(dir: 'assets/lib', include: ['*.jar']) implementation 'net.java.dev.jna:jna:5.12.1' implementation 'net.java.dev.jna:jna-platform:5.12.1' - implementation 'com.formdev:flatlaf:2.6' + implementation 'com.formdev:flatlaf:3.2.5' testImplementation 'junit:junit:4.12' } diff --git a/src/chatty/ChannelState.java b/src/chatty/ChannelState.java index db02b4e76..e465b893d 100644 --- a/src/chatty/ChannelState.java +++ b/src/chatty/ChannelState.java @@ -188,6 +188,10 @@ public synchronized boolean setFollowersOnly(int minutes) { return false; } + public synchronized int followersOnly() { + return followersOnly; + } + /** * Get the info text based on the current state. * diff --git a/src/chatty/Logging.java b/src/chatty/Logging.java index 00a95348d..c799fb683 100644 --- a/src/chatty/Logging.java +++ b/src/chatty/Logging.java @@ -2,6 +2,7 @@ package chatty; import chatty.Chatty.PathType; +import chatty.gui.laf.LaFChanger; import chatty.util.RingBuffer; import java.io.File; import java.io.IOException; @@ -91,8 +92,14 @@ public void publish(LogRecord record) { } if (record.getLevel() == Level.SEVERE) { if (client.g != null) { - boolean compact = record.getMessage().startsWith("FlatLaf: Failed to parse:"); - client.g.error(record, compact ? new LinkedList<>() : lastMessages.getItems()); + String msg = record.getMessage(); + boolean flatLafError = msg.startsWith("FlatLaf: Failed to parse:"); + if (flatLafError) { + LaFChanger.loggedFlatLookAndFeelError(msg+" "+record.getThrown().getLocalizedMessage()); + } + else { + client.g.error(record, lastMessages.getItems()); + } } } else if (record.getLevel() == USERINFO) { client.warning(record.getMessage()); diff --git a/src/chatty/SettingsManager.java b/src/chatty/SettingsManager.java index 3db9066af..4097674f8 100644 --- a/src/chatty/SettingsManager.java +++ b/src/chatty/SettingsManager.java @@ -219,6 +219,8 @@ public void defineSettings() { settings.addString("locale", ""); settings.addString("timezone", ""); + settings.addBoolean("inputLimitsEnabled", true); + settings.addBoolean("macScreenMenuBar", true); settings.addBoolean("macSystemAppearance", true); @@ -237,7 +239,6 @@ public void defineSettings() { settings.addString("timestamp","HH:mm"); settings.addString("timestampTimezone", ""); settings.addBoolean("capitalizedNames", true); - settings.addBoolean("ircv3CapitalizedNames", true); settings.addBoolean("correctlyCapitalizedNames", false); settings.addMap("customNames", new HashMap<>(), Setting.STRING); settings.addBoolean("actionColored", false); @@ -400,7 +401,11 @@ public void defineSettings() { settings.addLong("favoritesSorting", 20); settings.addList("gameFavorites", new ArrayList(), Setting.STRING); - + + settings.addBoolean("historyServiceEnabled", false); + settings.addLong("historyServiceLimit", 30); + settings.addList("historyServiceExcluded", new ArrayList(), Setting.STRING); + //======================= // Channel Admin Features //======================= @@ -426,6 +431,10 @@ public void defineSettings() { settings.addString("commercialHotkey",""); settings.addBoolean("adDelay", false); settings.addLong("adDelayLength", 300); + + // Moderation Presets + settings.addString("slowmodeDurations", "3s\n5s\n10s\n20s\n30s\n60s\n120s"); + settings.addString("followeronlyDurations", "0m\n10m\n30m\n1h\n1d\n7d\n30d\n90d"); //======= // Window @@ -960,9 +969,6 @@ public void overrideSettings() { } settings.setAdd("securedPorts", (long)443); } - if (switchedFromVersionBefore("0.8.4")) { - settings.setBoolean("ircv3CapitalizedNames", true); - } if (switchedFromVersionBefore("0.8.5b4")) { String currentValue = settings.getString("timeoutButtons"); if (!StringUtil.toLowerCase(currentValue).contains("/modunmod")) { diff --git a/src/chatty/TwitchClient.java b/src/chatty/TwitchClient.java index 72c0d3b61..34ac7b8b8 100644 --- a/src/chatty/TwitchClient.java +++ b/src/chatty/TwitchClient.java @@ -41,6 +41,8 @@ import chatty.util.IconManager; import chatty.util.ffz.FrankerFaceZ; import chatty.util.ffz.FrankerFaceZListener; +import chatty.util.history.HistoryManager; +import chatty.util.history.HistoryMessage; import chatty.util.ImageCache; import chatty.util.LogUtil; import chatty.util.MacAwtOptions; @@ -82,15 +84,11 @@ import chatty.util.api.eventsub.payloads.RaidPayload; import chatty.util.api.eventsub.payloads.ShieldModePayload; import chatty.util.api.eventsub.payloads.ShoutoutPayload; -import chatty.util.api.pubsub.RewardRedeemedMessageData; -import chatty.util.api.pubsub.Message; -import chatty.util.api.pubsub.ModeratorActionData; -import chatty.util.api.pubsub.PubSubListener; -import chatty.util.api.pubsub.UserModerationMessageData; import chatty.util.chatlog.ChatLog; import chatty.util.commands.CustomCommand; import chatty.util.commands.Parameters; import chatty.util.irc.MsgTags; +import chatty.util.irc.UserTagsUtil; import chatty.util.settings.FileManager; import chatty.util.settings.Settings; import chatty.util.settings.SettingsListener; @@ -194,6 +192,8 @@ public class TwitchClient { private final AutoModCommandHelper autoModCommandHelper; public final RoomManager roomManager; + + public final HistoryManager historyManager; /** * Holds the UserManager instance, which manages all the user objects. @@ -258,6 +258,7 @@ public TwitchClient(Map args) { settingsManager.startAutoSave(this); MacAwtOptions.setMacLookSettings(settings); + GuiUtil.inputLimitsEnabled = settings.getBoolean("inputLimitsEnabled"); Chatty.setSettings(settings); @@ -355,6 +356,8 @@ public TwitchClient(Map args) { roomManager = new RoomManager(new MyRoomUpdatedListener()); channelFavorites = new ChannelFavorites(settings, roomManager); + + historyManager = new HistoryManager(settings); c = new TwitchConnection(new Messages(), settings, "main", roomManager); c.setUserSettings(new User.UserSettings( @@ -718,6 +721,7 @@ public void closeChannel(String channel) { chatLog.closeChannel(room.getFilename()); updateStreamInfoChannelOpen(channel); } + historyManager.resetMessageSeen(Helper.toStream(channel)); } private void closeChannelStuff(Room room) { @@ -2850,6 +2854,7 @@ public void accessDenied() { @Override public void receivedUsericons(List icons) { usericonManager.addDefaultIcons(icons); + g.updateModButtons(null); if (refreshRequests.contains("badges2")) { g.printLine("Badges2 updated."); refreshRequests.remove("badges2"); @@ -3270,7 +3275,37 @@ public void requestChannelEmotes(String channel) { } // api.getEmotesByStreams(Helper.toStream(channel)); // Removed } - + + /** + * Requests the chat history + * + * @param stream The name of the channel + */ + public void requestChannelHistory(String stream) { + // Check in Settings if active/channel in blacklist + //settings. + if (!historyManager.isEnabled()) { + return; + } + if (historyManager.isChannelExcluded(stream)) { + return; + } + Room room = Room.createRegular("#" + stream); + g.printSystem(room, "### Pulling information from history service. ###"); + + // Get the actual list of messages + List history = historyManager.getHistoricChatMessages(room); + + for (int i = 0; i < history.size(); i++) { + HistoryMessage currentMsg = history.get(i); + User user = c.getUser("#"+stream, currentMsg.userName); + UserTagsUtil.updateUserFromTags(user, currentMsg.tags); + g.printMessage(user, currentMsg.message, currentMsg.action, currentMsg.tags); + } + historyManager.setMessageSeen(stream); + g.printSystem(room, "### Finished with history logs. ###"); + } + private class EmoteListener implements EmoticonListener { @Override @@ -3457,6 +3492,7 @@ public void onChannelJoined(User user) { api.getEmotesByChannelId(stream, null, false); requestChannelEmotes(stream); frankerFaceZ.joined(stream); + requestChannelHistory(stream); checkModLogListen(user); checkPointsListen(user); api.removeShieldModeCache(user.getRoom()); @@ -3522,6 +3558,7 @@ public void onChannelMessage(User user, String text, boolean action, MsgTags tag addressbookCommands(user.getChannel(), user, text); modCommandAddStreamHighlight(user, text, tags); } + historyManager.setMessageSeen(user.getStream()); } } @@ -3869,6 +3906,7 @@ private class ChannelStateUpdater implements ChannelStateListener { @Override public void channelStateUpdated(ChannelState state) { g.updateState(true); + g.channelStateUpdated(state); } } diff --git a/src/chatty/TwitchCommands.java b/src/chatty/TwitchCommands.java index cdb169cc5..70f40bc1c 100644 --- a/src/chatty/TwitchCommands.java +++ b/src/chatty/TwitchCommands.java @@ -241,7 +241,7 @@ public void addNewCommands(Commands commands, TwitchClient client) { TwitchApi.CHAT_SETTINGS_FOLLOWER_MODE, true); } else { - updateChatSettings(client, p, " ("+formatDuration(minutes / 60)+")", + updateChatSettings(client, p, " ("+formatDuration(minutes * 60)+")", TwitchApi.CHAT_SETTINGS_FOLLOWER_MODE, true, TwitchApi.CHAT_SETTINGS_FOLLOWER_MODE_DURATION, minutes); } diff --git a/src/chatty/TwitchConnection.java b/src/chatty/TwitchConnection.java index 4ccc04481..e348eaeec 100644 --- a/src/chatty/TwitchConnection.java +++ b/src/chatty/TwitchConnection.java @@ -12,6 +12,7 @@ import chatty.util.StringUtil; import chatty.util.api.Emoticons; import chatty.util.irc.IrcBadges; +import chatty.util.irc.UserTagsUtil; import chatty.util.settings.Settings; import java.util.ArrayList; import java.util.Arrays; @@ -830,10 +831,12 @@ void onJoin(String channel, String nick) { joinChecker.cancel(channel); debug("JOINED: " + channel); User user = userJoined(channel, nick); - if (this == irc && !onChannel(channel)) { + boolean onChannel = onChannel(channel); + // Change before notifying listener + joinedChannels.add(channel); + if (this == irc && !onChannel) { listener.onChannelJoined(user); } - joinedChannels.add(channel); } else { /** * Another user has joined a channel we are currently in. @@ -945,81 +948,7 @@ void onModeChange(String channel, String nick, boolean modeAdded, String mode, S } private void updateUserFromTags(User user, MsgTags tags) { - if (tags.isEmpty()) { - return; - } - /** - * Any and all tag values may be null, so account for that when - * checking against them. - */ - // Whether anything in the user changed to warrant an update - boolean changed = false; - - IrcBadges badges = IrcBadges.parse(tags.get("badges")); - if (user.setTwitchBadges(badges)) { - changed = true; - } - - IrcBadges badgeInfo = IrcBadges.parse(tags.get("badge-info")); - String subMonths = badgeInfo.get("subscriber"); - if (subMonths == null) { - subMonths = badgeInfo.get("founder"); - } - if (subMonths != null) { - user.setSubMonths(Helper.parseShort(subMonths, (short)0)); - } - - if (settings.getBoolean("ircv3CapitalizedNames")) { - if (user.setDisplayNick(StringUtil.trim(tags.get("display-name")))) { - changed = true; - } - } - - // Update color - String color = tags.get("color"); - if (color != null && !color.isEmpty()) { - user.setColor(color); - } - - // Update user status - boolean turbo = tags.isTrue("turbo") || badges.hasId("turbo") || badges.hasId("premium"); - if (user.setTurbo(turbo)) { - changed = true; - } - boolean subscriber = badges.hasId("subscriber") || badges.hasId("founder"); - if (user.setSubscriber(subscriber)) { - changed = true; - } - if (user.setVip(badges.hasId("vip"))) { - changed = true; - } - if (user.setModerator(badges.hasId("moderator"))) { - changed = true; - } - if (user.setAdmin(badges.hasId("admin"))) { - changed = true; - } - if (user.setStaff(badges.hasId("staff"))) { - changed = true; - } - - // Temporarily check both for containing a value as Twitch is - // changing it -// String userType = tags.get("user-type"); -// if (user.setModerator("mod".equals(userType))) { -// changed = true; -// } -// if (user.setStaff("staff".equals(userType))) { -// changed = true; -// } -// if (user.setAdmin("admin".equals(userType))) { -// changed = true; -// } -// if (user.setGlobalMod("global_mod".equals(userType))) { -// changed = true; -// } - - user.setId(tags.get("user-id")); + boolean changed = UserTagsUtil.updateUserFromTags(user, tags); if (changed && user != users.specialUser) { listener.onUserUpdated(user); diff --git a/src/chatty/User.java b/src/chatty/User.java index 3c509bf4c..ccec14675 100644 --- a/src/chatty/User.java +++ b/src/chatty/User.java @@ -330,16 +330,24 @@ public synchronized boolean maxLinesExceeded() { return numLines == userSettings.maxLines && numLines < numberOfLines; } + public synchronized void addMessage(String line, boolean action, String id) { + addMessage(line, action, id, System.currentTimeMillis()); + } + /** * Adds a single chatmessage with the current time. * * @param line * @param action * @param id + * @param timestamp */ - public synchronized void addMessage(String line, boolean action, String id) { + public synchronized void addMessage(String line, boolean action, String id, long timestamp) { + if (timestamp == -1) { + timestamp = System.currentTimeMillis(); + } setFirstSeen(); - addLine(new TextMessage(System.currentTimeMillis(), line, action, id, null)); + addLine(new TextMessage(timestamp, line, action, id, null)); replayCachedLowTrust(); numberOfMessages++; } diff --git a/src/chatty/gui/GuiUtil.java b/src/chatty/gui/GuiUtil.java index 5d3520d43..86345e4e4 100644 --- a/src/chatty/gui/GuiUtil.java +++ b/src/chatty/gui/GuiUtil.java @@ -2,6 +2,7 @@ package chatty.gui; import chatty.Helper; +import chatty.gui.components.SimplePopup; import chatty.gui.components.textpane.ChannelTextPane; import chatty.gui.laf.LaF; import chatty.util.Debugging; @@ -60,6 +61,7 @@ import javax.swing.JPanel; import javax.swing.JRootPane; import javax.swing.JTable; +import javax.swing.JTextArea; import javax.swing.KeyStroke; import javax.swing.RowSorter; import javax.swing.SortOrder; @@ -378,6 +380,9 @@ public static void main(String[] args) { JButton button = new JButton("Shake"); button.addActionListener(e -> shake(dialog, 2, 2)); dialog.add(button, BorderLayout.NORTH); + JTextArea input = new JTextArea(); + installLengthLimitDocumentFilter(input, 5, false); + dialog.add(input, BorderLayout.SOUTH); dialog.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); } catch (Exception ex) { Logger.getLogger(GuiUtil.class.getName()).log(Level.SEVERE, null, ex); @@ -663,6 +668,8 @@ public void vetoableChange(PropertyChangeEvent evt) throws PropertyVetoException }); } + public static boolean inputLimitsEnabled; + /** * Set a DocumentFilter that limits the text length and allows or filters * linebreak characters. @@ -697,21 +704,34 @@ public static void installLengthLimitDocumentFilter(JTextComponent comp, int def DocumentFilter filter = new DocumentFilter() { + private final SimplePopup popup = new SimplePopup(comp, null); + @Override public void replace(DocumentFilter.FilterBypass fb, int offset, int delLength, String text, AttributeSet attrs) throws BadLocationException { String fullText = fb.getDocument().getText(0, offset) + text + fb.getDocument().getText(offset + delLength, fb.getDocument().getLength() - offset - delLength); + int limit = defaultLimit; - for (Pair limitEntry : limits) { - if (limitEntry.key != null - && limitEntry.key.matcher(fullText).find()) { - limit = limitEntry.value; - break; + + if (!inputLimitsEnabled) { + limit = 0; + } + else { + for (Pair limitEntry : limits) { + if (limitEntry.key != null + && limitEntry.key.matcher(fullText).find()) { + limit = limitEntry.value; + break; + } } } + if (text == null || text.isEmpty() || limit <= 0) { + if (!allowNewlines) { + text = StringUtil.removeLinebreakCharacters(text); + } super.replace(fb, offset, delLength, text, attrs); } else { int currentLength = fb.getDocument().getLength(); @@ -723,6 +743,12 @@ public void replace(DocumentFilter.FilterBypass fb, int offset, */ int newLength = Math.max(text.length() - overLimit, 0); text = text.substring(0, newLength); + if (overLimit == 1) { + popup.showPopup("Length limit reached"); + } + else { + popup.showPopup("Length limit reached ("+overLimit+" characters not added)"); + } } if (!allowNewlines) { text = StringUtil.removeLinebreakCharacters(text); diff --git a/src/chatty/gui/MainGui.java b/src/chatty/gui/MainGui.java index 6ad4ec95a..8724837e5 100644 --- a/src/chatty/gui/MainGui.java +++ b/src/chatty/gui/MainGui.java @@ -6,6 +6,7 @@ import chatty.util.api.pubsub.LowTrustUserMessageData; import chatty.util.colors.HtmlColors; import chatty.Addressbook; +import chatty.ChannelState; import chatty.gui.components.textpane.UserMessage; import chatty.gui.components.DebugWindow; import chatty.gui.components.ChannelInfoDialog; @@ -77,6 +78,7 @@ import chatty.gui.components.userinfo.UserInfoManager; import chatty.gui.components.userinfo.UserNotes; import chatty.gui.emoji.EmojiUtil; +import chatty.gui.laf.LaFChanger; import chatty.gui.notifications.Notification; import chatty.gui.notifications.NotificationActionListener; import chatty.gui.notifications.NotificationManager; @@ -1504,6 +1506,25 @@ public void updateUserinfo(final User user) { @Override public void run() { updateUserInfoDialog(user); + updateModButtons(user); + } + }); + } + + public void updateModButtons(User user) { + SwingUtilities.invokeLater(() -> { + if (user == null) { + for (Channel chan : channels.getChannelsOfType(Channel.Type.CHANNEL)) { + chan.updateModButton(); + } + } + else { + if (user.isLocalUser()) { + Channel channel = channels.getExistingChannel(user.getChannel()); + if (channel != null) { + channel.updateModButton(); + } + } } }); } @@ -3655,7 +3676,12 @@ public void run() { hasReplacements ? filter.getLastReplacement() : null, tags); message.localUser = localUser; - + + // Message comes from a history service + if (tags.isHistoricMsg()) { + message.historicTimeStamp = tags.getHistoricTimeStamp(); + } + // Custom color boolean hlByPoints = tags.isHighlightedMessage() && client.settings.getBoolean("highlightByPoints"); if (highlighted) { @@ -3720,7 +3746,7 @@ public void run() { else { // Stuff independent of highlight/ignore if (timestamp == null) { - user.addMessage(processMessage(text), action, tags.getId()); + user.addMessage(processMessage(text), action, tags.getId(), tags.getHistoricTimeStamp()); } else { user.addMessage(processMessage(text), action, tags.getId(), timestamp); } @@ -4036,6 +4062,9 @@ private boolean printInfo(Channel channel, InfoMessage message) { if (message instanceof UserNotice) { user = ((UserNotice)message).user; } + boolean noHighlightUser = user != null + && client.settings.listContains("noHighlightUsers", user.getName()); + MsgTags tags = message.tags; User localUser = client.getLocalUser(channel.getChannel()); RoutingTargets routingTargets = new RoutingTargets(); @@ -4044,7 +4073,7 @@ private boolean printInfo(Channel channel, InfoMessage message) { boolean ignoreCheck = !ignored || highlighter.hasOverrideIgnored() || client.settings.getBoolean("highlightOverrideIgnored"); - if (ignoreCheck && !message.isHidden()) { + if (ignoreCheck && !message.isHidden() && !noHighlightUser) { boolean rejectIgnoredWithoutPrefix = client.settings.getBoolean("highlightOverrideIgnored") ? false : ignored; highlighted = checkInfoMsg(highlighter, "highlight", message.text, message.getMsgStart(), message.getMsgEnd(), user, tags, channel.getChannel(), client.addressbook, rejectIgnoredWithoutPrefix); if (highlighted) { @@ -4114,7 +4143,7 @@ private boolean printInfo(Channel channel, InfoMessage message) { } } - routingManager.addInfoMessage(routingTargets, message, user, localUser); + routingManager.addInfoMessage(routingTargets, message, user, localUser, channel); //---------- // Chat Log @@ -4485,8 +4514,17 @@ public void updateStreamLive(StreamInfo info) { }); } - public void updateState() { - updateState(false); + public ChannelState getChannelState(String channel) { + return client.getChannelState(channel); + } + + public void channelStateUpdated(ChannelState state) { + SwingUtilities.invokeLater(() -> { + Channel channel = channels.getExistingChannel(state.getChannel()); + if (channel != null) { + channel.updateModPanel(); + } + }); } public void updateState(final boolean forced) { @@ -5388,8 +5426,7 @@ private void requestFollowedStreams() { } private void updateLaF() { - LaF.setLookAndFeel(LaFSettings.fromSettings(client.settings)); - LaF.updateLookAndFeel(); + LaFChanger.changeLookAndFeel(LaFSettings.fromSettings(client.settings), this); } private class MySettingChangeListener implements SettingChangeListener { diff --git a/src/chatty/gui/RegexDocumentFilter.java b/src/chatty/gui/RegexDocumentFilter.java index f87ebe21d..edb797b19 100644 --- a/src/chatty/gui/RegexDocumentFilter.java +++ b/src/chatty/gui/RegexDocumentFilter.java @@ -1,7 +1,17 @@ package chatty.gui; +import chatty.gui.components.SimplePopup; +import chatty.lang.Language; +import chatty.util.StringUtil; +import java.awt.BorderLayout; +import java.awt.Component; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.swing.JFrame; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.text.AbstractDocument; import javax.swing.text.AttributeSet; import javax.swing.text.BadLocationException; import javax.swing.text.DocumentFilter; @@ -14,31 +24,96 @@ public class RegexDocumentFilter extends DocumentFilter { private final Pattern pattern; + private final SimplePopup popup; + private String latestFiltered = ""; - public RegexDocumentFilter(String regex) { + /** + * Create a new filter. Anything that matches the regex will not be allowed. + * + * @param regex The regex to use, required + * @param popupOwner The owner of the popup that is shown for invalid input, + * may be null (in which case no popup is shown) + */ + public RegexDocumentFilter(String regex, Component popupOwner) { pattern = Pattern.compile(regex); + if (popupOwner != null) { + popup = new SimplePopup(popupOwner, () -> { + latestFiltered = ""; + }); + } + else { + popup = null; + } + } + + public String getRegex() { + return pattern.pattern(); } @Override public void insertString(DocumentFilter.FilterBypass fb, int off, String str, AttributeSet attr) { try { - fb.insertString(off, pattern.matcher(str).replaceAll(""), attr); + fb.insertString(off, getFiltered(str), attr); } catch (BadLocationException | NullPointerException ex) { } } - + @Override public void replace(DocumentFilter.FilterBypass fb, int off, int len, String str, AttributeSet attr) { try { if (str == null) { fb.replace(off, len, str, attr); } else { - fb.replace(off, len, pattern.matcher(str).replaceAll(""), attr); + fb.replace(off, len, getFiltered(str), attr); } } catch (BadLocationException | NullPointerException ex) { } } + + private String getFiltered(String input) { + Matcher m = pattern.matcher(input); + boolean result = m.find(); + if (result) { + StringBuffer sb = new StringBuffer(); + StringBuilder filtered = new StringBuilder(); + do { + m.appendReplacement(sb, ""); + filtered.append(m.group()); + result = m.find(); + } while (result); + m.appendTail(sb); + showPopup(filtered.toString()); + return sb.toString(); + } + return input; + } + + private void showPopup(String filtered) { + if (popup == null) { + return; + } + + latestFiltered += filtered; + + popup.showPopup(String.format("%s '%s'", + Language.getString("dialog.error.invalidInput"), + StringUtil.shortenTo(latestFiltered, 100))); + } + + // For testing + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + JFrame frame = new JFrame(); + JTextField text = new JTextField(); + ((AbstractDocument) text.getDocument()).setDocumentFilter(new RegexDocumentFilter("[^0-9.]", text)); + frame.add(text, BorderLayout.SOUTH); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setVisible(true); + }); + } } \ No newline at end of file diff --git a/src/chatty/gui/components/AddressbookEditor.java b/src/chatty/gui/components/AddressbookEditor.java index baf1d8859..489d8975f 100644 --- a/src/chatty/gui/components/AddressbookEditor.java +++ b/src/chatty/gui/components/AddressbookEditor.java @@ -115,7 +115,7 @@ public MyEditor(JDialog owner) { categories.getDocument().addDocumentListener(documentListener); // Prevents any whitespace from being entered in the name field - ((AbstractDocument)name.getDocument()).setDocumentFilter(new RegexDocumentFilter("\\s+")); + ((AbstractDocument)name.getDocument()).setDocumentFilter(new RegexDocumentFilter("\\s+", this)); // Layout setLayout(new GridBagLayout()); diff --git a/src/chatty/gui/components/Channel.java b/src/chatty/gui/components/Channel.java index ff9a9dd9a..47492b522 100644 --- a/src/chatty/gui/components/Channel.java +++ b/src/chatty/gui/components/Channel.java @@ -1,6 +1,7 @@ package chatty.gui.components; +import chatty.ChannelState; import chatty.Room; import chatty.gui.MouseClickedListener; import chatty.gui.StyleManager; @@ -15,19 +16,28 @@ import chatty.gui.components.textpane.InfoMessage; import chatty.gui.components.textpane.Message; import chatty.util.StringUtil; +import chatty.util.api.AccessChecker; +import chatty.util.api.TokenInfo; import chatty.util.api.pubsub.LowTrustUserMessageData; +import chatty.util.api.usericons.Usericon; +import chatty.util.commands.CustomCommand; +import chatty.util.commands.Parameters; +import chatty.util.irc.MsgTags; import java.awt.BorderLayout; import java.awt.Dimension; +import java.awt.Point; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; -import java.awt.event.KeyEvent; -import javax.swing.AbstractAction; -import javax.swing.InputMap; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import javax.swing.BorderFactory; +import javax.swing.JButton; import javax.swing.JPanel; import javax.swing.JScrollBar; import javax.swing.JScrollPane; import javax.swing.JSplitPane; -import javax.swing.KeyStroke; +import javax.swing.Popup; +import javax.swing.PopupFactory; import javax.swing.Timer; import javax.swing.text.JTextComponent; @@ -37,7 +47,7 @@ * @author tduva */ public final class Channel extends JPanel { - + public enum Type { NONE, CHANNEL, WHISPER, SPECIAL } @@ -61,6 +71,10 @@ public enum Type { private int userlistMinWidth; private Room room; + + private ModerationPanel modPanel; + private Popup modPanelPopup; + private final JButton modPanelButton; public Channel(final Room room, Type type, MainGui main, StyleManager styleManager, ContextMenuListener contextMenuListener) { @@ -104,9 +118,92 @@ public Channel(final Room room, Type type, MainGui main, StyleManager styleManag installLimits(input); TextSelectionMenu.install(input); + JPanel inputPanel = new JPanel(new BorderLayout()); + inputPanel.add(input, BorderLayout.CENTER); + + modPanelButton = new JButton("M"); + modPanelButton.setToolTipText("Channel Modes"); + modPanelButton.setVisible(false); + inputPanel.add(modPanelButton, BorderLayout.EAST); + modPanelButton.addActionListener(e -> { + openModPanel(); + }); + // Add components add(mainPane, BorderLayout.CENTER); - add(input, BorderLayout.SOUTH); + add(inputPanel, BorderLayout.SOUTH); + } + + public void updateModButton() { + boolean hasAccess = AccessChecker.instance().check(room.getChannel(), TokenInfo.Scope.MANAGE_CHAT, true, false); + modPanelButton.setVisible(hasAccess); + + // Not sure if this looks good +// Usericon icon = main.client.usericonManager.getIcon(Usericon.Type.TWITCH, "moderator", "1", main.client.getLocalUser(room.getChannel()), MsgTags.EMPTY); +// if (icon != null) { +// int height = input.getFontMetrics(input.getFont()).getHeight(); +// modPanelButton.setIcon(icon.getIcon(2f, 2, height, (oldImage, newImage, sizeChanged) -> { +// modPanelButton.repaint(); +// }).getImageIcon()); +// modPanelButton.setText(null); +// modPanelButton.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 0)); +// } + } + + public void updateModPanel() { + if (modPanel != null) { + ChannelState state = main.getChannelState(room.getChannel()); + modPanel.updateState(state); + } + } + + private void closeModPanel() { + if (modPanelPopup != null) { + modPanelPopup.hide(); + modPanelPopup = null; + } + } + private void openModPanel() { + if (modPanelPopup != null) { + closeModPanel(); + return; + } + if (modPanel == null) { + modPanel = new ModerationPanel(main, main.getSettings()); + modPanel.setBorder(BorderFactory.createRaisedSoftBevelBorder()); + modPanel.addCommandListener(s -> { + main.anonCustomCommand(room, CustomCommand.parse(s), Parameters.create("")); + }); + + text.addFocusListener(new FocusAdapter() { + + @Override + public void focusGained(FocusEvent e) { + closeModPanel(); + } + + }); + + input.addFocusListener(new FocusAdapter() { + + @Override + public void focusGained(FocusEvent e) { + closeModPanel(); + } + + }); + } + updateModPanel(); + Point inputLocation = input.getLocationOnScreen(); + Dimension panelSize = modPanel.getPreferredSize(); + int buttonWidth = modPanelButton.getSize().width; + Popup popup = PopupFactory.getSharedInstance().getPopup( + input, + modPanel, + inputLocation.x + input.getWidth() - panelSize.width + buttonWidth, + inputLocation.y - panelSize.height); + popup.show(); + modPanelPopup = popup; } /** diff --git a/src/chatty/gui/components/ModerationPanel.java b/src/chatty/gui/components/ModerationPanel.java new file mode 100644 index 000000000..5444b2d8c --- /dev/null +++ b/src/chatty/gui/components/ModerationPanel.java @@ -0,0 +1,221 @@ + +package chatty.gui.components; + +import chatty.ChannelState; +import chatty.gui.GuiUtil; +import chatty.gui.components.settings.PresetsComboSetting; +import chatty.util.DateTime; +import chatty.util.settings.Settings; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Window; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.swing.BorderFactory; +import javax.swing.JCheckBox; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; + +/** + * + * @author tduva + */ +public class ModerationPanel extends JPanel { + + private final JCheckBox subonlyCheckbox = new JCheckBox("Subscribers-Only Chat"); + private final JCheckBox emoteonlyCheckbox = new JCheckBox("Emotes-Only Chat"); + private final JCheckBox followeronlyCheckbox = new JCheckBox("Followers-Only Chat"); + private final JCheckBox slowmodeCheckbox = new JCheckBox("Slow Mode"); + private final JCheckBox uniquechatCheckbox = new JCheckBox("Unique Chat"); + private final PresetsComboSetting followeronlyDuration; + private final PresetsComboSetting slowmodeDuration; + + private boolean updating; + + public ModerationPanel(Window parent, Settings settings) { + + followeronlyDuration = new PresetsComboSetting<>(parent, settings, "followeronlyDurations", s -> { + return DateTime.parseDurationSeconds(s); + }, + v -> { + return DateTime.duration(v * 1000, DateTime.Formatting.NO_ZERO_VALUES, DateTime.Formatting.VERBOSE); + }, null, null, false); + followeronlyDuration.init(); + + slowmodeDuration = new PresetsComboSetting<>(parent, settings, "slowmodeDurations", s -> { + return DateTime.parseDurationSeconds(s); + }, + v -> { + return DateTime.duration(v * 1000, DateTime.Formatting.NO_ZERO_VALUES, DateTime.Formatting.VERBOSE); + }, null, null, false); + slowmodeDuration.init(); + + + //======== + // Layout + //======== + + setLayout(new GridBagLayout()); + + GridBagConstraints gbc; + + JPanel modesPanel = new JPanel(new GridBagLayout()); + modesPanel.setBorder(BorderFactory.createTitledBorder("Channel Modes")); + + gbc = GuiUtil.makeGbc(0, 10, 1, 1, GridBagConstraints.WEST); + modesPanel.add(subonlyCheckbox, gbc); + + gbc = GuiUtil.makeGbc(0, 11, 1, 1, GridBagConstraints.WEST); + modesPanel.add(emoteonlyCheckbox, gbc); + + gbc = GuiUtil.makeGbc(0, 12, 1, 1, GridBagConstraints.WEST); + modesPanel.add(uniquechatCheckbox, gbc); + + gbc = GuiUtil.makeGbc(0, 13, 1, 1, GridBagConstraints.WEST); + modesPanel.add(followeronlyCheckbox, gbc); + + gbc = GuiUtil.makeGbc(1, 13, 1, 1, GridBagConstraints.WEST); + modesPanel.add(followeronlyDuration, gbc); + + gbc = GuiUtil.makeGbc(0, 14, 1, 1, GridBagConstraints.WEST); + modesPanel.add(slowmodeCheckbox, gbc); + + gbc = GuiUtil.makeGbc(1, 14, 1, 1, GridBagConstraints.WEST); + modesPanel.add(slowmodeDuration, gbc); + + gbc = GuiUtil.makeGbc(0, 0, 1, 1); + add(modesPanel, gbc); + + + //=================== + // Command Listeners + //=================== + + addListener(c -> { + return subonlyCheckbox.isSelected() ? "/subscribers" : "/subscribersOff"; + }, subonlyCheckbox); + + addListener(c -> { + return emoteonlyCheckbox.isSelected() ? "/emoteonly" : "/emoteonlyOff"; + }, emoteonlyCheckbox); + + addListener(c -> { + return uniquechatCheckbox.isSelected() ? "/uniquechat" : "/uniquechatOff"; + }, uniquechatCheckbox); + + addListener(c -> { + if (followeronlyCheckbox.isSelected()) { + long duration = followeronlyDuration.getSelectedValue() / 60; + if (duration > 0) { + return "/followers "+duration+"m"; + } + return "/followers"; + } + if (c == followeronlyCheckbox) { + return "/followersOff"; + } + return null; + }, followeronlyCheckbox, followeronlyDuration); + + addListener(c -> { + if (slowmodeCheckbox.isSelected()) { + long duration = slowmodeDuration.getSelectedValue(); + return "/slow " + duration; + } + if (c == slowmodeCheckbox) { + return "/slowOff"; + } + return null; + }, slowmodeCheckbox, slowmodeDuration); + + } + + private final Set> commandListeners = new HashSet<>(); + + public void addCommandListener(Consumer listener) { + if (listener != null) { + commandListeners.add(listener); + } + } + + private void addListener(Function getCommand, JComponent... components) { + for (JComponent c : components) { + if (c instanceof JCheckBox) { + ((JCheckBox) c).addItemListener(e -> { + if (!updating) { + sendCommand(getCommand.apply(c)); + } + }); + } + else if (c instanceof PresetsComboSetting) { + ((PresetsComboSetting) c).addChangeListener(e -> { + if (!updating) { + sendCommand(getCommand.apply(c)); + } + }); + } + } + } + + private void sendCommand(String command) { + if (command == null) { + return; + } + for (Consumer listener : commandListeners) { + listener.accept(command); + } + } + + public void updateState(ChannelState state) { + updating = true; + subonlyCheckbox.setSelected(state.subMode()); + emoteonlyCheckbox.setSelected(state.emoteOnly()); + uniquechatCheckbox.setSelected(state.r9kMode()); + followeronlyCheckbox.setSelected(state.followersOnly() > -1); + if (state.followersOnly() > -1) { + followeronlyDuration.setSelectedValue((long) state.followersOnly() * 60); + } + slowmodeCheckbox.setSelected(state.slowMode() > 0); + if (state.slowMode() > 0) { + slowmodeDuration.setSelectedValue((long) state.slowMode()); + } + updating = false; + } + + /** + * For testing. + */ + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + Settings settings = new Settings("", null); + settings.addString("slowmodeDurations", "1\n2\n3"); + settings.addString("followeronlyDurations", "1\n2\n3"); + + JFrame frame = new JFrame(); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + ModerationPanel panel = new ModerationPanel(frame, settings); + panel.addCommandListener(s -> { + System.out.println(s); + }); + frame.add(panel); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + + Timer timer = new Timer(1000, e -> { + ChannelState state = new ChannelState("test"); + state.setSubMode(true); + state.setSlowMode(10); + panel.updateState(state); + }); + timer.setRepeats(false); + timer.start(); + }); + } + +} diff --git a/src/chatty/gui/components/SimplePopup.java b/src/chatty/gui/components/SimplePopup.java new file mode 100644 index 000000000..f44eb64eb --- /dev/null +++ b/src/chatty/gui/components/SimplePopup.java @@ -0,0 +1,117 @@ + +package chatty.gui.components; + +import chatty.util.Debugging; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.Popup; +import javax.swing.PopupFactory; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.border.Border; +import javax.swing.text.BadLocationException; +import javax.swing.text.JTextComponent; + +/** + * Show a popup in relation to a component that will disappear after a few + * seconds as well as when the mouse is moved over it. + * + * @author tduva + */ +public class SimplePopup { + + private static final Border POPUP_BORDER = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(Color.RED), + BorderFactory.createEmptyBorder(5, 5, 5, 5)); + + private final Component owner; + private final SimplePopupListener listener; + private Popup popup; + private Timer timer; + + public SimplePopup(Component owner, SimplePopupListener listener) { + this.owner = owner; + this.listener = listener; + } + + public void showPopup(String text) { + if (owner == null) { + return; + } + hidePopup(); + + JLabel label = new JLabel(text); + label.setOpaque(false); + label.setBorder(POPUP_BORDER); + label.addMouseMotionListener(new MouseAdapter() { + + private int movedCount; + + @Override + public void mouseMoved(MouseEvent e) { + movedCount++; + /** + * When the label appears with the mouse in it's location a + * moved event is already triggered, however the popup should + * only be removed when the mouse is actually actively moved. + */ + if (movedCount > 1) { + hidePopup(); + if (listener != null) { + listener.popupHidden(); + } + } + } + + }); + + Dimension labelSize = label.getPreferredSize(); + + Point location = owner.getLocationOnScreen(); + if (owner instanceof JTextComponent) { + try { + JTextComponent textComponent = (JTextComponent) owner; + location = textComponent.modelToView(textComponent.getCaretPosition()).getLocation(); + SwingUtilities.convertPointToScreen(location, textComponent); + } catch (BadLocationException ex) { + + } + } + + popup = PopupFactory.getSharedInstance().getPopup(owner, label, location.x, location.y - labelSize.height - 5); + popup.show(); + + if (timer == null) { + timer = new Timer(2000, e -> { + hidePopup(); + if (listener != null) { + listener.popupHidden(); + } + }); + timer.setRepeats(false); + } + timer.start(); + } + + private void hidePopup() { + Debugging.edtLoud(); + if (popup != null) { + popup.hide(); + timer.stop(); + popup = null; + } + } + + public static interface SimplePopupListener { + + public void popupHidden(); + + } + +} diff --git a/src/chatty/gui/components/help/help.html b/src/chatty/gui/components/help/help.html index 52bc109da..ba9ae5085 100644 --- a/src/chatty/gui/components/help/help.html +++ b/src/chatty/gui/components/help/help.html @@ -5,7 +5,7 @@ -

Chatty (Version: 0.26-b3)

+

Chatty (Version: 0.26-b4)

diff --git a/src/chatty/gui/components/routing/RoutingManager.java b/src/chatty/gui/components/routing/RoutingManager.java index bf6b8d8ec..eb85ea7c3 100644 --- a/src/chatty/gui/components/routing/RoutingManager.java +++ b/src/chatty/gui/components/routing/RoutingManager.java @@ -141,12 +141,20 @@ public void addUserMessage(RoutingTargets targets, UserMessage message, User loc } if (ts.shouldLog()) { - chatLog.message(ts.logFile, message.user, message.text, message.action, message.user.getChannel()); + chatLog.message(ts.getPrefixedLogFilename(), message.user, message.text, message.action, message.user.getChannel()); } } } - public void addInfoMessage(RoutingTargets targets, InfoMessage message, User user, User localUser) { + /** + * + * @param targets + * @param message + * @param user + * @param localUser May be null (e.g. if before channel joined) + * @param channel + */ + public void addInfoMessage(RoutingTargets targets, InfoMessage message, User user, User localUser, Channel channel) { if (!filterTargets(targets)) { addRoutingTargets(targets, message, user, localUser); } @@ -163,7 +171,7 @@ public void addInfoMessage(RoutingTargets targets, InfoMessage message, User use InfoMessage thisMessage = message.copy(); thisMessage.routingSource = hlItem; thisMessage.localUser = localUser; - target.addInfoMessage(localUser.getChannel(), thisMessage); + target.addInfoMessage(channel.getChannel(), thisMessage); RoutingTargetSettings ts = getSettings(name); @@ -174,7 +182,7 @@ public void addInfoMessage(RoutingTargets targets, InfoMessage message, User use } if (ts.shouldLog()) { - chatLog.info(ts.logFile, message.text, localUser != null ? localUser.getChannel() : null); + chatLog.info(ts.getPrefixedLogFilename(), message.text, channel.getChannel()); } } } @@ -204,7 +212,7 @@ public void addNotification(String targetName, String channel, InfoMessage msg) } if (ts.shouldLog()) { - chatLog.info(ts.logFile, msg.text, null); + chatLog.info(ts.getPrefixedLogFilename(), msg.text, null); } } diff --git a/src/chatty/gui/components/routing/RoutingTarget.java b/src/chatty/gui/components/routing/RoutingTarget.java index 013b422b2..ba0237cb7 100644 --- a/src/chatty/gui/components/routing/RoutingTarget.java +++ b/src/chatty/gui/components/routing/RoutingTarget.java @@ -150,7 +150,8 @@ public JPopupMenu getContextMenu() { }; this.content.setId(contentId); this.content.setTitle(title); - createTextPane(ALL_CHANNEL_KEY); + // Also adds to "textPanes" + getTextPane(ALL_CHANNEL_KEY); showChannel(null, ALL_CHANNEL_KEY); } diff --git a/src/chatty/gui/components/routing/RoutingTargetSettings.java b/src/chatty/gui/components/routing/RoutingTargetSettings.java index 542fe4488..d6172416d 100644 --- a/src/chatty/gui/components/routing/RoutingTargetSettings.java +++ b/src/chatty/gui/components/routing/RoutingTargetSettings.java @@ -54,20 +54,20 @@ public class RoutingTargetSettings { public final boolean exclusive; private final String id; public final boolean logEnabled; - public final String logFile; + private final String logFilename; public final int multiChannel; public final boolean channelFixed; public final boolean showAll; public RoutingTargetSettings(String targetName, int openOnMessage, - boolean exlusive, boolean logEnabled, String logFile, + boolean exlusive, boolean logEnabled, String logFilename, int multiChannel, boolean channelFixed, boolean showAll) { this.targetName = targetName; this.openOnMessage = openOnMessage; this.exclusive = exlusive; this.id = RoutingManager.toId(targetName); this.logEnabled = logEnabled; - this.logFile = logFile; + this.logFilename = logFilename; this.multiChannel = multiChannel; this.channelFixed = channelFixed; this.showAll = showAll; @@ -82,10 +82,40 @@ public String getId() { } public boolean shouldLog() { - return logEnabled && logFile != null && !logFile.isEmpty(); + return logEnabled && logFilename != null && !logFilename.isEmpty(); } - public String makeInfo() { + /** + * The part of the name chosen by the user. + * + * @return + */ + public String getRawLogFilename() { + return logFilename; + } + + /** + * The filename including the prefix. May also be used as directory name. + * The ".log" suffix for chat log files (not directories) will get added + * when actually writing the file. + * + * @return + */ + public String getPrefixedLogFilename() { + return shouldLog() ? "customTab-"+logFilename : null; + } + + /** + * The filename including the prefix and suffix added when writing the file. + * Used for display only. + * + * @return + */ + public String getFullLogFilename() { + return shouldLog() ? "customTab-"+logFilename+".log" : null; + } + + public String makeSettingsInfo() { String info = openOnMessageValues.get((long) openOnMessage); if (multiChannel == 1) { info += ", by channel"; @@ -93,9 +123,6 @@ public String makeInfo() { else if (multiChannel == 2) { info += ", by channel/all"; } - if (shouldLog()) { - info += ", write to "+logFile+".log"; - } return info; } @@ -109,14 +136,14 @@ public RoutingTargetSettings setChannelFixed(boolean newValue) { if (channelFixed == newValue) { return this; } - return new RoutingTargetSettings(targetName, openOnMessage, exclusive, logEnabled, logFile, multiChannel, newValue, showAll); + return new RoutingTargetSettings(targetName, openOnMessage, exclusive, logEnabled, logFilename, multiChannel, newValue, showAll); } public RoutingTargetSettings setShowAll(boolean newValue) { if (showAll == newValue) { return this; } - return new RoutingTargetSettings(targetName, openOnMessage, exclusive, logEnabled, logFile, multiChannel, channelFixed, newValue); + return new RoutingTargetSettings(targetName, openOnMessage, exclusive, logEnabled, logFilename, multiChannel, channelFixed, newValue); } public List toList() { @@ -125,7 +152,7 @@ public List toList() { result.add(openOnMessage); result.add(exclusive ? 1 : 0); result.add(logEnabled ? 1 : 0); - result.add(logFile); + result.add(logFilename); result.add(multiChannel); result.add(channelFixed ? 1 : 0); result.add(showAll ? 1 : 0); diff --git a/src/chatty/gui/components/settings/ChannelFormatter.java b/src/chatty/gui/components/settings/ChannelFormatter.java new file mode 100644 index 000000000..192544a81 --- /dev/null +++ b/src/chatty/gui/components/settings/ChannelFormatter.java @@ -0,0 +1,30 @@ + +package chatty.gui.components.settings; + +import chatty.util.StringUtil; + +public class ChannelFormatter implements DataFormatter { + + /** + * Prepends the input with a "#" if not already present. Returns + * {@code null} if the length after prepending is only 1, which means it + * only consists of the "#" and is invalid. + * + * @param input The input to be formatted + * @return The formatted input, which has the "#" prepended, or {@code null} + * or any empty String if the input was invalid + */ + @Override + public String format(String input) { + if (input != null && !input.isEmpty() + && !input.startsWith("#") + && !input.startsWith("$")) { + input = "#" + input; + } + if (input != null && input.length() == 1) { + input = null; + } + return StringUtil.toLowerCase(input); + } + +} diff --git a/src/chatty/gui/components/settings/ChatSettings.java b/src/chatty/gui/components/settings/ChatSettings.java index 734e5651f..fdfb077f7 100644 --- a/src/chatty/gui/components/settings/ChatSettings.java +++ b/src/chatty/gui/components/settings/ChatSettings.java @@ -58,11 +58,6 @@ public ChatSettings(SettingsDialog d) { main.add(bufferSizesButton, gbc); - gbc = d.makeGbc(0, 5, 3, 1, GridBagConstraints.WEST); - main.add(d.addSimpleBooleanSetting("inputHistoryMultirowRequireCtrl", - "On a multirow inputbox require Ctrl to navigate input history", - null), gbc); - gbc = d.makeGbc(0, 6, 3, 1, GridBagConstraints.WEST); main.add(d.addSimpleBooleanSetting("showImageTooltips"), gbc); diff --git a/src/chatty/gui/components/settings/DurationSetting.java b/src/chatty/gui/components/settings/DurationSetting.java index 3dc70f52b..5973469ad 100644 --- a/src/chatty/gui/components/settings/DurationSetting.java +++ b/src/chatty/gui/components/settings/DurationSetting.java @@ -14,7 +14,7 @@ public class DurationSetting extends JTextField implements LongSetting { public DurationSetting(int size, boolean editable) { super(size); setEditable(editable); - ((AbstractDocument)getDocument()).setDocumentFilter(new RegexDocumentFilter("[^\\dms]+")); + ((AbstractDocument)getDocument()).setDocumentFilter(new RegexDocumentFilter("[^\\dms]+", this)); } @Override diff --git a/src/chatty/gui/components/settings/HighlightSettings.java b/src/chatty/gui/components/settings/HighlightSettings.java index b63bbc645..1822dfacc 100644 --- a/src/chatty/gui/components/settings/HighlightSettings.java +++ b/src/chatty/gui/components/settings/HighlightSettings.java @@ -158,7 +158,7 @@ public HighlightSettings(SettingsDialog d) { base.add(highlightBlacklistButton, gbc); JButton presetsButton = new JButton("Presets"); - presetsButton.setMargin(GuiUtil.SMALLER_BUTTON_INSETS); + GuiUtil.smallButtonInsets(presetsButton); presetsButton.addActionListener(e -> { d.showMatchingPresets(); }); @@ -168,7 +168,7 @@ public HighlightSettings(SettingsDialog d) { base.add(presetsButton, gbc); JButton substitutesButton = new JButton("Substitutes / Lookalikes"); - substitutesButton.setMargin(GuiUtil.SMALLER_BUTTON_INSETS); + GuiUtil.smallButtonInsets(substitutesButton); substitutesButton.addActionListener(e -> { substitutes.show(HighlightSettings.this); }); diff --git a/src/chatty/gui/components/settings/HistorySettings.java b/src/chatty/gui/components/settings/HistorySettings.java index 25c939bdc..02f0855ed 100644 --- a/src/chatty/gui/components/settings/HistorySettings.java +++ b/src/chatty/gui/components/settings/HistorySettings.java @@ -1,69 +1,73 @@ package chatty.gui.components.settings; +import chatty.gui.components.LinkLabel; import java.awt.FlowLayout; import java.awt.GridBagConstraints; import java.awt.Insets; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; + import javax.swing.JButton; +import javax.swing.JCheckBox; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; + /** * * @author tduva */ public class HistorySettings extends SettingsPanel implements ActionListener { - + private final JButton clearHistory = new JButton("Clear history"); private final SettingsDialog d; //private final JButton removeOld = new JButton("Remove old entries"); - + public HistorySettings(SettingsDialog d) { this.d = d; // History group JPanel main = addTitledPanel("Channel History (shown in the Favorites/History Dialog)", 0); - + GridBagConstraints gbc; - + gbc = d.makeGbc(0, 0, 1, 1); gbc.anchor = GridBagConstraints.WEST; main.add(d.addSimpleBooleanSetting("saveChannelHistory", "Enable History", "If enabled, automatically add joined channels to the history"), gbc); - + JPanel days = new JPanel(); ((FlowLayout)days.getLayout()).setVgap(0); days.add(d.addSimpleBooleanSetting("historyClear", "Only keep channels joined in the last ", "")); days.add(d.addSimpleLongSetting("channelHistoryKeepDays", 3, true)); days.add(new JLabel("days")); - + gbc = d.makeGbc(0, 2, 1, 1); gbc.anchor = GridBagConstraints.WEST; gbc.insets = new Insets(0,20,5,5); main.add(days, gbc); - + gbc = d.makeGbc(0, 3, 1, 1); gbc.insets = new Insets(5, 20, 10, 5); main.add(new JLabel("" + "Expired entries (defined as per the setting above) " + "are automatically deleted from the history " + "when you start Chatty."), gbc); - + gbc = d.makeGbc(0, 4, 1, 1); gbc.anchor = GridBagConstraints.WEST; main.add(clearHistory, gbc); clearHistory.addActionListener(this); - + JPanel presets = addTitledPanel("Status Presets (shown in the Presets in the Admin Dialog)", 1); - + gbc = d.makeGbc(0, 0, 1, 1); gbc.anchor = GridBagConstraints.WEST; presets.add(d.addSimpleBooleanSetting("saveStatusHistory", "Enable History", "If enabled, automatically add used status (title/game) to the history"), gbc); - + JPanel daysPresets = new JPanel(); ((FlowLayout)daysPresets.getLayout()).setVgap(0); daysPresets.add(d.addSimpleBooleanSetting("statusHistoryClear", @@ -71,12 +75,45 @@ public HistorySettings(SettingsDialog d) { "Whether to remove old status history entries.")); daysPresets.add(d.addSimpleLongSetting("statusHistoryKeepDays", 3, true)); daysPresets.add(new JLabel("days")); - + gbc = d.makeGbc(0, 2, 1, 1); gbc.anchor = GridBagConstraints.WEST; gbc.insets = new Insets(0,20,5,5); presets.add(daysPresets, gbc); + + JPanel externalHistory = addTitledPanel("History Service", 2); + + externalHistory.add(new LinkLabel(SettingConstants.HTML_PREFIX + + "Chatty uses the [url:https://recent-messages.robotty.de recent-messages.robotty.de] API " + + "to get messages of the last 24 hours when joining a channel.", + d.getLinkLabelListener()), + SettingsDialog.makeGbc(0, 0, 2, 1, GridBagConstraints.NORTH)); + + JCheckBox historyServiceEnabled = d.addSimpleBooleanSetting("historyServiceEnabled", + "Enable History Service", + "Use an external service to get the channel history"); + externalHistory.add(historyServiceEnabled, + SettingsDialog.makeGbc(0, 1, 2, 1, GridBagConstraints.WEST)); + + ComboLongSetting historyServiceLimit = d.addComboLongSetting("historyServiceLimit", + 5, 10, 15, 20, + 30, 40, 50, 60, + 70, 80, 90, 100); + SettingsUtil.addStandardSetting(externalHistory, "historyServiceLimit", 2, historyServiceLimit, true); + + externalHistory.add(new JLabel("Excluded channels:"), + SettingsDialog.makeGbcSub2(0, 3, 2, 1, GridBagConstraints.WEST)); + + ListSelector excludedChannels = d.addListSetting("historyServiceExcluded", + "Channel to be excluded from history", + 250, 200, + false, true); + final ChannelFormatter formatter = new ChannelFormatter(); + excludedChannels.setDataFormatter(formatter); + externalHistory.add(excludedChannels, SettingsDialog.makeGbcSub2(0, 4, 2, 1, GridBagConstraints.WEST)); + + SettingsUtil.addSubsettings(historyServiceEnabled, historyServiceLimit, excludedChannels); } @Override @@ -93,5 +130,4 @@ public void actionPerformed(ActionEvent e) { } } } - } diff --git a/src/chatty/gui/components/settings/LogSettings.java b/src/chatty/gui/components/settings/LogSettings.java index 2b5568687..bd78a89e6 100644 --- a/src/chatty/gui/components/settings/LogSettings.java +++ b/src/chatty/gui/components/settings/LogSettings.java @@ -8,7 +8,6 @@ import chatty.gui.GuiUtil; import static chatty.gui.components.settings.MessageSettings.addTimestampFormat; import chatty.lang.Language; -import chatty.util.StringUtil; import chatty.util.chatlog.ChatLog; import chatty.util.commands.CustomCommand; import chatty.util.irc.IrcBadges; @@ -214,6 +213,8 @@ public String test(Window parent, Component component, int x, int y, String valu d.makeGbcCloser(0, 0, 1, 1, GridBagConstraints.WEST)); extraPanel.add(d.addSimpleBooleanSetting("logIgnored2"), d.makeGbcCloser(0, 1, 1, 1, GridBagConstraints.WEST)); + extraPanel.add(new JLabel(""+Language.getString("settings.customTabSettings.logInfo2")), + d.makeGbc(0, 2, 1, 1, GridBagConstraints.WEST)); JPanel otherSettings = createTitledPanel(Language.getString("settings.log.section.other")); @@ -315,30 +316,4 @@ private void update() { info.setText(""+infoText); cardManager.show(cards, switchTo); } - - private static class ChannelFormatter implements DataFormatter { - - /** - * Prepends the input with a "#" if not already present. Returns - * {@code null} if the length after prepending is only 1, which means - * it only consists of the "#" and is invalid. - * - * @param input The input to be formatted - * @return The formatted input, which has the "#" prepended, or - * {@code null} or any empty String if the input was invalid - */ - @Override - public String format(String input) { - if (input != null && !input.isEmpty() && !input.startsWith("#") - && !input.startsWith("$")) { - input = "#"+input; - } - if (input.length() == 1) { - input = null; - } - return StringUtil.toLowerCase(input); - } - - } - } diff --git a/src/chatty/gui/components/settings/LongTextField.java b/src/chatty/gui/components/settings/LongTextField.java index 9acbbfdbf..21604e1a0 100644 --- a/src/chatty/gui/components/settings/LongTextField.java +++ b/src/chatty/gui/components/settings/LongTextField.java @@ -25,7 +25,7 @@ public LongTextField(int size, boolean editable) { setEditable(editable); setInputVerifier(new IntegerVerifier()); - ((AbstractDocument)getDocument()).setDocumentFilter(new RegexDocumentFilter("\\D+")); + ((AbstractDocument)getDocument()).setDocumentFilter(new RegexDocumentFilter("\\D+", this)); getDocument().addDocumentListener(new DocumentListener() { @Override diff --git a/src/chatty/gui/components/settings/LookSettings.java b/src/chatty/gui/components/settings/LookSettings.java index 1cd9521b3..716dfa01a 100644 --- a/src/chatty/gui/components/settings/LookSettings.java +++ b/src/chatty/gui/components/settings/LookSettings.java @@ -5,6 +5,7 @@ import chatty.gui.laf.LaF.LaFSettings; import chatty.gui.components.LinkLabel; import chatty.gui.laf.FlatLafUtil; +import chatty.gui.laf.LaFChanger; import chatty.lang.Language; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; @@ -69,8 +70,7 @@ protected LookSettings(SettingsDialog d) { JButton lafPreviewButton = new JButton("Preview"); lafPreviewButton.addActionListener(e -> { - LaF.setLookAndFeel(LaFSettings.fromSettingsDialog(d, d.settings)); - LaF.updateLookAndFeel(); + LaFChanger.changeLookAndFeel(LaFSettings.fromSettingsDialog(d, d.settings), this); d.lafPreviewed = true; d.pack(); }); diff --git a/src/chatty/gui/components/settings/ModerationSettings.java b/src/chatty/gui/components/settings/ModerationSettings.java index 2b12f7192..90f107887 100644 --- a/src/chatty/gui/components/settings/ModerationSettings.java +++ b/src/chatty/gui/components/settings/ModerationSettings.java @@ -177,7 +177,7 @@ private static class TestSimilarity extends JDialog { * Remove all characters that are not in the BMP (except surrogates, so * do remove those). */ - public static DocumentFilter IGNORED_CHARS_FILTER = new RegexDocumentFilter("[^\u0000-\uD7FF\uE000-\uFFFF]"); + public static DocumentFilter IGNORED_CHARS_FILTER = new RegexDocumentFilter("[^\u0000-\uD7FF\uE000-\uFFFF]", null); private static final String DEFAULT_EXAMPLE_A = "Have you already checked out Chatty's YouTube channel? Might have some useful video guides."; private static final String DEFAULT_EXAMPLE_B = "Chatty's YouTube channel might have some useful video guides. Have you checked it out yet?"; diff --git a/src/chatty/gui/components/settings/PresetsComboSetting.java b/src/chatty/gui/components/settings/PresetsComboSetting.java new file mode 100644 index 000000000..fa773e54b --- /dev/null +++ b/src/chatty/gui/components/settings/PresetsComboSetting.java @@ -0,0 +1,336 @@ + +package chatty.gui.components.settings; + +import chatty.lang.Language; +import chatty.util.settings.Settings; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.KeyboardFocusManager; +import java.awt.Window; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Function; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.plaf.basic.BasicComboBoxRenderer; + +/** + * Combo setting where the preset values are read from the settings. + * + * @author tduva + */ +public class PresetsComboSetting extends JPanel { + + private final GenericComboSetting combo = new GenericComboSetting<>(); + private final Settings settings; + + private final Editor settingEditor; + private final JTextField customValueInput = new JTextField(); + + private final GridBagConstraints customInputGbc; + + private final String settingName; + + private final Function stringToValue; + private final Function valueToString; + + private final String defaultLabel; + private final String customValueLabel; + private final boolean customInputEnabled; + + private final boolean shortcuts; + + private final int[] codes = new int[]{KeyEvent.VK_1, + KeyEvent.VK_2, KeyEvent.VK_3, KeyEvent.VK_4, KeyEvent.VK_5, + KeyEvent.VK_6, KeyEvent.VK_7, KeyEvent.VK_8, KeyEvent.VK_9}; + + private final Set listeners = new HashSet<>(); + private boolean isUpdating; + + /** + * + * + * @param parent + * @param settings + * @param settingName The setting the presets are saved in + * @param stringToValue Parse a value from a string (for loading presets) + * @param valueToString Turn a value into a display string + * @param defaultLabel Add a default entry with value null + * @param customValueLabel Add an entry that shows an inputbox when selected + * that allows entering a custom value + * @param shortcuts Whether to show shortcuts for the entries + */ + public PresetsComboSetting(final Window parent, Settings settings, String settingName, + Function stringToValue, + Function valueToString, + String defaultLabel, + String customValueLabel, + boolean shortcuts) { + this.settings = settings; + this.settingName = settingName; + this.stringToValue = stringToValue; + this.valueToString = valueToString; + this.defaultLabel = defaultLabel; + this.customValueLabel = customValueLabel; + this.shortcuts = shortcuts; + this.customInputEnabled = customValueLabel != null; + + setLayout(new GridBagLayout()); + + JButton editButton = new JButton(); + editButton.setIcon(new ImageIcon(SettingsDialog.class.getResource("edit.png"))); + editButton.setMargin(new Insets(0, 2, 0, 2)); + editButton.addActionListener((ActionEvent e) -> { + editPresets(); + }); + + settingEditor = new Editor(parent); + settingEditor.setAllowEmpty(true); + settingEditor.setAllowLinebreaks(true); + + final GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridx = 0; + gbc.gridy = 0; + add(combo, gbc); + gbc.gridx = 1; + gbc.fill = GridBagConstraints.VERTICAL; + add(editButton, gbc); + customInputGbc = new GridBagConstraints(); + customInputGbc.gridx = 0; + customInputGbc.gridy = 1; + customInputGbc.gridwidth = 2; + customInputGbc.fill = GridBagConstraints.HORIZONTAL; + add(customValueInput, customInputGbc); + + /** + * Custom renderer to display the value of the items for the selected + * item, instead of the label (hide the shortcut). + */ + combo.setRenderer(new BasicComboBoxRenderer() { + + @Override + public Component getListCellRendererComponent(JList list, + Object value, int index, boolean isSelected, + boolean hasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, hasFocus); + + if (index == -1 && value != null) { + String text = valueToString.apply(((GenericComboSetting.Entry) value).value); + if (text != null && !text.isEmpty()) { + setText(text); + } + } + return this; + } + + }); + + /** + * Add/remove custom value input box if last item is selected. + */ + combo.addActionListener((ActionEvent e) -> { + if (customInputEnabled && combo.getItemCount() > 1 && combo.getSelectedIndex() == combo.getItemCount() - 1) { + addCustomInput(); + } + else { + removeCustomInput(); + } + if (!isUpdating) { + listeners.forEach(l -> l.actionPerformed(e)); + } + }); + + /** + * When the popup is open, allow shortcuts to select items. + */ + combo.addKeyListener(new KeyAdapter() { + + @Override + public void keyPressed(KeyEvent e) { + if (!combo.isPopupVisible()) { + return; + } + for (int i = 0; i < codes.length; i++) { + if (codes[i] == e.getKeyCode()) { + int indexToSelect = i; + if (defaultLabel != null) { + indexToSelect++; + } + int indexToCheck = indexToSelect; + if (customInputEnabled) { + indexToCheck++; + } + if (combo.getItemCount() > indexToCheck) { + combo.setPopupVisible(false); + combo.setSelectedIndex(indexToSelect); + } + e.consume(); + } + } + if (e.getKeyCode() == KeyEvent.VK_C && customInputEnabled) { + combo.setSelectedIndex(combo.getItemCount() - 1); + e.consume(); + } + } + }); + + customValueInput.addActionListener((ActionEvent e) -> { + KeyboardFocusManager.getCurrentKeyboardFocusManager().focusPreviousComponent(); + }); + customValueInput.addKeyListener(new KeyAdapter() { + + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_UP) { + combo.requestFocusInWindow(); + combo.setPopupVisible(true); + combo.setSelectedIndex(combo.getItemCount() - 2); + } + } + }); + } + + public void init() { + updatePresetsFromSettings(); + reset(); + } + + private void editPresets() { + String currentPresets = settings.getString(settingName); + String editedPresets = settingEditor.showDialog( + Language.getString("settings.editor." + settingName), + currentPresets, + null); + if (editedPresets != null) { + settings.setString(settingName, editedPresets); + updatePresetsFromSettings(); + } + } + + public void updatePresetsFromSettings() { + isUpdating = true; + + E selectedBefore = getSelectedValue(); + + String values = settings.getString(settingName); + String[] split = values.split("\n"); + combo.removeAllItems(); + if (defaultLabel != null) { + combo.add(stringToValue.apply(null), defaultLabel); + } + for (int i = 0; i < split.length; i++) { + if (!split[i].trim().isEmpty()) { + String shortcut = "-"; + if (codes.length > i) { + shortcut = KeyEvent.getKeyText(codes[i]); + } + E value = stringToValue.apply(split[i]); + String shortcutLabel = ""; + if (shortcuts) { + shortcutLabel = "[" + shortcut + "] "; + } + combo.add(value, shortcutLabel + valueToString.apply(value)); + } + } + if (customInputEnabled) { + combo.add(stringToValue.apply(""), "[C] " + customValueLabel); + } + + if (selectedBefore != null) { + setSelectedValue(selectedBefore); + } + + isUpdating = false; + } + + public E getSelectedValue() { + int index = combo.getSelectedIndex(); + if ((index == 0 && customInputEnabled) || index == -1) { + return null; + } + if (customInputEnabled && index == combo.getItemCount() - 1) { + return stringToValue.apply(customValueInput.getText()); + } + return combo.getSettingValue(); + } + + public void setSelectedValue(E value) { + isUpdating = true; + if (!combo.containsValue(value)) { + combo.add(value, valueToString.apply(value)); + } + combo.setSettingValue(value); + isUpdating = false; + } + + private void reset() { + isUpdating = true; + if (combo.getItemCount() > 0) { + combo.setSelectedIndex(0); + } + isUpdating = false; + } + + private void addCustomInput() { + add(customValueInput, customInputGbc); + revalidate(); + customValueInput.requestFocusInWindow(); + customValueInput.setSelectionStart(0); + customValueInput.setSelectionEnd(customValueInput.getText().length()); + } + + private void removeCustomInput() { + remove(customValueInput); + revalidate(); + } + + public void addChangeListener(ActionListener listener) { + if (listener != null) { + listeners.add(listener); + } + } + + /** + * For testing. + */ + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + Settings settings = new Settings("", null); + settings.addString("testSetting", "1\n2\n3"); + + JFrame frame = new JFrame(); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + PresetsComboSetting settingPanel = new PresetsComboSetting<>(frame, settings, "testSetting", + s -> { + if (s == null) { + return -2L; + } + if (s.isEmpty()) { + return -1L; + } + return Long.valueOf(s); + }, + v -> String.valueOf(v), + "Relatively wide default label", "Custom value", false); + settingPanel.init(); + + frame.add(settingPanel, BorderLayout.CENTER); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + }); + } + +} diff --git a/src/chatty/gui/components/settings/RoutingSettingsTable.java b/src/chatty/gui/components/settings/RoutingSettingsTable.java index 3fa108257..df8412c62 100644 --- a/src/chatty/gui/components/settings/RoutingSettingsTable.java +++ b/src/chatty/gui/components/settings/RoutingSettingsTable.java @@ -4,8 +4,11 @@ import chatty.gui.GuiUtil; import chatty.gui.RegexDocumentFilter; import chatty.gui.components.routing.RoutingTargetSettings; +import static chatty.gui.components.settings.SettingsUtil.createLabel; import static chatty.gui.components.settings.TableEditor.SORTING_MODE_MANUAL; import chatty.lang.Language; +import chatty.util.FileUtil; +import chatty.util.StringUtil; import java.awt.Component; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; @@ -32,6 +35,10 @@ */ public class RoutingSettingsTable extends TableEditor { + private static final int NAME_COLUMN = 0; + private static final int SETTINGS_COLUMN = 1; + private static final int LOG_COLUMN = 2; + private final MyTableModel data; private MyItemEditor editor; @@ -47,21 +54,25 @@ public RoutingSettingsTable(JDialog owner, Component info) { return editor; }); - setFixedColumnWidth(0, 200); + setFixedColumnWidth(NAME_COLUMN, 180); } private static class MyTableModel extends ListTableModel { public MyTableModel() { - super(new String[]{"Name", "Settings"}); + super(new String[]{"Name", "Settings", "Log file"}); } @Override public Object getValueAt(int rowIndex, int columnIndex) { RoutingTargetSettings entry = get(rowIndex); switch (columnIndex) { - case 0: return entry.getName(); - case 1: return entry.makeInfo(); + case NAME_COLUMN: + return entry.getName(); + case SETTINGS_COLUMN: + return entry.makeSettingsInfo(); + case LOG_COLUMN: + return entry.getFullLogFilename(); } return ""; } @@ -86,6 +97,8 @@ public Class getColumnClass(int c) { public static class MyItemEditor implements TableEditor.ItemEditor { + private static final String HTML_PREFIX = ""; + private final JDialog dialog; private final JTextField name = new JTextField(10); private final JCheckBox logEnabled = new JCheckBox(Language.getString("settings.customTabSettings.logEnabled")); @@ -116,7 +129,7 @@ public MyItemEditor(JDialog owner, Component info) { SettingsUtil.setTextAndTooltip(multiChannelSepAndAll, "settings.customTabSettings.multiChannelSepAndAll"); SettingsUtil.setTextAndTooltip(channelFixed, "settings.customTabSettings.channelFixed"); - ((AbstractDocument) logFile.getDocument()).setDocumentFilter(new RegexDocumentFilter("[^a-zA-Z]")); + ((AbstractDocument) logFile.getDocument()).setDocumentFilter(new RegexDocumentFilter(FileUtil.ILLEGAL_FILENAME_CHARACTERS_PATTERN.pattern(), logFile)); name.getDocument().addDocumentListener(new DocumentListener() { @@ -193,7 +206,7 @@ public void actionPerformed(ActionEvent e) { multiChannelPanel.add(channelFixed, gbc); gbc = SettingsDialog.makeGbc(0, 4, 2, 1, GridBagConstraints.CENTER); - multiChannelPanel.add(new JLabel("Switch channels through the context menu. Changing this setting only applies to new messages."), gbc); + multiChannelPanel.add(new JLabel(HTML_PREFIX+"Switch channels through the context menu. Changing this setting only applies to new messages."), gbc); SettingsUtil.addSubsettings( new JRadioButton[]{multiChannelSep, multiChannelSepAndAll}, @@ -205,20 +218,33 @@ public void actionPerformed(ActionEvent e) { JPanel logPanel = new JPanel(new GridBagLayout()); logPanel.setBorder(BorderFactory.createTitledBorder("Log to file")); - gbc = GuiUtil.makeGbc(0, 0, 3, 1, GridBagConstraints.WEST); + // Log enabled + gbc = GuiUtil.makeGbc(0, 0, 4, 1, GridBagConstraints.WEST); logPanel.add(logEnabled, gbc); - SettingsUtil.addLabeledComponent(logPanel, - "settings.customTabSettings.logFile", - 0, 1, 1, GridBagConstraints.WEST, - logFile, true); + // Log file name + JLabel logFileLabel = createLabel("settings.customTabSettings.logFile"); + logFileLabel.setLabelFor(logFile); + gbc = GuiUtil.makeGbc(0, 1, 1, 1, GridBagConstraints.WEST); + logPanel.add(logFileLabel, gbc); + + gbc = GuiUtil.makeGbc(1, 1, 1, 1, GridBagConstraints.WEST); + gbc.insets = new Insets(5, 0, 5, 0); + logPanel.add(new JLabel("customTab-"), gbc); gbc = GuiUtil.makeGbc(2, 1, 1, 1, GridBagConstraints.WEST); + gbc.insets = new Insets(5, 2, 5, 2); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1; + logPanel.add(logFile, gbc); + + gbc = GuiUtil.makeGbc(3, 1, 1, 1, GridBagConstraints.WEST); gbc.insets = new Insets(5, 0, 5, 5); logPanel.add(new JLabel(".log"), gbc); - gbc = GuiUtil.makeGbc(0, 2, 3, 1, GridBagConstraints.WEST); - logPanel.add(new JLabel(Language.getString("settings.customTabSettings.logInfo")), gbc); + // Log info + gbc = GuiUtil.makeGbc(0, 2, 4, 1, GridBagConstraints.WEST); + logPanel.add(new JLabel(HTML_PREFIX+Language.getString("settings.customTabSettings.logInfo")), gbc); gbc = GuiUtil.makeGbc(0, 7, 3, 1); gbc.fill = GridBagConstraints.HORIZONTAL; @@ -260,7 +286,10 @@ public RoutingTargetSettings showEditor(RoutingTargetSettings preset, Component openOnMessage.setSettingValue((long) preset.openOnMessage); exclusive.setSelected(preset.exclusive); logEnabled.setSelected(preset.logEnabled); - logFile.setText(preset.logFile); + logFile.setText(preset.getRawLogFilename()); + if (StringUtil.isNullOrEmpty(preset.getRawLogFilename())) { + logFile.setText(preset.getName()); + } switch (preset.multiChannel) { case 0: multiChannelAll.setSelected(true); diff --git a/src/chatty/gui/components/settings/SettingsDialog.java b/src/chatty/gui/components/settings/SettingsDialog.java index 8243b548f..099bef913 100644 --- a/src/chatty/gui/components/settings/SettingsDialog.java +++ b/src/chatty/gui/components/settings/SettingsDialog.java @@ -99,14 +99,14 @@ public static void get(MainGui g, Consumer action) { private final Set restartRequiredDef = new HashSet<>(Arrays.asList( "ffz", "nod3d", "noddraw", "userlistWidth", "userlistMinWidth", "userlistEnabled", - "capitalizedNames", "correctlyCapitalizedNames", "ircv3CapitalizedNames", + "capitalizedNames", "correctlyCapitalizedNames", "inputFont", "bttvEmotes", "botNamesBTTV", "botNamesFFZ", "ffzEvent", "seventv", "logPath", "logTimestamp", "logSplit", "logSubdirectories", "logLockFiles", "logMessageTemplate", "laf", "lafTheme", "lafFontScale", "language", "timezone", "locale", "userDialogMessageLimit", "cachePath", "imgPath", "exportPath", - "webp" + "webp", "inputLimitsEnabled" )); private final Set reconnectRequiredDef = new HashSet<>(Arrays.asList( @@ -804,6 +804,17 @@ protected static GridBagConstraints makeGbcSub(int x, int y, int w, int h, int a return gbc; } + protected static GridBagConstraints makeGbcSub2(int x, int y, int w, int h, int anchor) { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.gridx = x; + gbc.gridy = y; + gbc.gridwidth = w; + gbc.gridheight = h; + gbc.insets = new Insets(3,18,3,5); + gbc.anchor = anchor; + return gbc; + } + protected static GridBagConstraints makeGbcStretchHorizontal(int x, int y, int w, int h) { GridBagConstraints gbc = new GridBagConstraints(); gbc.gridx = x; diff --git a/src/chatty/gui/components/settings/SettingsUtil.java b/src/chatty/gui/components/settings/SettingsUtil.java index e01b51c94..4a0685bb3 100644 --- a/src/chatty/gui/components/settings/SettingsUtil.java +++ b/src/chatty/gui/components/settings/SettingsUtil.java @@ -177,10 +177,14 @@ public static void addLabeledComponent(JPanel panel, String labelSettingName, in } public static void addLabeledComponent(JPanel panel, String labelSettingName, int x, int y, int w, int labelAlign, JComponent component, boolean stretchComponent) { + addLabeledComponent(panel, labelSettingName, x, y, w, labelAlign, component, stretchComponent, false); + } + + public static void addLabeledComponent(JPanel panel, String labelSettingName, int x, int y, int w, int labelAlign, JComponent component, boolean stretchComponent, boolean sub) { JLabel label = createLabel(labelSettingName); label.setLabelFor(component); - panel.add(label, SettingsDialog.makeGbc(x, y, 1, 1, labelAlign)); - GridBagConstraints gbc = SettingsDialog.makeGbc(x+1, y, w, 1, GridBagConstraints.WEST); + panel.add(label, SettingsDialog.makeGbcSub(x, y, 1, 1, labelAlign)); + GridBagConstraints gbc = SettingsDialog.makeGbc(x + 1, y, w, 1, GridBagConstraints.WEST); if (stretchComponent) { gbc.fill = GridBagConstraints.BOTH; } @@ -210,7 +214,7 @@ public static void addStandardSetting(JPanel panel, String name, int y, JCompone } } else { - addLabeledComponent(panel, name, 0, y, 1, GridBagConstraints.EAST, component); + addLabeledComponent(panel, name, 0, y, 1, GridBagConstraints.EAST, component, false, sub); } } diff --git a/src/chatty/gui/components/settings/SimpleTableEditor.java b/src/chatty/gui/components/settings/SimpleTableEditor.java index 85b966cd9..5d3c24f4c 100644 --- a/src/chatty/gui/components/settings/SimpleTableEditor.java +++ b/src/chatty/gui/components/settings/SimpleTableEditor.java @@ -68,14 +68,14 @@ public void edit(String item) { protected abstract T valueFromString(String input); public void setKeyFilter(String p) { - keyFilter = new RegexDocumentFilter(p); + keyFilter = new RegexDocumentFilter(p, this); if (editor != null) { editor.setKeyFilter(keyFilter); } } public void setValueFilter(String p) { - valueFilter = new RegexDocumentFilter(p); + valueFilter = new RegexDocumentFilter(p, this); if (editor != null) { editor.setValueFilter(valueFilter); } @@ -316,11 +316,20 @@ private void updateButtons() { } public void setKeyFilter(DocumentFilter keyFilter) { - ((AbstractDocument)key.getDocument()).setDocumentFilter(keyFilter); + setFilter(key, keyFilter); } - public void setValueFilter(DocumentFilter keyFilter) { - ((AbstractDocument)value.getDocument()).setDocumentFilter(keyFilter); + public void setValueFilter(DocumentFilter valueFilter) { + setFilter(value, valueFilter); } + + private void setFilter(JTextField textField, DocumentFilter filter) { + if (filter instanceof RegexDocumentFilter) { + // Set owner for invalid input popups for this type of filter + filter = new RegexDocumentFilter(((RegexDocumentFilter) filter).getRegex(), textField); + } + ((AbstractDocument)textField.getDocument()).setDocumentFilter(filter); + } + } } diff --git a/src/chatty/gui/components/settings/WindowSettings.java b/src/chatty/gui/components/settings/WindowSettings.java index 6309e3b83..6896ddc83 100644 --- a/src/chatty/gui/components/settings/WindowSettings.java +++ b/src/chatty/gui/components/settings/WindowSettings.java @@ -64,37 +64,50 @@ public WindowSettings(final SettingsDialog d) { d.makeGbc(0, 4, 3, 1, GridBagConstraints.WEST)); //------- - // Other + // Chat //------- - JPanel other = addTitledPanel(Language.getString("settings.section.otherWindow"), 2); - - other.add(d.addSimpleBooleanSetting("urlPrompt"), - d.makeGbc(0, 0, 1, 1, GridBagConstraints.WEST)); + JPanel chatSettings = addTitledPanel(Language.getString("settings.section.otherChat"), 2); - other.add(d.addSimpleBooleanSetting("chatScrollbarAlways"), - d.makeGbc(1, 0, 1, 1, GridBagConstraints.WEST)); + chatSettings.add(d.addSimpleBooleanSetting("chatScrollbarAlways"), + SettingsDialog.makeGbc(0, 0, 2, 1, GridBagConstraints.WEST)); - other.add(d.addSimpleBooleanSetting("userlistEnabled"), - d.makeGbc(0, 4, 2, 1, GridBagConstraints.WEST)); + chatSettings.add(d.addSimpleBooleanSetting("userlistEnabled"), + SettingsDialog.makeGbc(0, 5, 2, 1, GridBagConstraints.WEST)); JPanel userlistWidthPanel = new JPanel(); userlistWidthPanel.add(new JLabel("Default Userlist Width:")); userlistWidthPanel.add(d.addSimpleLongSetting("userlistWidth", 3, true)); userlistWidthPanel.add(new JLabel("Min. Width:")); userlistWidthPanel.add(d.addSimpleLongSetting("userlistMinWidth", 3, true)); - other.add(userlistWidthPanel, - d.makeGbc(0, 5, 2, 1, GridBagConstraints.WEST)); + chatSettings.add(userlistWidthPanel, + SettingsDialog.makeGbc(0, 6, 2, 1, GridBagConstraints.WEST)); - other.add(d.addSimpleBooleanSetting("inputEnabled"), - d.makeGbc(0, 6, 2, 1, GridBagConstraints.WEST)); + chatSettings.add(d.addSimpleBooleanSetting("inputEnabled"), + SettingsDialog.makeGbc(0, 10, 2, 1, GridBagConstraints.WEST)); - SettingsUtil.addLabeledComponent(other, "inputFocus", 0, 7, 1, WEST, + SettingsUtil.addLabeledComponent(chatSettings, "inputFocus", 0, 11, 1, WEST, d.addComboLongSetting("inputFocus", 0, 1, 2)); + chatSettings.add(d.addSimpleBooleanSetting("inputHistoryMultirowRequireCtrl", + "On a multirow inputbox require Ctrl to navigate input history", + null), + SettingsDialog.makeGbc(0, 12, 2, 1, GridBagConstraints.WEST)); + + //------- + // Other + //------- + JPanel other = addTitledPanel(Language.getString("settings.section.otherWindow"), 3); + + other.add(d.addSimpleBooleanSetting("urlPrompt"), + d.makeGbc(0, 0, 1, 1, GridBagConstraints.WEST)); + + other.add(d.addSimpleBooleanSetting("inputLimitsEnabled"), + d.makeGbc(0, 1, 1, 1, GridBagConstraints.WEST)); + //-------- // Popout //-------- - JPanel popout = addTitledPanel("Popout", 3); + JPanel popout = addTitledPanel("Popout", 4); popout.add(new JLabel("Moved to 'Tabs'")); } diff --git a/src/chatty/gui/components/textpane/ChannelTextPane.java b/src/chatty/gui/components/textpane/ChannelTextPane.java index 8d0cf6177..9dc7b3153 100644 --- a/src/chatty/gui/components/textpane/ChannelTextPane.java +++ b/src/chatty/gui/components/textpane/ChannelTextPane.java @@ -661,7 +661,7 @@ private void printUserMessage(UserMessage message, String timestamp) { style = styles.standard(color); } if (timestamp == null) { - printTimestamp(style); + printTimestamp(style, message.historicTimeStamp); } else { printTimestamp(style, timestamp); } @@ -705,7 +705,7 @@ private void printUserMessage(UserMessage message, String timestamp) { lastUsers.add(new MentionCheck(user)); } - + public void printInfoMessage(InfoMessage message) { if (message.msgType == InfoMessage.Type.APPEND) { appendToMessage(message); @@ -3248,14 +3248,24 @@ public void setScrollPane(JScrollPane scroll) { } /** - * Makes the time prefix. + * Makes the time prefix with current time * * @param style */ protected void printTimestamp(AttributeSet style) { + printTimestamp(style, -1); + } + + /** + * Makes the time prefix. + * + * @param style + * @param time as long epoch + */ + protected void printTimestamp(AttributeSet style, long time) { Timestamp timestamp = styles.timestampFormat(); if (timestamp != null) { - print(timestamp.make(-1, channel != null ? channel.getRoom() : null)+" ", styles.timestamp(style)); + print(timestamp.make(time, channel != null ? channel.getRoom() : null)+" ", styles.timestamp(style)); } else { // Inserts the linebreak with a style that shouldn't break anything diff --git a/src/chatty/gui/components/textpane/LinkController.java b/src/chatty/gui/components/textpane/LinkController.java index 155438e59..e8a168010 100644 --- a/src/chatty/gui/components/textpane/LinkController.java +++ b/src/chatty/gui/components/textpane/LinkController.java @@ -41,6 +41,7 @@ import java.awt.Component; import java.awt.Cursor; import java.awt.Dimension; +import java.awt.Font; import java.awt.Point; import java.awt.Rectangle; import java.awt.event.ActionEvent; @@ -75,6 +76,7 @@ import javax.swing.text.BadLocationException; import javax.swing.text.Element; import javax.swing.text.JTextComponent; +import javax.swing.text.StyleConstants; import javax.swing.text.StyledDocument; import javax.swing.text.html.HTML; import org.json.simple.JSONArray; @@ -304,14 +306,15 @@ public void mouseClicked(MouseEvent e) { @Override public void mouseMoved(MouseEvent e) { Element element = getElement(e); + JTextPane textPane = (JTextPane)e.getSource(); if (element == null) { + textPane.setCursor(NORMAL_CURSOR); return; } // Check for Element instead of e.g. EmoticonImage because the same one // can occur in several Element objects hidePopupIfDifferentElement(element); - JTextPane textPane = (JTextPane)e.getSource(); if (Debugging.isEnabled("attr")) { popup.show(textPane, element, p -> debugElement(element, p), -1); return; @@ -380,7 +383,7 @@ public void mouseExited(MouseEvent e) { popup.hide(); } - private String getUrl(Element e) { + private static String getUrl(Element e) { return (String)(e.getAttributes().getAttribute(HTML.Attribute.HREF)); } @@ -441,6 +444,13 @@ private String getSelectedText(MouseEvent e) { return text.getSelectedText(); } + /** + * Return the element under the mouse pointer, or a nearby one. For links it + * may sometimes return a paragraph element. + * + * @param e + * @return + */ public static Element getElement(MouseEvent e) { JTextPane text = (JTextPane) e.getSource(); Point mouseLocation = new Point(e.getX(), e.getY()); @@ -466,13 +476,59 @@ public static Element getElement(MouseEvent e) { if (e.getX() < rect.x && e.getY() < rect.y + rect.height && pos > 0) { pos--; } + + StyledDocument doc = text.getStyledDocument(); + Element element = doc.getCharacterElement(pos); + + /** + * If a link is broken into two lines then depending on how it + * is broken up (the algorithm seems different depending on + * which characters are present in the textpane) there may be a + * large blank space at the end of the first line that still + * counts as the link, which can be irritating for the user to + * click accidentally. + * + * Check that the mouse pointer isn't to the right of the link. + * The x coordinate seems to represent the left side of the + * closest letter, so the width of the letter is needed to + * calculate the right side of the letter. Not sure if there's a + * better way to get this, like getting the actual view bounding + * box. + */ + if (getUrl(element) != null) { + AttributeSet attr = element.getAttributes(); + // text.getFont() likely not the same as the attributes + Font font = new Font(StyleConstants.getFontFamily(attr), Font.PLAIN, StyleConstants.getFontSize(attr)); + int textWidth = text.getFontMetrics(font).stringWidth(doc.getText(pos, 1)); + if (e.getX() - rect.x > textWidth) { + /** + * Returning the paragraph element still contains info + * like highlight source, but not the link. Normally + * this returns a leaf element, so depending on what + * assumptions the caller makes this could cause issues, + * but probably not. + * + * Returning the last element in the line could work, + * since that should just be a linebreak (or not a link + * at least), but it also could cause issues. + * + * Semantically it seems a bit weirder to return the + * "wrong" element, rather than the paragraph that at + * least contains the mouse pointer. + */ + Element paragraph = doc.getParagraphElement(pos); +// element = paragraph.getElement(paragraph.getElementCount() - 1); +// if (getUrl(element) != null) { +// element = null; +// } + element = paragraph; + } + } + return element; + } catch (BadLocationException ex) { } - - StyledDocument doc = text.getStyledDocument(); - Element element = doc.getCharacterElement(pos); - return element; } return null; } @@ -484,60 +540,53 @@ private void openContextMenu(MouseEvent e) { if (!e.getComponent().isShowing()) { return; } + JPopupMenu m = null; Element element = getElement(e); if (element == null) { - return; - } - String selectedText = getSelectedText(e); - User user = getUser(element); - if (user == null) { - user = getMention(element); - } - String url = getUrl(element); - String link = getGeneralLink(element); - CachedImage emoteImage = getEmoticonImage(element); - CachedImage usericonImage = getUsericonImage(element); - JPopupMenu m = null; - if (user != null) { - m = new UserContextMenu(user, getMsgId(element), - getAutoModMsgId(element), contextMenuListener); - } - else if (url != null) { - m = new UrlContextMenu(url, isUrlDeleted(element), contextMenuListener); - } - else if (link != null) { - if (link.startsWith("join.")) { - String c = Helper.toStream(link.substring("join.".length())); - m = new StreamsContextMenu(Arrays.asList(new String[]{c}), contextMenuListener); - } - } - else if (emoteImage != null) { - m = new EmoteContextMenu(emoteImage, contextMenuListener); - } - else if (usericonImage != null) { - m = new UsericonContextMenu(usericonImage, contextMenuListener); - } - else if (!StringUtil.isNullOrEmpty(selectedText) && ((JTextPane) e.getSource()).hasFocus()) { - /** - * Text will stay selected when the focus shifts aways, but won't be - * selected visually anymore. This can be confusing when - * right-clicking directly back into the channel, since there won't - * be any text visibly selected but still open this menu. So check - * focus first. - */ - m = new TextSelectionMenu((JTextComponent)e.getSource(), false); + m = createDefaultMenu(element); } else { - if (defaultContextMenuCreator == null) { - if (channel != null) { - m = new ChannelContextMenu(contextMenuListener, channel); + String selectedText = getSelectedText(e); + User user = getUser(element); + if (user == null) { + user = getMention(element); + } + String url = getUrl(element); + String link = getGeneralLink(element); + CachedImage emoteImage = getEmoticonImage(element); + CachedImage usericonImage = getUsericonImage(element); + if (user != null) { + m = new UserContextMenu(user, getMsgId(element), + getAutoModMsgId(element), contextMenuListener); + } + else if (url != null) { + m = new UrlContextMenu(url, isUrlDeleted(element), contextMenuListener); + } + else if (link != null) { + if (link.startsWith("join.")) { + String c = Helper.toStream(link.substring("join.".length())); + m = new StreamsContextMenu(Arrays.asList(new String[]{c}), contextMenuListener); } - } else { - ContextMenu menu = defaultContextMenuCreator.get(); - menu.addContextMenuListener(contextMenuListener); - m = menu; } - addMessageInfoItems(m, element); + else if (emoteImage != null) { + m = new EmoteContextMenu(emoteImage, contextMenuListener); + } + else if (usericonImage != null) { + m = new UsericonContextMenu(usericonImage, contextMenuListener); + } + else if (!StringUtil.isNullOrEmpty(selectedText) && ((JTextPane) e.getSource()).hasFocus()) { + /** + * Text will stay selected when the focus shifts aways, but + * won't be selected visually anymore. This can be confusing + * when right-clicking directly back into the channel, since + * there won't be any text visibly selected but still open this + * menu. So check focus first. + */ + m = new TextSelectionMenu((JTextComponent) e.getSource(), false); + } + else { + m = createDefaultMenu(element); + } } if (m != null) { JPopupMenu m2 = m; @@ -1037,6 +1086,26 @@ private static void debugElement(Element e, MyPopup p) { p.setText(result.toString()); } + private JPopupMenu createDefaultMenu(Element element) { + JPopupMenu m = null; + // Create menu + if (defaultContextMenuCreator == null) { + if (channel != null) { + m = new ChannelContextMenu(contextMenuListener, channel); + } + } + else { + ContextMenu menu = defaultContextMenuCreator.get(); + menu.addContextMenuListener(contextMenuListener); + m = menu; + } + // Add additional entries to menu if possible + if (element != null && m != null) { + addMessageInfoItems(m, element); + } + return m; + } + /** * Add menu items showing message info like the source of a Highlight. The * items have their own action listener set, which calls the diff --git a/src/chatty/gui/components/textpane/UserMessage.java b/src/chatty/gui/components/textpane/UserMessage.java index 956f7a860..5629b7c5f 100644 --- a/src/chatty/gui/components/textpane/UserMessage.java +++ b/src/chatty/gui/components/textpane/UserMessage.java @@ -30,6 +30,7 @@ public class UserMessage extends Message { public Object ignoreSource; public Object routingSource; public User localUser; + public long historicTimeStamp; public UserMessage(User user, String text, Emoticons.TagEmotes emotes, String id, int bits, List highlightMatches, @@ -39,6 +40,7 @@ public UserMessage(User user, String text, Emoticons.TagEmotes emotes, this.emotes = emotes; this.bits = bits; this.tags = tags; + this.historicTimeStamp = -1; // keep -1 as default for current timestamp } public UserMessage copy() { diff --git a/src/chatty/gui/laf/FlatLafUtil.java b/src/chatty/gui/laf/FlatLafUtil.java index 12e7b950f..4619ece09 100644 --- a/src/chatty/gui/laf/FlatLafUtil.java +++ b/src/chatty/gui/laf/FlatLafUtil.java @@ -10,6 +10,7 @@ import java.nio.charset.Charset; import java.util.Properties; import java.util.logging.Logger; +import javax.swing.UIManager; /** * @@ -23,7 +24,7 @@ public class FlatLafUtil { private static final Logger LOGGER = Logger.getLogger(FlatLafUtil.class.getName()); - protected static void loadFlatLaf(String baseTheme, LaF.LaFSettings settings) throws IOException { + protected static void loadFlatLaf(String baseTheme, LaF.LaFSettings settings) throws Exception { Properties pCustom = new Properties(); pCustom.load(new ByteArrayInputStream(settings.flatProperties.getBytes(Charset.forName("ISO-8859-1")))); boolean loadChattyProperties = !pCustom.getOrDefault("chattyProperties", "true").equals("false"); @@ -41,7 +42,7 @@ protected static void loadFlatLaf(String baseTheme, LaF.LaFSettings settings) th p.put("TitlePane.useWindowDecorations", String.valueOf(settings.flatStyledWindow)); p.put("@baseTheme", baseTheme); p.putAll(pCustom); - FlatPropertiesLaf l = new FlatPropertiesLaf("Customized Flat LaF", p) { + FlatPropertiesLaf lookAndFeel = new FlatPropertiesLaf("Customized Flat LaF", p) { @Override public void provideErrorFeedback(Component component) { @@ -51,7 +52,7 @@ public void provideErrorFeedback(Component component) { } }; - FlatLaf.setup(l); + UIManager.setLookAndFeel(lookAndFeel); } } diff --git a/src/chatty/gui/laf/LaF.java b/src/chatty/gui/laf/LaF.java index fdeeae0c5..cdbe26be1 100644 --- a/src/chatty/gui/laf/LaF.java +++ b/src/chatty/gui/laf/LaF.java @@ -38,12 +38,18 @@ public class LaF { private static final Logger LOGGER = Logger.getLogger(LaF.class.getName()); + private static final Color TAB_FOREGROUND_HIGHLIGHT_LIGHT = new Color(255, 80, 0); + private static final Color TAB_FOREGROUND_UNREAD_LIGHT = new Color(200, 0, 0); + + private static final Color TAB_FOREGROUND_HIGHLIGHT_DARK = new Color(255, 180, 40); + private static final Color TAB_FOREGROUND_UNREAD_DARK = new Color(255, 80, 80); + private static LaFSettings settings; private static String linkColor = "#0000FF"; private static boolean isDarkTheme; private static String lafClass; - private static Color tabForegroundUnread = new Color(200,0,0); - private static Color tabForegroundHighlight = new Color(255,80,0); + private static Color tabForegroundHighlight = TAB_FOREGROUND_HIGHLIGHT_LIGHT; + private static Color tabForegroundUnread = TAB_FOREGROUND_UNREAD_LIGHT; private static Border inputBorder; private static boolean defaultButtonInsets = false; @@ -162,7 +168,22 @@ public static LaFSettings fromSettingsDialog(SettingsDialog d, Settings settings } - public static void setLookAndFeel(LaFSettings settings) { + /** + * Get the most recently set Look&Feel settings. + * + * @return + */ + public static LaFSettings getSettings() { + return LaF.settings; + } + + /** + * Sets the Look&Feel to the given settings. + * + * @param settings + * @return An error message if an error occured, {@code null} otherwise + */ + public static String setLookAndFeel(LaFSettings settings) { LaFUtil.resetDefaults(); inputBorder = null; LaF.settings = settings; @@ -289,18 +310,22 @@ else if (laf != null) { modifyDefaults(); } catch (Exception ex) { LOGGER.warning("[LAF] Failed setting LAF: "+ex); + return ex.getLocalizedMessage(); } isDarkTheme = determineDarkTheme(); // Set some settings not directly used by the LAF, but based on LAF. if (isDarkTheme) { linkColor = "#EEEEEE"; - tabForegroundHighlight = new Color(255,180,40); - tabForegroundUnread = new Color(255,80,80); + tabForegroundHighlight = TAB_FOREGROUND_HIGHLIGHT_DARK; + tabForegroundUnread = TAB_FOREGROUND_UNREAD_DARK; } else { linkColor = "#0000FF"; + tabForegroundHighlight = TAB_FOREGROUND_HIGHLIGHT_LIGHT; + tabForegroundUnread = TAB_FOREGROUND_UNREAD_LIGHT; } loadOtherCustom(); + return null; } private static boolean determineDarkTheme() { diff --git a/src/chatty/gui/laf/LaFChanger.java b/src/chatty/gui/laf/LaFChanger.java new file mode 100644 index 000000000..df02511bb --- /dev/null +++ b/src/chatty/gui/laf/LaFChanger.java @@ -0,0 +1,93 @@ + +package chatty.gui.laf; + +import chatty.gui.laf.LaF.LaFSettings; +import java.awt.Component; +import java.util.logging.Logger; +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; + +/** + * + * @author tduva + */ +public class LaFChanger { + + private static final Logger LOGGER = Logger.getLogger(LaFChanger.class.getName()); + + private static LaFSettings settingsBefore; + private static String loggedError; + private static Component parentComponent; + + /** + * Set a new Look&Feel, but revert to previous if an error occurs and output + * error message. + * + * @param settings + * @param parentComponent + */ + public static void changeLookAndFeel(LaFSettings settings, Component parentComponent) { + LaFChanger.settingsBefore = LaF.getSettings(); + LaFChanger.loggedError = null; + LaFChanger.parentComponent = parentComponent; + + // Try to set Look&Feel, during which Flat LaF may log errors. + String caughtError = LaF.setLookAndFeel(settings); + if (caughtError == null && loggedError == null) { + /** + * Only update when there is no error, so it doesn't cause issues + * when reverting is necessary. It threw a NPE otherwise, not sure + * if that's because stuff wasn't initialized correctly or because + * of changing LaF again directly after updating. + */ + LaF.updateLookAndFeel(); + } + else { + revertLookAndFeelWithError(caughtError != null ? caughtError : loggedError); + } + + /** + * Show popup every time after setting LaF with the option to revert. It + * could also automatically revert after a few seconds, for example if + * colors are weird making it difficult to read/click stuff (not + * implemented here yet). But might be a bit too annoying for every + * small change. + */ +// SwingUtilities.invokeLater(() -> { +// int result = JOptionPane.showConfirmDialog(null, settings, "Keep Look&Feel settings?", JOptionPane.YES_NO_OPTION); +// if (result == 1) { +// LaFSettings s = LaF.getPreviousSettings(); +// System.out.println(s.lafCode); +// SwingUtilities.invokeLater(() -> { +// LaF.setLookAndFeel(s); +// LaF.updateLookAndFeel(); +// }); +// } +// }); + } + + /** + * Called when the Flat LaF logs an error without throwing an exception, for + * example when an invalid color code fails to parse. + * + * @param error + */ + public static void loggedFlatLookAndFeelError(String error) { + LaFChanger.loggedError = error; + } + + private static void revertLookAndFeelWithError(String error) { + LOGGER.info("Reverting LaF due to error: "+error); + SwingUtilities.invokeLater(() -> { + LaF.setLookAndFeel(settingsBefore); + LaF.updateLookAndFeel(); + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog(parentComponent, + error, + "Failed to apply new Look&Feel settings", + JOptionPane.WARNING_MESSAGE); + }); + }); + } + +} diff --git a/src/chatty/lang/Strings.properties b/src/chatty/lang/Strings.properties index 0845c338a..5abeb478e 100644 --- a/src/chatty/lang/Strings.properties +++ b/src/chatty/lang/Strings.properties @@ -280,6 +280,7 @@ dialog.button.remove = Remove dialog.button.customize = Customize # Change some setting dialog.button.change = Change +dialog.error.invalidInput = Invalid input: !====================! !== General Status ==! @@ -554,6 +555,12 @@ admin.tags.cm.replace = Replace ''{0}'' !-- Select Labels --! admin.labels.title = Select Content Classification Labels +!========================! +!== Channel Moderation ==! +!========================! +settings.editor.slowmodeDurations = Slow Mode presets (3s - 120s) +settings.editor.followeronlyDurations = Followers-Only presets (0m - 90d) + !=========================! !== Channel Info Dialog ==! !=========================! @@ -1124,8 +1131,10 @@ settings.boolean.filterEnabled = Enable Filter settings.boolean.routingMulti = Allow routing to several custom tabs settings.boolean.routingMulti.tip = If disabled, only the first matched Custom Tab will be routed to settings.customTabSettings.logEnabled = Logging enabled -settings.customTabSettings.logFile = File name: -settings.customTabSettings.logInfo = See "Log to file" settings page for folder. +settings.customTabSettings.logFile = Filename: +settings.customTabSettings.logFile.tip = To the chosen filename "customTab-" is prepended and ".log" is appended. +settings.customTabSettings.logInfo = The "Log to file" settings apply (logging mode, folder, message types). +settings.customTabSettings.logInfo2 = Logging of Custom Tabs can be configured in the Custom Tabs settings. settings.customTabSettings.multiChannelAll = All in one settings.customTabSettings.multiChannelAll.tip = Messages from all channels are shown in one panel. settings.customTabSettings.multiChannelSep = Current channel @@ -1303,6 +1312,11 @@ settings.boolean.singleClickTrayOpen.tip = With this unchecked, a double-click m settings.section.otherWindow = Other # Enable the confirmation dialog when opening an URL out of Chatty settings.boolean.urlPrompt = "Open URL" Popup +settings.boolean.inputLimitsEnabled = Enable input length limits +settings.boolean.inputLimitsEnabled.tip = Some text input fields have a limit to prevent pasting too much text or to not sent too long messages. + +!-- Chat Window Settings --! +settings.section.otherChat = Chat settings.boolean.chatScrollbarAlways = Always show chat scrollbar settings.window.defaultUserlistWidth = Default Userlist Width: # In context of Userlist Width, so don't need to repeat "Userlist" @@ -1477,6 +1491,9 @@ settings.label.streamHighlightCooldown = Cooldown (seconds): settings.label.streamHighlightCooldown.tip = Highlight will not be added unless the set amount of seconds have passed since the last time a highlight was added (per channel) settings.long.streamHighlightCooldown.option.0 = Off +!-- History Settings --! +settings.label.historyServiceLimit = Message limit: + !-- Hotkey Settings --! settings.hotkeys.key.button.set = Set key combination settings.hotkeys.key.button.clear = Clear key combination diff --git a/src/chatty/util/FileUtil.java b/src/chatty/util/FileUtil.java new file mode 100644 index 000000000..78bcb2f83 --- /dev/null +++ b/src/chatty/util/FileUtil.java @@ -0,0 +1,14 @@ + +package chatty.util; + +import java.util.regex.Pattern; + +/** + * + * @author tduva + */ +public class FileUtil { + + public static final Pattern ILLEGAL_FILENAME_CHARACTERS_PATTERN = Pattern.compile("[/\\n\\r\\t\\x00\\f`?*\\\\<>|\\\"\\\\:]"); + +} diff --git a/src/chatty/util/UrlRequest.java b/src/chatty/util/UrlRequest.java index 608b6f97d..c7be966bd 100644 --- a/src/chatty/util/UrlRequest.java +++ b/src/chatty/util/UrlRequest.java @@ -25,8 +25,6 @@ public class UrlRequest { private static final Logger LOGGER = Logger.getLogger(UrlRequest.class.getName()); private static final Charset CHARSET = Charset.forName("UTF-8"); - private static final int CONNECT_TIMEOUT = 10000; - private static final int READ_TIMEOUT = 10000; private static final String VERSION = "Chatty "+Chatty.VERSION; @@ -37,6 +35,9 @@ public class UrlRequest { */ private String label = ""; + private int connectTimeout = 10000; + private int readTimeout = 10000; + /** * Construct without URL. The URL should be set via {@link setUrl(String)}. */ @@ -66,6 +67,11 @@ public final void setUrl(String url) { public final void setLabel(String label) { this.label = "["+label+"]"; } + + public final void setTimeouts(int connectTimeout, int readTimeout) { + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + } public void async(ResultListener listener) { new Thread(() -> { @@ -112,8 +118,8 @@ private void performRequest(Result result) { connection.setRequestProperty("User-Agent", VERSION); connection.setRequestProperty("Accept-Encoding", "gzip"); - connection.setConnectTimeout(CONNECT_TIMEOUT); - connection.setReadTimeout(READ_TIMEOUT); + connection.setConnectTimeout(connectTimeout); + connection.setReadTimeout(readTimeout); String encoding = connection.getContentEncoding(); // Read response diff --git a/src/chatty/util/api/usericons/Usericon.java b/src/chatty/util/api/usericons/Usericon.java index 94c5cc49f..8503aef55 100644 --- a/src/chatty/util/api/usericons/Usericon.java +++ b/src/chatty/util/api/usericons/Usericon.java @@ -409,6 +409,10 @@ public Usericon(Builder builder) { private int customScaleMode; public CachedImage getIcon(float scale, int customUsericonScaleMode, CachedImage.CachedImageUser user) { + return getIcon(scale, customUsericonScaleMode, 0, user); + } + + public CachedImage getIcon(float scale, int customUsericonScaleMode, int maxHeight, CachedImage.CachedImageUser user) { this.customScaleMode = customUsericonScaleMode; if (images == null) { images = new CachedImageManager<>(this, new CachedImage.CachedImageRequester() { @@ -468,7 +472,7 @@ public Image modifyImage(ImageIcon icon) { customKey = 1; } } - return images.getIcon(scale, 0, customKey, CachedImage.ImageType.STATIC, user); + return images.getIcon(scale, maxHeight, customKey, CachedImage.ImageType.STATIC, user); } private static Dimension toHeight(Dimension d, int targetHeight) { diff --git a/src/chatty/util/history/HistoryManager.java b/src/chatty/util/history/HistoryManager.java new file mode 100644 index 000000000..13277e3dc --- /dev/null +++ b/src/chatty/util/history/HistoryManager.java @@ -0,0 +1,192 @@ + +package chatty.util.history; + +import java.util.ArrayList; +import java.util.List; + +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; + +import chatty.Room; +import chatty.gui.components.settings.ChannelFormatter; +import chatty.util.UrlRequest; +import chatty.util.UrlRequest.FullResult; +import chatty.util.api.Requests; + +import chatty.util.irc.MsgTags; +import chatty.util.irc.ParsedMsg; +import chatty.util.settings.Settings; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; + +/** + * History Manager which should be the entry point for getting historic Chat messages from external services. + * Currently, only robotty https://recent-messages.robotty.de is implemented + * @author m00hlti + */ +public class HistoryManager { + + private static final Logger LOGGER = Logger.getLogger(HistoryManager.class.getName()); + + private final Settings settings; + private static final ChannelFormatter channelFormater = new ChannelFormatter(); + + private final static String STRHISTORYURL = "https://recent-messages.robotty.de/api/v2/recent-messages/"; + + private final Map latestMessageSeen = new HashMap<>(); + + /** + * Default Constructor + * + * @param settings + */ + public HistoryManager(Settings settings) { + this.settings = settings; + } + + public void setMessageSeen(String stream) { + latestMessageSeen.put(stream, System.currentTimeMillis()); + } + + public void resetMessageSeen(String stream) { + latestMessageSeen.remove(stream); + } + + /** + * Checks if a channel is on the exclusion list. + * + * @param channel Channel which should be checked + * @return false if not excluded, true if + */ + public boolean isChannelExcluded(String channel) { + return settings.listContains("historyServiceExcluded", channelFormater.format(channel)); + } + + /** + * Check whether the chat history feature is enabled and configured + * correctly. + * + * @return true if enabled and configured correctly, false otherwise + */ + public boolean isEnabled() { + return settings.getBoolean("historyServiceEnabled"); + } + + /** + * Detects input from the API with regex and transforms it into a History + * Message Object + * + * @param rawMessage Input from the external API + * @return A HistoryMessage Object containing all information from the historic message + */ + private HistoryMessage transformStringToMessage(String rawMessage) { + ParsedMsg parsed = ParsedMsg.parse(rawMessage); + if (parsed == null) { + return null; + } + + if (parsed.getCommand().equals("PRIVMSG")) { + if (parsed.getParameters().has(1) + && parsed.getParameters().get(0).startsWith("#")) { + String message = parsed.getParameters().get(1); + + HistoryMessage result = new HistoryMessage(); + result.action = message.charAt(0) == (char) 1 && message.startsWith("ACTION", 1); + result.message = result.action ? message.substring(7).trim() : message; + result.tags = MsgTags.merge( + parsed.getTags(), + MsgTags.create( + "historic-timestamp", + parsed.getTags().get("rm-received-ts") + ) + ); + result.userName = parsed.getNick(); + if (!result.userName.isEmpty()) { + return result; + } + } + } + return null; + } + + /** + * Executes the actual HTTP request for historical Data + * + * @param stream Channel to start the request for + * @return A JSONObject with all messages requested accordingly to the parameters + */ + private JSONObject executeRequest(String stream) { + JSONObject root = null; + + try { + String url = STRHISTORYURL + stream; + + long limit = settings.getLong("historyServiceLimit"); + if (limit <= 0) { + limit = 30; + } + + // -24h until now. + long timestampBefore = System.currentTimeMillis(); + long timestampAfter = System.currentTimeMillis() - 24 * 60 * 60 * 1000; + if (latestMessageSeen.containsKey(stream)) { + timestampAfter = latestMessageSeen.get(stream); + } + + url = Requests.makeUrl(url, + "limit", String.valueOf(limit), + "before", String.valueOf(timestampBefore), + "after", String.valueOf(timestampAfter)); + + UrlRequest request = new UrlRequest(url); + request.setLabel("ChatHistory/"); + // Set lower timeout so the IRC thread isn't stuck for ages if the + // server is slow/not reachable + request.setTimeouts(4000, 2000); + FullResult result = request.sync(); + + if (result.getResponseCode() != 200) { + // Some error detection in future?? + } else { + String res = result.getResult(); + JSONParser parser = new JSONParser(); + root = (JSONObject) parser.parse(res); + } + } catch(Exception ex) { + LOGGER.warning("Error requesting chat history: "+ex); + } + + return root; + } + + /** + * Get all the chat messages from the room in the given constraints from the settings + * @param room + * @return a List of HistoryMessages + */ + public List getHistoricChatMessages(Room room) { + ArrayList ret = new ArrayList<>(); + + String channelName = room.getStream(); + //?hide_moderation_messages=true/false: Omits CLEARCHAT and CLEARMSG messages from the response. Optional, defaults to false. + //?hide_moderated_messages=true/false: Omits all messages from the response that have been deleted by a CLEARCHAT or CLEARMSG message. Optional, defaults to false. + //?clearchat_to_notice=true/false: Converts CLEARCHAT messages into NOTICE messages with a user-presentable message. + + JSONObject historyObject = this.executeRequest(channelName); + if (historyObject == null) { + return ret; + } + + JSONArray jsArray = (JSONArray) historyObject.get("messages"); + for (int i = 0; i< jsArray.size(); i++) { + HistoryMessage historyMsg = this.transformStringToMessage((String)jsArray.get(i)); + if (historyMsg != null) { + ret.add(historyMsg); + } + } + return ret; + } + +} diff --git a/src/chatty/util/history/HistoryMessage.java b/src/chatty/util/history/HistoryMessage.java new file mode 100644 index 000000000..e0968add5 --- /dev/null +++ b/src/chatty/util/history/HistoryMessage.java @@ -0,0 +1,29 @@ + +package chatty.util.history; + +import chatty.util.irc.MsgTags; + +/** + * History Message which is needed for internal Data holding in the History + * Manager. + * + * @author m00hlti + */ + +public class HistoryMessage { + + public String userName; + public String message; + public boolean action; + public MsgTags tags; + + /** + * Create a new History Message + */ + public HistoryMessage() { + message = ""; + userName = ""; + action = false; + } + +} diff --git a/src/chatty/util/irc/MsgTags.java b/src/chatty/util/irc/MsgTags.java index 79b6ec5b1..ff9c3d733 100644 --- a/src/chatty/util/irc/MsgTags.java +++ b/src/chatty/util/irc/MsgTags.java @@ -42,7 +42,7 @@ public boolean isHighlightedMessage() { public boolean isCustomReward() { return hasValue("custom-reward-id"); } - + public String getCustomRewardId() { return get("custom-reward-id"); } @@ -50,6 +50,18 @@ public String getCustomRewardId() { public boolean isFromPubSub() { return isValue("chatty-source", "pubsub"); } + + public boolean isHistoricMsg() { + return hasValue("historic-timestamp"); + } + + public long getHistoricTimeStamp() { + if (isHistoricMsg()) { + return Long.parseLong(get("historic-timestamp")); + } else { + return -1; + } + } public String getChannelJoin() { return get("chatty-channel-join"); diff --git a/src/chatty/util/irc/UserTagsUtil.java b/src/chatty/util/irc/UserTagsUtil.java new file mode 100644 index 000000000..0da90ca77 --- /dev/null +++ b/src/chatty/util/irc/UserTagsUtil.java @@ -0,0 +1,75 @@ + +package chatty.util.irc; + +import chatty.Helper; +import chatty.User; +import chatty.util.StringUtil; + +/** + * + * @author tduva + */ +public class UserTagsUtil { + + public static boolean updateUserFromTags(User user, MsgTags tags) { + if (tags.isEmpty()) { + return false; + } + /** + * Any and all tag values may be null, so account for that when checking + * against them. + */ + // Whether anything in the user changed to warrant an update + boolean changed = false; + + IrcBadges badges = IrcBadges.parse(tags.get("badges")); + if (user.setTwitchBadges(badges)) { + changed = true; + } + + IrcBadges badgeInfo = IrcBadges.parse(tags.get("badge-info")); + String subMonths = badgeInfo.get("subscriber"); + if (subMonths == null) { + subMonths = badgeInfo.get("founder"); + } + if (subMonths != null) { + user.setSubMonths(Helper.parseShort(subMonths, (short) 0)); + } + + if (user.setDisplayNick(StringUtil.trim(tags.get("display-name")))) { + changed = true; + } + + // Update color + String color = tags.get("color"); + if (color != null && !color.isEmpty()) { + user.setColor(color); + } + + // Update user status + boolean turbo = tags.isTrue("turbo") || badges.hasId("turbo") || badges.hasId("premium"); + if (user.setTurbo(turbo)) { + changed = true; + } + boolean subscriber = badges.hasId("subscriber") || badges.hasId("founder"); + if (user.setSubscriber(subscriber)) { + changed = true; + } + if (user.setVip(badges.hasId("vip"))) { + changed = true; + } + if (user.setModerator(badges.hasId("moderator"))) { + changed = true; + } + if (user.setAdmin(badges.hasId("admin"))) { + changed = true; + } + if (user.setStaff(badges.hasId("staff"))) { + changed = true; + } + + user.setId(tags.get("user-id")); + return changed; + } + +}