import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.JComponent; import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JMenu; import javax.swing.JMenuItem; import javax.swing.JMenuBar; import javax.swing.JSeparator; import javax.swing.JRadioButton; import javax.swing.JOptionPane; import javax.swing.ButtonGroup; import javax.swing.Box; import javax.swing.BorderFactory; import java.awt.BorderLayout; import java.awt.GridBagLayout; import java.awt.GridBagConstraints; import java.awt.FlowLayout; import java.awt.Font; import java.awt.Dimension; import java.awt.Insets; import java.awt.Cursor; import java.awt.Color; import java.awt.Graphics; import java.awt.Image; import java.util.List; import java.util.ArrayList; import java.net.URL; import java.net.MalformedURLException; import java.awt.event.ActionListener; import java.awt.event.ActionEvent; import java.awt.event.MouseListener; import java.awt.event.MouseEvent; import java.awt.event.FocusListener; import java.awt.event.FocusEvent; import java.awt.event.KeyListener; import java.awt.event.KeyEvent; import cafe.biskuteri.hinoki.Tree; import java.io.IOException; import java.time.ZonedDateTime; import java.time.ZoneId; import java.time.Period; import java.time.temporal.ChronoUnit; import java.time.format.DateTimeParseException; class TimelineWindow extends JFrame implements ActionListener { private JKomasto primaire; private MastodonApi api; private TimelinePage page; // - -%- - private TimelineComponent display; private JMenuItem openHome, openMessages, openLocal, openFederated, openNotifications, openOwnProfile, openProfile, createPost, openAutoPostView, quit; private JMenuItem flipToNewestPost; // - -%- - private static final int PREVIEW_COUNT = TimelineComponent.PREVIEW_COUNT; // ---%-@-%--- public void setTimelineType(TimelineType type) { page.type = type; page.accountNumId = null; String s1 = type.toString(); s1 = s1.charAt(0) + s1.substring(1).toLowerCase(); setTitle(s1 + " - JKomasto"); String s2 = type.toString().toLowerCase(); display.setBackgroundImage(ImageApi.local(s2)); } public TimelineType getTimelineType() { return page.type; } public void showAuthorPosts(String authorNumId) { assert authorNumId != null; page.type = TimelineType.FEDERATED; page.accountNumId = authorNumId; setTitle(authorNumId + " - JKomasto"); display.setBackgroundImage(ImageApi.local("profile")); } public void showLatestPage() { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( page.type, PREVIEW_COUNT, null, null, page.accountNumId, page.listId, new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( TimelineWindow.this, "Failed to fetch page.." + "\n" + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( TimelineWindow.this, "Failed to fetch page.." + "\n" + json.get("error").value + "\n(HTTP code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { page.posts = json; display.displayEntities(page.posts); boolean full = json.size() >= PREVIEW_COUNT; display.setNextPageAvailable(full); display.setPreviousPageAvailable(true); display.resetFocus(); } } ); display.setCursor(null); } public void nextPage() { assert page.posts != null; assert page.posts.size() != 0; Tree last = page.posts.get(page.posts.size() - 1); String lastId = last.get("id").value; display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( page.type, PREVIEW_COUNT, lastId, null, page.accountNumId, page.listId, new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( TimelineWindow.this, "Failed to fetch page.." + "\n" + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( TimelineWindow.this, "Failed to fetch page.." + "\n" + json.get("error").value + "\n(HTTP code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { if (json.size() == 0) { // We should probably say something // to the user here? For now, we // quietly cancel. return; } page.posts = json; display.displayEntities(page.posts); boolean full = json.size() >= PREVIEW_COUNT; display.setNextPageAvailable(full); display.setPreviousPageAvailable(true); display.resetFocus(); } } ); display.setCursor(null); } public void previousPage() { assert page.posts != null; assert page.posts.size() != 0; Tree first = page.posts.get(0); String firstId = first.get("id").value; display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( page.type, PREVIEW_COUNT, null, firstId, page.accountNumId, page.listId, new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( TimelineWindow.this, "Failed to fetch page.." + "\n" + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( TimelineWindow.this, "Failed to fetch page.." + "\n" + json.get("error").value + "\n(HTTP code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { if (json.size() < PREVIEW_COUNT) { showLatestPage(); return; } page.posts = json; display.displayEntities(page.posts); display.setNextPageAvailable(true); display.setPreviousPageAvailable(true); display.resetFocus(); } } ); display.setCursor(null); } public void openOwnProfile() { Tree accountDetails = api.getAccountDetails(); String id = accountDetails.get("id").value; assert id != null; TimelineWindow w = new TimelineWindow(primaire); w.showAuthorPosts(id); w.showLatestPage(); w.setLocationRelativeTo(this); w.setVisible(true); } public void openProfile() { String query = JOptionPane.showInputDialog( this, "Whose account do you want to see?\n" + "Type an account name with the instance,\n" + "or a display name if you can't remember.\n", "Profile search", JOptionPane.PLAIN_MESSAGE ); if (query == null) return; class Handler implements RequestListener { public Tree json; // -=%=- public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( TimelineWindow.this, "Tried to fetch accounts, but it failed.." + "\n" + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( TimelineWindow.this, "Tried to fetch accounts, but it failed.." + "\n" + json.get("error").value + "\n(HTTP code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { this.json = json; } } // (知) Have to create a named class because // it has to hold the variable. Handler handler = new Handler(); api.getAccounts(query, handler); if (handler.json == null) return; if (handler.json.size() == 0) { JOptionPane.showMessageDialog( this, "There were no results from the query.. ☹️" ); return; } String id = null; if (query.startsWith("@")) query = query.substring(1); List message = new ArrayList<>(); message.add("Maybe one of these?"); ButtonGroup selGroup = new ButtonGroup(); for (Tree account: handler.json) { String dname = account.get("display_name").value; String acct = account.get("acct").value; if (query.equals(acct)) { id = account.get("id").value; break; } JRadioButton b = new JRadioButton(); b.setText(dname + " (" + acct + ")"); selGroup.add(b); message.add(b); } if (id == null) { int response = JOptionPane.showConfirmDialog( this, message.toArray(), "Search results", JOptionPane.OK_CANCEL_OPTION ); if (response == JOptionPane.CANCEL_OPTION) return; for (int o = 1; o < message.size(); ++o) { JRadioButton b = (JRadioButton)message.get(o); if (selGroup.isSelected(b.getModel())) { id = handler.json.get(o - 1).get("id").value; break; } } } TimelineWindow w = new TimelineWindow(primaire); w.showAuthorPosts(id); w.showLatestPage(); w.setLocationRelativeTo(this); w.setVisible(true); } // - -%- - public void postSelected(Tree post) { primaire.getAutoViewWindow().displayEntity(post); } public void postOpened(Tree post) { PostWindow w = new PostWindow(primaire); w.displayEntity(post); w.setLocationRelativeTo(this); w.setVisible(true); } public void actionPerformed(ActionEvent eA) { Object src = eA.getSource(); if (src == openHome) { setTimelineType(TimelineType.HOME); showLatestPage(); } if (src == openFederated) { setTimelineType(TimelineType.FEDERATED); showLatestPage(); } if (src == openLocal) { setTimelineType(TimelineType.LOCAL); showLatestPage(); } if (src == openOwnProfile) { openOwnProfile(); } if (src == openProfile) { openProfile(); } if (src == createPost) { primaire.getComposeWindow().setVisible(true); } if (src == openAutoPostView) { PostWindow w = primaire.getAutoViewWindow(); w.setLocation(getX() + 10 + getWidth(), getY()); w.setVisible(true); } if (src == openNotifications) { NotificationsWindow w = primaire.getNotificationsWindow(); w.setLocationByPlatform(true); w.setVisible(true); } if (src == flipToNewestPost) { showLatestPage(); } if (src == quit) { /* * Umm.. should we even have a quit option? * Wouldn't closing every window work? By * disposing of everyone. We won't be having any * background threads IIRC (and they can check * if the Swing thread is alive). */ dispose(); } } // - -%- - private static String plainify(String html) { // Delete all tags. StringBuilder b = new StringBuilder(); boolean in = false; for (char c: html.toCharArray()) switch (c) { case '<': in = true; break; case '>': in = false; break; default: if (!in) b.append(c); } String s = b.toString(); s = s.replaceAll("<", "<"); s = s.replaceAll(">", ">"); s = s.replaceAll(" ", ""); return s; } // ---%-@-%--- TimelineWindow(JKomasto primaire) { this.primaire = primaire; this.api = primaire.getMastodonApi(); getContentPane().setPreferredSize(new Dimension(320, 460)); pack(); setDefaultCloseOperation(DISPOSE_ON_CLOSE); openHome = new JMenuItem("Open home timeline"); openFederated = new JMenuItem("Open federated timeline"); openNotifications = new JMenuItem("Open notifications"); openOwnProfile = new JMenuItem("Open own profile"); openProfile = new JMenuItem("Open profile.."); createPost = new JMenuItem("Create a post"); openAutoPostView = new JMenuItem("Open auto post view"); quit = new JMenuItem("Quit"); openHome.addActionListener(this); openFederated.addActionListener(this); openNotifications.addActionListener(this); openOwnProfile.addActionListener(this); openProfile.addActionListener(this); createPost.addActionListener(this); openAutoPostView.addActionListener(this); quit.addActionListener(this); flipToNewestPost = new JMenuItem("Flip to newest post"); flipToNewestPost.addActionListener(this); JMenu programMenu = new JMenu("Program"); programMenu.setMnemonic(KeyEvent.VK_P); programMenu.add(openHome); programMenu.add(openFederated); programMenu.add(openNotifications); programMenu.add(openOwnProfile); programMenu.add(openProfile); programMenu.add(new JSeparator()); programMenu.add(createPost); programMenu.add(openAutoPostView); programMenu.add(new JSeparator()); programMenu.add(quit); JMenu timelineMenu = new JMenu("Timeline"); timelineMenu.setMnemonic(KeyEvent.VK_T); timelineMenu.add(flipToNewestPost); JMenuBar menuBar = new JMenuBar(); menuBar.add(programMenu); menuBar.add(timelineMenu); setJMenuBar(menuBar); page = new TimelinePage(); page.posts = new Tree(); display = new TimelineComponent(this); display.setNextPageAvailable(false); display.setPreviousPageAvailable(false); setContentPane(display); setTimelineType(TimelineType.HOME); } } class TimelineComponent extends JPanel implements ActionListener, KeyListener, MouseListener, FocusListener { private TimelineWindow primaire; private Tree posts; // - -%- - private JButton next, prev; private JLabel pageLabel; private final List postPreviews; private boolean hoverSelect; private Image backgroundImage; // - -%- - static final int PREVIEW_COUNT = 10; // ---%-@-%--- public void displayEntities(Tree postArray) { assert postArray.size() <= postPreviews.size(); this.posts = postArray; for (int o = 0; o < postArray.size(); ++o) { PostPreviewComponent c = postPreviews.get(o); Tree p = postArray.get(o); Tree a = p.get("account"); String an = a.get("display_name").value; if (an.isEmpty()) an = a.get("username").value; c.setTopLeft(an); Tree boosted = p.get("reblog"); if (boosted.size() > 0) { c.setTopLeft("boosted by " + an); p = boosted; a = p.get("account"); } String f = ""; if (p.get("media_attachments").size() > 0) f += "a"; String t = ""; try { String jv = p.get("created_at").value; ZonedDateTime pv = ZonedDateTime.parse(jv); ZoneId z = ZoneId.systemDefault(); pv = pv.withZoneSameInstant(z); ZonedDateTime now = ZonedDateTime.now(); long d = ChronoUnit.SECONDS.between(pv, now); long s = Math.abs(d); if (s < 30) t = "now"; else if (s < 60) t = d + "s"; else if (s < 3600) t = (d / 60) + "m"; else if (s < 86400) t = (d / 3600) + "h"; else t = (d / 86400) + "d"; } catch (DateTimeParseException eDt) { assert false; } c.setTopRight(f + " " + t); String html = p.get("content").value; String cw = p.get("spoiler_text").value; if (!cw.isEmpty()) c.setBottom("(" + cw + ")"); else c.setBottom(textApproximation(html)); } for (int o = posts.size(); o < postPreviews.size(); ++o) { postPreviews.get(o).reset(); } } public void setPageLabel(String label) { assert label != null; pageLabel.setText("" + label); } public void setNextPageAvailable(boolean n) { next.setEnabled(n); } public void setPreviousPageAvailable(boolean n) { prev.setEnabled(n); } public void setHoverSelect(boolean n) { hoverSelect = n; } public void setBackgroundImage(Image n) { backgroundImage = n; } public void resetFocus() { postPreviews.get(0).requestFocusInWindow(); } // - -%- - protected void paintComponent(Graphics g) { ((java.awt.Graphics2D)g).setRenderingHint( java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON ); int w = getWidth(), h = getHeight(); g.clearRect(0, 0, w, h); int h2 = h * 5 / 10, w2 = h2; int x = w - w2, y = h - h2; g.drawImage(backgroundImage, x, y, w2, h2, this); } private void select(Object c) { assert c instanceof PostPreviewComponent; for (int o = 0; o < postPreviews.size(); ++o) { PostPreviewComponent p = postPreviews.get(o); if (c == p) { primaire.postSelected(posts.get(o)); p.setSelected(true); p.repaint(); } else deselect(p); } } private void deselect(Object c) { assert c instanceof PostPreviewComponent; PostPreviewComponent p = (PostPreviewComponent)c; p.setSelected(false); p.repaint(); } private void open(Object c) { assert c instanceof PostPreviewComponent; PostPreviewComponent p = (PostPreviewComponent)c; int o = postPreviews.indexOf(p); assert o != -1; if (o >= posts.size()) return; primaire.postOpened(posts.get(o)); } public void focusGained(FocusEvent eF) { select(eF.getSource()); } public void focusLost(FocusEvent eF) { deselect(eF.getSource()); } public void mouseClicked(MouseEvent eM) { if (eM.getClickCount() == 2) open(eM.getSource()); else select(eM.getSource()); } public void mouseEntered(MouseEvent eM) { if (!hoverSelect) return; mouseClicked(eM); } public void mouseExited(MouseEvent eM) { if (!hoverSelect) return; deselect(eM.getSource()); } // (知) First time I'm using these two..! public void keyPressed(KeyEvent eK) { if (eK.getKeyCode() != KeyEvent.VK_ENTER) return; PostPreviewComponent selected = null; for (PostPreviewComponent c: postPreviews) if (c.getSelected()) selected = c; if (selected == null) return; open(selected); } public void actionPerformed(ActionEvent eA) { Object src = eA.getSource(); if (src == next) primaire.nextPage(); else if (src == prev) primaire.previousPage(); /* * I think the page previews will just forward to us. * But I think they'll have to tell us where they are * in the list somehow, because we need to show only * one post as selected. */ } public void mousePressed(MouseEvent eM) { } public void mouseReleased(MouseEvent eM) { } public void keyTyped(KeyEvent eK) { } public void keyReleased(KeyEvent eK) { } // - -%- - public static String textApproximation(String html) { StringBuilder returnee = new StringBuilder(); Tree nodes = RudimentaryHTMLParser.depthlessRead(html); if (nodes.size() == 0) return "-"; Tree first = nodes.get(0); for (Tree node: nodes) { if (node.key.equals("tag")) { if (node.get(0).key.equals("br")) returnee.append("; "); if (node.get(0).key.equals("p") && node != first) returnee.append("; "); } if (node.key.equals("emoji")) { returnee.append(":" + node.value + ":"); } if (node.key.equals("text")) { returnee.append(node.value); } } return returnee.toString(); } // ---%-@-%--- TimelineComponent(TimelineWindow primaire) { this.primaire = primaire; postPreviews = new ArrayList<>(PREVIEW_COUNT); hoverSelect = true; prev = new JButton("<"); next = new JButton(">"); prev.setEnabled(false); next.setEnabled(false); prev.setMnemonic(KeyEvent.VK_PAGE_UP); next.setMnemonic(KeyEvent.VK_PAGE_DOWN); prev.addActionListener(this); next.addActionListener(this); pageLabel = new JLabel("0"); Box bottom = Box.createHorizontalBox(); bottom.add(Box.createGlue()); bottom.add(prev); bottom.add(Box.createHorizontalStrut(8)); bottom.add(next); JPanel centre = new JPanel(); centre.setOpaque(false); centre.setLayout(new GridBagLayout()); GridBagConstraints constraints = new GridBagConstraints(); constraints.fill = GridBagConstraints.HORIZONTAL; constraints.gridx = 0; constraints.weightx = 1; constraints.insets = new Insets(4, 0, 4, 0); for (int n = PREVIEW_COUNT; n > 0; --n) { PostPreviewComponent c = new PostPreviewComponent(); c.reset(); c.addMouseListener(this); c.addFocusListener(this); c.addKeyListener(this); centre.add(c, constraints); postPreviews.add(c); } setLayout(new BorderLayout()); add(centre, BorderLayout.CENTER); add(bottom, BorderLayout.SOUTH); setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); } } class PostPreviewComponent extends JComponent { private JLabel topLeft, topRight, bottom; private boolean selected; // ---%-@-%--- public void setTopLeft(String text) { topLeft.setText(text); } public void setTopRight(String text) { topRight.setText(text); } public void setBottom(String text) { bottom.setText(text); } public void reset() { setTopLeft(" "); setTopRight(" "); setBottom(" "); } public void setSelected(boolean selected) { this.selected = selected; if (!selected) setBackground(null); else setBackground(new Color(0, 0, 0, 25)); } public boolean getSelected() { return selected; } // - -%- - protected void paintComponent(Graphics g) { if (selected) { g.setColor(getBackground()); g.fillRect(0, 0, getWidth(), getHeight()); } } // ---%-@-%--- public PostPreviewComponent() { Font f = new JLabel().getFont(); Font f1 = f.deriveFont(Font.PLAIN, 12f); Font f2 = f.deriveFont(Font.ITALIC, 12f); Font f3 = f.deriveFont(Font.PLAIN, 14f); topLeft = new JLabel(); topLeft.setFont(f1); topLeft.setOpaque(false); topRight = new JLabel(); topRight.setHorizontalAlignment(JLabel.RIGHT); topRight.setFont(f2); topRight.setOpaque(false); Box top = Box.createHorizontalBox(); top.setOpaque(false); top.add(topLeft); top.add(Box.createGlue()); top.add(topRight); bottom = new JLabel(); bottom.setFont(f3); bottom.setOpaque(false); JPanel left = new JPanel(); left.setOpaque(false); left.setLayout(new BorderLayout()); left.add(top, BorderLayout.NORTH); left.add(bottom); setFocusable(true); setOpaque(false); setLayout(new BorderLayout()); add(left); } }