package chatty.gui.components; import chatty.Chatty; import chatty.Helper; import chatty.gui.GuiUtil; import chatty.gui.components.menus.ContextMenu; import chatty.gui.components.menus.ContextMenuListener; import chatty.gui.components.menus.StreamsContextMenu; import chatty.gui.components.settings.ListTableModel; import chatty.util.DateTime; import chatty.util.StringUtil; import chatty.util.api.Follower; import chatty.util.api.FollowerInfo; import chatty.util.api.TwitchApi; import java.awt.Color; import java.awt.Component; import java.awt.Dimension; import java.awt.Font; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.awt.Window; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.BufferedWriter; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collection; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.swing.BorderFactory; import javax.swing.JDialog; import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JScrollPane; import javax.swing.JTable; import javax.swing.Timer; import javax.swing.table.DefaultTableCellRenderer; import javax.swing.table.TableCellRenderer; import javax.swing.table.TableColumn; /** * Dialog showing a list of followers or subscribers. The type given to the * constructor simply changes a few strings, the other functionality is * identical for both followers and subscribers. * * @author tduva */ public class FollowersDialog extends JDialog { public enum Type { FOLLOWERS("Followers"), SUBSCRIBERS("Subscribers"); private final String name; Type(String name) { this.name = name; } @Override public String toString() { return name; } } /** * In what interval to redraw the GUI/try to request new data. */ private static final int REFRESH_TIMER = 10*1000; private final JLabel total = new JLabel("Total: 123.456 (+123.456)"); private final JLabel stats = new JLabel("| Week: 99+ | Day: 99+ | Hour: 23"); private final JTable table; private final ListTableModel<Follower> followers = new MyListTableModel(); private final JLabel loadInfo = new JLabel(); private final TwitchApi api; private final Type type; private final ContextMenuListener contextMenuListener; private final MyContextMenu mainContextMenu = new MyContextMenu(); /** * What stream the dialog was opened for. */ private String stream; /** * The most recently received data. */ private FollowerInfo currentInfo; /** * The most recent received data that wasn't an error. */ private FollowerInfo lastValidInfo; /** * Whether data was requested and we're currently waiting for the response. */ private boolean loading; /** * When the data was last updated. */ private long lastUpdated = -1; public FollowersDialog(Type type, Window owner, final TwitchApi api, ContextMenuListener contextMenuListener) { super(owner); this.contextMenuListener = contextMenuListener; this.type = type; this.api = api; // Layout setLayout(new GridBagLayout()); GridBagConstraints gbc; gbc = GuiUtil.makeGbc(0, 0, 1, 1, GridBagConstraints.WEST); total.setToolTipText("Total number of "+type); add(total, gbc); gbc = GuiUtil.makeGbc(0, 1, 1, 1, GridBagConstraints.WEST); gbc.insets = new Insets(0, 6, 3, 5); gbc.weightx = 1; stats.setToolTipText(type+" in the last 7 days (Week), 24 hours (Day) and Hour (based on the current list)"); add(stats, gbc); gbc = GuiUtil.makeGbc(0, 2, 2, 1); gbc.fill = GridBagConstraints.BOTH; gbc.weightx = 1; gbc.weighty = 1; gbc.insets = new Insets(5, 4, 5, 4); table = new JTable(followers); table.setShowGrid(false); table.setTableHeader(null); table.getColumnModel().getColumn(0).setCellRenderer(new MyRenderer(MyRenderer.Type.NAME)); table.getColumnModel().getColumn(1).setCellRenderer(new MyRenderer(MyRenderer.Type.TIME)); table.setIntercellSpacing(new Dimension(0, 0)); table.setFont(table.getFont().deriveFont(Font.BOLD)); table.setRowHeight(table.getFontMetrics(table.getFont()).getHeight()+2); add(new JScrollPane(table), gbc); gbc = GuiUtil.makeGbc(0, 3, 2, 1, GridBagConstraints.WEST); gbc.insets = new Insets(2, 5, 5, 5); add(loadInfo, gbc); // Timer Timer timer = new Timer(REFRESH_TIMER, new ActionListener() { @Override public void actionPerformed(ActionEvent e) { request(); update(); table.repaint(); } }); timer.setRepeats(true); timer.start(); // Listener table.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { selectClicked(e); openContextMenu(e); } @Override public void mouseReleased(MouseEvent e) { selectClicked(e); openContextMenu(e); } }); // Add to content pane, seems to work better than adding to "this" getContentPane().addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { openMainContextMenu(e); } @Override public void mouseReleased(MouseEvent e) { openMainContextMenu(e); } }); pack(); setSize(300,400); } /** * Select the row the mouse cursor is over, except if it is already over an * selected row, in which case it just keeps the current selection. * * @param e The MouseEvent */ private void selectClicked(MouseEvent e) { int row = table.rowAtPoint(e.getPoint()); if (row != -1 && !table.isRowSelected(row)) { table.getSelectionModel().setSelectionInterval(row, row); } } /** * Open the context menu for the given MouseEvent if it is the popup trigger * and rows are selected. * * @param e The MouseEvent */ private void openContextMenu(MouseEvent e) { if (e.isPopupTrigger()) { Collection<String> streams = new HashSet<>(); int[] selectedRows = table.getSelectedRows(); for (int selectedRow : selectedRows) { Follower selected = followers.get(selectedRow); streams.add(StringUtil.toLowerCase(selected.name)); } if (!streams.isEmpty()) { StreamsContextMenu m = new StreamsContextMenu(streams, contextMenuListener); m.show(table, e.getX(), e.getY()); } } } private void openMainContextMenu(MouseEvent e) { if (e.isPopupTrigger()) { mainContextMenu.show(e.getComponent(), e.getX(), e.getY()); } } /** * Adjust the width of the time column to fit the current times. */ private void adjustColumnSize() { int width = 0; for (int row = 0; row < table.getRowCount(); row++) { TableCellRenderer renderer = table.getCellRenderer(row, 1); Component comp = table.prepareRenderer(renderer, row, 1); width = Math.max(comp.getPreferredSize().width, width); } setColumnWidth(1, width, width, width); } /** * Helper method to set a fixed column width. * * @param column * @param width * @param minwidth * @param maxwidth */ private void setColumnWidth(int column, int width, int minwidth, int maxwidth) { TableColumn tc = table.getColumnModel().getColumn(column); tc.setPreferredWidth(width); if (maxwidth > 0) { tc.setMaxWidth(maxwidth); } if (minwidth > 0) { tc.setMinWidth(minwidth); } } /** * Try to request new data if the dialog is open and a stream is set. */ private void request() { if (isVisible() && stream != null && !stream.isEmpty()) { loading = true; if (type == Type.FOLLOWERS) { api.getFollowers(stream); } else if (type == Type.SUBSCRIBERS) { api.getSubscribers(stream); } } } /** * Update the last loaded label and the stats. */ private void update() { if (currentInfo != null) { if (currentInfo.requestError) { if (lastUpdated != -1) { loadInfo.setText(String.format("%s (%s ago, updated %s ago)", currentInfo.requestErrorDescription, DateTime.agoSingleCompact(currentInfo.time), DateTime.agoSingleCompact(lastUpdated)) ); } else { loadInfo.setText(String.format("%s (%s ago)", currentInfo.requestErrorDescription, DateTime.agoSingleCompact(currentInfo.time)) ); } } else { loadInfo.setText(String.format("Last updated %s ago", DateTime.agoSingleVerbose(lastUpdated)) ); } } if (loading) { loadInfo.setText("Loading.."); } updateStats(); } /** * Open the dialog with the given stream. * * @param stream */ public void showDialog(String stream) { this.stream = stream; setTitle(type+" of "+stream+" (100 most recent)"); if (currentInfo == null || !currentInfo.stream.equals(stream)) { // Set to default if no info is set yet or if it is opened on a // different channel than before. followers.clear(); total.setText("Total: -"); stats.setText(null); currentInfo = null; lastValidInfo = null; lastUpdated = -1; updateStats(); } setVisible(true); request(); update(); } /** * Set the FollowerInfo as it is received (from API or cache), if it is for * the same stream as the stream this dialog is currently opened for. * * @param info The FollowerInfo to set */ public void setFollowerInfo(FollowerInfo info) { if (info.stream.equals(stream)) { loading = false; if (!info.requestError && (currentInfo == null || currentInfo.time != info.time)) { // Only actually refresh list if this is new FollowerInfo and // not cached (and no error) setNewFollowerInfo(info); } currentInfo = info; update(); } } /** * Set FollowerInfo that is actually new (and not cached). * * @param info The FollwerInfo */ private void setNewFollowerInfo(FollowerInfo info) { lastValidInfo = info; followers.setData(info.followers); updateTotalLabel(info, currentInfo); lastUpdated = info.time; adjustColumnSize(); } /** * Update the total label, based on new and old data (to show the difference * if applicable). * * @param newInfo * @param oldInfo */ private void updateTotalLabel(FollowerInfo newInfo, FollowerInfo oldInfo) { if (oldInfo != null && newInfo != oldInfo && oldInfo.stream.equals(stream) && !oldInfo.requestError) { int change = newInfo.total - oldInfo.total; String changeString = ""; if (change < 0) { changeString = " (" + String.valueOf(change) + ")"; } else if (change > 0) { changeString = " (+" + change + ")"; } total.setText("Total: " + Helper.formatViewerCount(newInfo.total) + changeString); } else { total.setText("Total: " + Helper.formatViewerCount(newInfo.total)); } } /** * Update the stats label. */ private void updateStats() { if (lastValidInfo != null) { stats.setText("| "+Stats.makeFullStats(lastValidInfo)); //System.out.println("Update stats"); } else { stats.setText("| Week: - | Day: - | Hour: -"); } } /** * Save last valid FollowerInfo entries to file. * * @param onlyName Whether to only save the name, or including times */ private void saveToFile(boolean onlyName) { FollowerInfo info = lastValidInfo; if (info != null) { Path path = Paths.get(Chatty.getUserDataDirectory(),"exported"); Path file = path.resolve(StringUtil.toLowerCase(type.toString())+".txt"); try { Files.createDirectories(path); try (BufferedWriter writer = Files.newBufferedWriter(file, Charset.forName("UTF-8"))) { for (Follower f : lastValidInfo.followers) { writer.write(f.name); if (!onlyName) { writer.write("\t" + DateTime.formatFullDatetime(f.time)); writer.write(" (" + DateTime.agoSingleVerbose(f.time) + ")"); } writer.newLine(); } } } catch (IOException ex) { JOptionPane.showMessageDialog(this, ex, "Failed to write file.", JOptionPane.ERROR_MESSAGE); return; } JOptionPane.showMessageDialog(this, type+" exported to: "+file, "File written.", JOptionPane.INFORMATION_MESSAGE); } else { JOptionPane.showMessageDialog(this, "No data to write.", "Failed to write file.", JOptionPane.ERROR_MESSAGE); } } /** * Renderer for both the name and time row, behaving slightly different * depending on the type set (but same background colors). */ private static class MyRenderer extends DefaultTableCellRenderer { private static final Color BG_COLOR_NEW = new Color(255,245,210); private static final Color BG_COLOR_RECENT = new Color(255,250,240); private static final Color BG_COLOR_HOUR = new Color(245,245,245); private static final Color COLOR_OLDER_THAN_WEEK = new Color(180, 180, 180); private static final Color COLOR_OLDER_THAN_DAY = new Color(120, 120, 120); private final Type type; public enum Type { NAME, TIME } public MyRenderer(Type type) { this.type = type; setBorder(BorderFactory.createEmptyBorder(2, 5, 2, 5)); } @Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { /** * In rare cases apparently value can be null (even though no * Follower object can be null). */ if (value == null) { setText(""); setToolTipText("error"); return this; } Follower f = (Follower)value; // Text if (type == Type.NAME) { if (f.name.equalsIgnoreCase(f.display_name)) { setText(f.display_name); setToolTipText(f.display_name); } else { setText(f.display_name+" ("+f.name+")"); setToolTipText(f.display_name+" ("+f.name+")"); } } else { setText(DateTime.agoSingleVerbose(f.time)); setToolTipText(DateTime.formatFullDatetime(f.time)); } // Colors if (isSelected) { setBackground(table.getSelectionBackground()); setForeground(table.getSelectionForeground()); } else { setForeground(table.getForeground()); // Set background for both name and time long ago = (System.currentTimeMillis() - f.time) / 1000; if (f.newFollower) { setBackground(BG_COLOR_NEW); } else if (ago < 15 * 60) { setBackground(BG_COLOR_RECENT); } else if (ago < 60 * 60) { setBackground(BG_COLOR_HOUR); } else { setBackground(table.getBackground()); } // Set foreground for time if (type == Type.TIME) { if (ago > 60 * 60 * 24 * 7) { setForeground(COLOR_OLDER_THAN_WEEK); } else if (ago > 60 * 60 * 24) { setForeground(COLOR_OLDER_THAN_DAY); } } // Set foreground for name if (type == Type.NAME) { if (f.refollow) { setForeground(Color.GRAY); } } } // Set alignment if (type == Type.TIME) { setHorizontalAlignment(JLabel.RIGHT); } else { setHorizontalAlignment(JLabel.LEFT); } return this; } } /** * Helper class making stats out of a FollowerInfo. */ private static class Stats { private static final Map<Integer, String> TIMES = new LinkedHashMap<>(); private static final Map<Integer, String> TIMES2 = new LinkedHashMap<>(); static { add(60*60*24*30, "This Month"); add(60*60*24*7, "This Week"); add(60*60*24, "Today"); add(60*60, "Last Hour"); add(60*30, "Last 30 Minutes"); add(60*15, "Last 15 Minutes"); add(60*5, "Last 5 Minutes"); add2(60*60*24*7, "Week"); add2(60*60*24, "Day"); add2(60*60, "Hour"); } private static void add(int seconds, String label) { TIMES.put(seconds, label); } private static void add2(int seconds, String label) { TIMES2.put(seconds, label); } private static String makeFullStats(FollowerInfo info) { if (info.requestError) { return ""; } StringBuilder b = new StringBuilder(); List<Follower> followers = info.followers; boolean first = true; for (Integer time : TIMES2.keySet()) { if (!first) { b.append(" | "); } b.append(statsForTime(followers, time, TIMES2.get(time))); first = false; } return b.toString(); } private static String statsForTime(List<Follower> followers, int seconds, String label) { boolean ok = false; for (int i = followers.size() - 1; i >= 0; i--) { Follower f = followers.get(i); if ((System.currentTimeMillis() - f.time) / 1000 > seconds) { ok = true; } else if (ok) { return label + ": " + (i + 1); } else { return label + ": " +i+"+"; } } return label + ": 0"; } private static String makeStats(FollowerInfo info) { if (info.requestError) { return ""; } List<Follower> followers = info.followers; for (Integer time : TIMES.keySet()) { boolean ok = false; for (int i = followers.size()-1; i > 0; i--) { Follower f = followers.get(i); if ((System.currentTimeMillis() - f.time)/1000 > time) { ok = true; } else if (ok) { return TIMES.get(time)+": "+(i+1); } } } return ""; } } /** * Table Model with 2 columns. */ private class MyListTableModel extends ListTableModel<Follower> { public MyListTableModel() { super(new String[]{"Name","Followed"}); } /** * Return the Follower for both columns, the renderer needs it to draw * the appropriate background color etc. * * @param rowIndex * @param columnIndex * @return */ @Override public Object getValueAt(int rowIndex, int columnIndex) { return get(rowIndex); } } private class MyContextMenu extends ContextMenu { public MyContextMenu() { final String saveMenu = "Export list to file"; addItem("saveSimple", "Names only", saveMenu); addItem("saveVerbose", "Names and dates", saveMenu); } @Override public void actionPerformed(ActionEvent e) { if (e.getActionCommand().equals("saveSimple")) { saveToFile(true); } else if (e.getActionCommand().equals("saveVerbose")) { saveToFile(false); } } } }