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 WindowUpdater windowUpdater; private TimelinePage page; // - -%- - private TimelineComponent display; private JMenuItem openHome, openMessages, openLocal, openFederated, openNotifications, openOwnProfile, openProfile, createPost, openAutoPostView, quit; private JMenuItem flipToNewestPost; private boolean showingLatest; // - -%- - private static final int PREVIEW_COUNT = TimelineComponent.PREVIEW_COUNT; // ---%-@-%--- public synchronized void use(TimelinePage page) { assert page != null; this.page = page; List previews; previews = display.getPostPreviews(); int available = page.posts.length; int max = previews.size(); assert available <= max; for (int o = 0; o < available; ++o) { PostPreviewComponent preview = previews.get(o); Post post = page.posts[o]; preview.setTopLeft(post.author.name); if (post.boostedPost != null) { String s = "boosted by " + post.author.name; preview.setTopLeft(s); post = post.boostedPost; } String flags = ""; if (post.attachments.length > 0) flags += "a"; post.resolveRelativeTime(); preview.setTopRight(flags + " " + post.relativeTime); post.resolveApproximateText(); if (post.contentWarning != null) preview.setBottom("(" + post.contentWarning + ")"); else preview.setBottom(post.approximateText); } for (int o = available; o < max; ++o) { previews.get(o).reset(); } boolean full = !(available < PREVIEW_COUNT); display.setNextPageAvailable(full); display.setPreviousPageAvailable(true); display.resetFocus(); } public void readEntity(Tree postEntityArray) { TimelinePage page = new TimelinePage(postEntityArray); page.type = this.page.type; page.accountNumId = this.page.accountNumId; page.listId = this.page.listId; use(page); } public void refresh() { String firstId = null; if (!showingLatest) { assert page.posts != null; assert page.posts.length != 0; firstId = page.posts[0].id; } display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( page.type, PREVIEW_COUNT, firstId, 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() < PREVIEW_COUNT) { showLatestPage(); return; } readEntity(json); } } ); display.setCursor(null); } 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) { readEntity(json); showingLatest = true; windowUpdater.add(TimelineWindow.this); } } ); display.setCursor(null); } public void showNextPage() { assert page.posts != null; assert page.posts.length != 0; String lastId = page.posts[page.posts.length - 1].id; 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; } readEntity(json); showingLatest = false; windowUpdater.remove(TimelineWindow.this); } } ); display.setCursor(null); } public void showPreviousPage() { assert page.posts != null; assert page.posts.length != 0; String firstId = page.posts[0].id; 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; } readEntity(json); showingLatest = false; windowUpdater.remove(TimelineWindow.this); } } ); display.setCursor(null); } public synchronized void setTimelineType(TimelineType type) { assert type != TimelineType.LIST; assert type != TimelineType.PROFILE; page.type = type; page.accountNumId = null; setTitle(toString(type) + " timeline - JKomasto"); String f = type.toString().toLowerCase(); display.setBackgroundImage(ImageApi.local(f)); /* * (注) Java's image renderer draws images with transparency * darker than GIMP does. Overcompensate in lightening. */ display.repaint(); } public synchronized TimelineType getTimelineType() { return page.type; } public synchronized void showAuthorPosts(String authorNumId) { assert authorNumId != null; page.type = TimelineType.PROFILE; page.accountNumId = authorNumId; setTitle(authorNumId + " - JKomasto"); display.setBackgroundImage(ImageApi.local("profile")); display.repaint(); } public synchronized void openOwnProfile() { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); Tree accountDetails = api.getAccountDetails(); ProfileWindow w = new ProfileWindow(primaire); w.use(new Account(accountDetails)); w.setLocationByPlatform(true); w.setVisible(true); display.setCursor(null); } 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; } Tree openee = 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)) { openee = account; break; } JRadioButton b = new JRadioButton(); b.setText(dname + " (" + acct + ")"); selGroup.add(b); message.add(b); } if (openee == 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())) { openee = handler.json.get(o - 1); break; } } if (openee == null) return; /* * It seems like this can happen if someone * presses escape out of the confirm dialog. * I don't know why that doesn't map to cancel. */ } ProfileWindow w = new ProfileWindow(primaire); w.use(new Account(openee)); w.setLocationByPlatform(true); w.setVisible(true); } // - -%- - public synchronized void previewSelected(int index) { if (index > page.posts.length) return; Post post = page.posts[index - 1]; primaire.getAutoViewWindow().use(post); } public synchronized void previewOpened(int index) { if (index > page.posts.length) return; Post post = page.posts[index - 1]; PostWindow w = new PostWindow(primaire); w.use(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(); if (!w.isVisible()) { 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 toString(TimelineType type) { switch (type) { case FEDERATED: return "Federated"; case LOCAL: return "Local"; case HOME: return "Home"; default: return ""; } } // ---%-@-%--- TimelineWindow(JKomasto primaire) { this.primaire = primaire; this.api = primaire.getMastodonApi(); this.windowUpdater = primaire.getWindowUpdater(); 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 Post[0]; display = new TimelineComponent(this); display.setNextPageAvailable(false); display.setPreviousPageAvailable(false); setContentPane(display); setTimelineType(TimelineType.HOME); setIconImage(primaire.getProgramIcon()); } } class TimelineComponent extends JPanel implements ActionListener, KeyListener, MouseListener, FocusListener { private TimelineWindow primaire; // - -%- - private JButton next, prev; private JLabel pageLabel; private final List postPreviews; private boolean hoverSelect; private Image backgroundImage; // - -%- - static final int PREVIEW_COUNT = 10; // ---%-@-%--- public List getPostPreviews() { return postPreviews; } 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) { int w = getWidth(), h = getHeight(); g.clearRect(0, 0, w, h); if (backgroundImage != null) { int b = h * 5 / 10; int iw = backgroundImage.getWidth(this); int ih = backgroundImage.getHeight(this); if (ih > iw) { ih = ih * b / iw; iw = b; } else { iw = iw * b / ih; ih = b; } int x = w - iw, y = h - ih; g.drawImage(backgroundImage, x, y, iw, ih, this); } ((java.awt.Graphics2D)g).setRenderingHint( java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON ); } private void select(Object c) { assert c instanceof PostPreviewComponent; for (PostPreviewComponent p: postPreviews) { if (p == c) { int index = 1 + postPreviews.indexOf(p); primaire.previewSelected(index); p.setSelected(true); p.repaint(); } else { p.setSelected(false); p.repaint(); } } } private void deselect(Object c) { assert c instanceof PostPreviewComponent; PostPreviewComponent p = (PostPreviewComponent)c; p.setSelected(false); p.repaint(); } private void open(Object c) { int o = postPreviews.indexOf(c); assert o != -1; primaire.previewOpened(1 + 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.isSelected()) selected = c; if (selected == null) return; open(selected); } public void actionPerformed(ActionEvent eA) { Object src = eA.getSource(); if (src == next) primaire.showNextPage(); else if (src == prev) primaire.showPreviousPage(); /* * 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) { } // ---%-@-%--- 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.setBackground(null); bottom.setOpaque(false); 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; } public boolean isSelected() { return selected; } // - -%- - protected void paintComponent(Graphics g) { if (selected) { g.setColor(new Color(0, 0, 0, 25)); g.fillRect(0, 0, getWidth(), getHeight()); } } // ---%-@-%--- public PostPreviewComponent() { Font f1 = new Font("Dialog", Font.PLAIN, 12); Font f2 = new Font("Dialog", Font.ITALIC, 12); Font f3 = new Font("Dialog", Font.PLAIN, 14); 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); } }