package chatty.gui.components; import chatty.Helper; import chatty.TwitchClient; import chatty.User; import chatty.gui.MainGui; import chatty.gui.components.menus.AutoModContextMenu; import chatty.util.DateTime; import chatty.util.MiscUtil; import chatty.util.api.TwitchApi; import chatty.util.api.pubsub.ModeratorActionData; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.ComponentListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.DefaultListCellRenderer; import javax.swing.DefaultListModel; import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JList; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.KeyStroke; import javax.swing.ListSelectionModel; import javax.swing.ScrollPaneConstants; import javax.swing.SwingUtilities; import javax.swing.Timer; /** * * @author tduva */ public class AutoModDialog extends JDialog { private static final int MESSAGE_LIMIT = 20; private final MainGui gui; private final TwitchApi api; private final TwitchClient client; private final JList<Item> list; private final DefaultListModel<Item> data; private final Map<String, List<Item>> cache = new HashMap<>(); private String currentRoom = ""; private String currentRoomLoaded = ""; public AutoModDialog(MainGui main, TwitchApi api, TwitchClient client) { super(main); setTitle("AutoMod"); this.gui = main; this.api = api; this.client = client; list = new JList<Item>() { /** * To prevent horizontal scrolling and allow for tracking of the * viewport width. * * @return */ @Override public boolean getScrollableTracksViewportWidth() { return true; } }; data = new DefaultListModel<>(); list.setModel(data); list.setCellRenderer(new MyCellRenderer()); list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); JScrollPane scroll = new JScrollPane(list); scroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); scroll.getVerticalScrollBar().setUnitIncrement(20); add(scroll); list.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { openUserInfoDialog(); } } @Override public void mousePressed(MouseEvent e) { openContextMenu(e); } @Override public void mouseReleased(MouseEvent e) { openContextMenu(e); } }); Timer timer = new Timer(30000, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { list.repaint(); } }); timer.setRepeats(true); timer.start(); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("A"), "automod.approve"); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("alt A"), "automod.approve"); list.getActionMap().put("automod.approve", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { approve(null); } }); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("D"), "automod.deny"); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("alt D"), "automod.deny"); list.getActionMap().put("automod.deny", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { deny(null); } }); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("S"), "automod.next"); list.getActionMap().put("automod.next", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { selectNext(); } }); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("W"), "automod.previous"); list.getActionMap().put("automod.previous", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { selectPrevious(); } }); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("alt S"), "automod.nextUnhandled"); list.getActionMap().put("automod.nextUnhandled", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { selectNext(true); } }); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("alt W"), "automod.previousUnhandled"); list.getActionMap().put("automod.previousUnhandled", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { selectPrevious(true); } }); list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("Q"), "automod.close"); list.getActionMap().put("automod.close", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { setVisible(false); } }); ComponentListener l = new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { // Trick from kleopatra: // http://stackoverflow.com/questions/7306295/swing-jlist-with-multiline-text-and-dynamic-height // next line possible if list is of type JXList // list.invalidateCellSizeCache(); // for core: force cache invalidation by temporarily setting fixed height list.setFixedCellHeight(10); list.setFixedCellHeight(-1); } }; list.addComponentListener(l); setSize(new Dimension(400, 200)); } public void showDialog() { switchDataToCurrent(); setVisible(true); list.setSelectedIndex(data.size() - 1); scrollDown(); } public void setChannel(String channel) { if (channel != null && !channel.equals(currentRoom)) { currentRoom = channel; if (isVisible()) { switchDataToCurrent(); } } } private void switchDataToCurrent() { if (!currentRoom.equals(currentRoomLoaded)) { currentRoomLoaded = currentRoom; setTitle("AutoMod (" + currentRoom + ") [Use Context Menu to Approve/Deny]"); List<Item> cached = cache.get(currentRoom); data.removeAllElements(); if (cached != null) { for (Item item : cached) { data.addElement(item); } } scrollDown(); } } public void addData(ModeratorActionData modData) { if (modData.stream == null) { return; } if (modData.type == ModeratorActionData.Type.AUTOMOD_REJECTED) { addItem(modData); } if (modData.type == ModeratorActionData.Type.AUTOMOD_APPROVED) { handledExternally(modData, Item.STATUS_APPROVED); } if (modData.type == ModeratorActionData.Type.AUTOMOD_DENIED) { handledExternally(modData, Item.STATUS_DENIED); } } /** * Result of an API request to approve/deny a message. * * @param result * @param msgId */ public void requestResult(String result, String msgId) { Item changedItem = findItemByMsgId(msgId); if (changedItem != null) { changedItem.setRequestPending(false); if (result.equals("approved")) { changedItem.setStatus(Item.STATUS_APPROVED); } else if (result.equals("denied")) { changedItem.setStatus(Item.STATUS_DENIED); } else if (changedItem.status <= Item.STATUS_NONE) { if (result.equals("400")) { changedItem.setStatus(Item.STATUS_HANDLED); } else if (result.equals("404")) { changedItem.setStatus(Item.STATUS_NA); } else { changedItem.setStatus(Item.STATUS_ERROR); } } } repaintFor(changedItem); } private Item findItemByMsgId(String msgId) { for (List<Item> items : cache.values()) { for (Item item : items) { if (item.data.msgId.equals(msgId)) { return item; } } } return null; } private void addItem(ModeratorActionData modData) { if (modData.args.size() != 2 || modData.msgId.isEmpty()) { return; } String room = modData.stream; String channel = Helper.toValidChannel(modData.stream); String username = modData.args.get(0); String message = modData.args.get(1); if (channel == null) { return; } if (!Helper.validateStream(username)) { return; } User user = client.getUser(channel, username); user.addAutoModMessage(message); gui.updateUserinfo(user); Item item = new Item(modData, user); if (!cache.containsKey(room)) { cache.put(room, new ArrayList<Item>()); } cache.get(room).add(item); if (cache.get(room).size() > MESSAGE_LIMIT) { cache.get(room).remove(0); if (room.equals(currentRoom) && !data.isEmpty()) { data.remove(0); } } if (room.equals(currentRoom)) { data.addElement(item); scrollDownIfApplicable(); } } /** * Message has been handled by another user, so determine if corresponding * message can be found and set the status accordingly. * * @param modData * @param status */ private void handledExternally(ModeratorActionData modData, int status) { if (modData.args.size() != 1 || modData.created_by.isEmpty()) { return; } String handledBy = modData.created_by; String targetUsername = modData.args.get(0); String room = modData.stream; Item item; if (!modData.msgId.isEmpty()) { item = findItemByMsgId(modData.msgId); } else { item = findItemByUsername(room, targetUsername); } if (item != null && !item.hasRequestPending && !item.isHandled()) { item.setStatus(status, handledBy); repaintFor(item); } } /** * Find a single Item by username for a given room. The issue with this is * that Twitch only provides the username for approved messages (and a new * message id), so only return an Item if only one for that username was * found in the last 5 minutes that hasn't been handled yet. Messages seem * to get removed after a few minutes, so messages older than 5 minutes * probably can't be approved anymore. Still, this isn't that pretty. * * @param room * @param username * @return */ private Item findItemByUsername(String room, String username) { Item foundItem = null; List<Item> items = cache.get(room); if (items != null) { boolean oldEnoughHistory = false; for (int i=items.size() - 1; i>=0; i--) { Item item = items.get(i); if (item.getAge() > 5*60) { oldEnoughHistory = true; break; } if (!item.hasRequestPending && !item.isHandled() && item.targetUser.getName().equals(username)) { if (foundItem != null) { // Can't be more than one Item, since we don't know // which is the correct one by just the username foundItem = null; break; } foundItem = item; } } if (!oldEnoughHistory) { foundItem = null; } } return foundItem; } private void scrollDownIfApplicable() { if (list.getLastVisibleIndex() >= data.size() - 2) { scrollDown(); } } private void scrollDown() { list.ensureIndexIsVisible(data.size() - 1); SwingUtilities.invokeLater(new Runnable() { @Override public void run() { list.ensureIndexIsVisible(data.size() - 1); } }); } /** * Select the list item at the location of this MouseEvent, if any. * * @param e The MouseEvent */ private void selectClicked(MouseEvent e) { int index = list.locationToIndex(e.getPoint()); if (index != -1) { list.setSelectedIndex(index); } } private void openContextMenu(MouseEvent e) { if (e.isPopupTrigger()) { selectClicked(e); Item selectedItem = list.getSelectedValue(); if (selectedItem == null) { return; } AutoModContextMenu m = new AutoModContextMenu(selectedItem, new AutoModContextMenu.AutoModContextMenuListener() { @Override public void itemClicked(Item item, ActionEvent e) { if (e.getActionCommand().equals("approve")) { approve(item); } else if (e.getActionCommand().equals("reject")) { deny(item); } else if (e.getActionCommand().equals("copy")) { MiscUtil.copyToClipboard(item.toString()); } else if (e.getActionCommand().equals("help")) { gui.openHelp(null, "automod"); } else if (e.getActionCommand().equals("user")) { openUserInfoDialog(); } else if (e.getActionCommand().equals("close")) { setVisible(false); } } }); m.show(list, e.getX(), e.getY()); } } private void openUserInfoDialog() { Item item = list.getSelectedValue(); if (item != null) { gui.openUserInfoDialog(item.targetUser, null); } } private void approve(Item item) { if (item == null) { item = list.getSelectedValue(); } if (item == null) { return; } setPending(item); api.autoMod("approve", item.data.msgId); } private void deny(Item item) { if (item == null) { item = list.getSelectedValue(); } if (item == null) { return; } setPending(item); api.autoMod("deny", item.data.msgId); } private void setPending(Item item) { item.setRequestPending(true); repaintFor(item); } private void repaintFor(Item item) { if (data.contains(item)) { list.repaint(); } } /** * Selects the next entry in the list, relative to the current selection. */ private void selectNext() { selectNext(false); } /** * Selects the previous entry in the list, relative to the current * selection. */ private void selectPrevious() { selectPrevious(false); } /** * Selects the next entry in the list, relative to what is currently * selected. * * @param onlyUnhandled If true will only select an Item has a status of not * having been handled in any way yet */ private void selectNext(boolean onlyUnhandled) { if (data.isEmpty()) { return; } int selected = list.getSelectedIndex(); if (selected == -1) { selected = data.size() - 2; } for (int i=selected+1;i < data.size();i++) { if (select(i, onlyUnhandled)) { return; } } /** * Tried, but could not select anything new, but also not currently at * the last item. This can happen with Alt+S when last item is already * handled to indicate there are no more items, but still scroll down * for items that may be added later. */ if (list.getSelectedIndex() != data.size() - 1) { scrollDown(); list.removeSelectionInterval(selected, selected); } } /** * Selects the previous entry in the list, relative to what is currently * selected. * * @param onlyUnhandled If true will only select an Item has a status of not * having been handled in any way yet */ private void selectPrevious(boolean onlyUnhandled) { if (data.isEmpty()) { return; } int selected = list.getSelectedIndex(); if (selected == -1) { selected = data.size(); } for (int i=selected-1;i >= 0;i--) { if (select(i, onlyUnhandled)) { return; } } } /** * Select the given index of the list. * * @param i The index to select * @param onlyUnhandled If true will only select this index if this Item is * not handled yet in any way * @return true if this index was selected, false otherwise */ private boolean select(int i, boolean onlyUnhandled) { Item item = data.get(i); if (!onlyUnhandled || item.status == Item.STATUS_NONE) { list.setSelectedIndex(i); list.ensureIndexIsVisible(i); return true; } return false; } public static class Item { public static final int STATUS_NONE = 0; public static final int STATUS_HANDLED = 2; public static final int STATUS_ERROR = 3; public static final int STATUS_NA = 4; public static final int STATUS_APPROVED = 5; public static final int STATUS_DENIED = 6; public final ModeratorActionData data; public final User targetUser; private int status; private String handledBy; private boolean hasRequestPending; private Item(ModeratorActionData data, User targetUser) { this.data = data; this.targetUser = targetUser; } public void setStatus(int status, String handledBy) { this.status = status; this.handledBy = handledBy; } public void setStatus(int status) { setStatus(status, null); } public void setRequestPending(boolean isPending) { this.hasRequestPending = isPending; } public boolean hasRequestPending() { return hasRequestPending; } public String getHandledBy() { return handledBy; } public boolean isHandled() { return status == STATUS_APPROVED || status == STATUS_DENIED || status == STATUS_HANDLED || status == STATUS_NA; } /** * Returns the age of this item in seconds. * * @return */ public long getAge() { return (System.currentTimeMillis() - data.created_at) / 1000; } @Override public String toString() { return String.format("[%s] <%s> %s", DateTime.format(data.created_at), data.args.get(0), data.args.get(1)); } public String getStatusText() { if (hasRequestPending) { return "Pending"; } switch (status) { case STATUS_NONE: return ""; case STATUS_HANDLED: return "Handled"; case STATUS_APPROVED: return "Approved"; case STATUS_DENIED: return "Denied"; case STATUS_ERROR: return "Error"; case STATUS_NA: return "N/A"; } return ""; } } /** * Custom renderer to use a text area and borders etc. */ private static class MyCellRenderer extends DefaultListCellRenderer { private final JTextArea area; public MyCellRenderer() { area = new JTextArea(); area.setBorder(BorderFactory.createEmptyBorder(4, 5, 5, 5)); area.setLineWrap(true); area.setWrapStyleWord(true); } @Override public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { if (value == null) { area.setText(null); return area; } //System.out.println("Getting rubberstamp for "+value); Item item = (Item)value; // Make Text String agoText; if (System.currentTimeMillis() - item.data.created_at < 60*1000) { agoText = "now"; } else { agoText = DateTime.agoSingleCompact(item.data.created_at); } String status; if (item.hasRequestPending()) { status = "-Pending- "; } else if (item.getHandledBy() == null) { status = item.status > Item.STATUS_NONE ? "-"+item.getStatusText()+"- " : ""; } else { status = item.status > Item.STATUS_NONE ? "-"+item.getStatusText()+" by "+item.getHandledBy()+"- " : ""; } String text = String.format("%s[%s] <%s> %s", status, agoText, item.data.args.get(0), item.data.args.get(1)); area.setText(text); // Adjust size int width = list.getWidth(); if (width > 0) { area.setSize(width, Short.MAX_VALUE); } // Selected Color if (isSelected) { area.setBackground(list.getSelectionBackground()); } else { area.setBackground(list.getBackground()); } if (item.isHandled() || item.getAge() > 60 * 5) { area.setForeground(Color.GRAY); } else { area.setForeground(list.getForeground()); } return area; } } }