/* copyright This file is part of JKomasto2. Written in 2022 by Usawashi This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . copyright */ import javax.swing.JFrame; import javax.swing.JPanel; import javax.swing.JMenuBar; import javax.swing.JPopupMenu; import javax.swing.JMenuItem; import javax.swing.JSeparator; import javax.swing.JButton; import javax.swing.JLabel; import javax.swing.JScrollPane; import javax.swing.JScrollBar; import javax.swing.Box; import javax.swing.BoxLayout; import javax.swing.BorderFactory; import javax.swing.border.Border; import javax.swing.JOptionPane; import javax.swing.ImageIcon; import javax.swing.MenuSelectionManager; import javax.swing.MenuElement; import java.awt.Graphics; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Shape; import java.awt.Dimension; import java.awt.BorderLayout; import java.awt.GridLayout; import java.awt.Cursor; import java.awt.Image; import java.awt.Component; import java.awt.event.ActionListener; import java.awt.event.ActionEvent; import java.awt.MediaTracker; import java.util.List; import java.util.ArrayList; import java.net.URL; import java.net.MalformedURLException; import java.io.IOException; import cafe.biskuteri.hinoki.Tree; import java.text.BreakIterator; import java.util.Locale; import java.time.ZonedDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.HashMap; class PostWindow extends JFrame { private JKomasto primaire; private MastodonApi api; private Post post, wrapperPost; // - -%- - private PostComponent display; // - -%- - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("d LLLL ''uu"), TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); // ---%-@-%--- public synchronized void use(Post post) { assert post != null; if (post.boostedPost != null) { wrapperPost = post; this.post = post.boostedPost; post = post.boostedPost; } else { wrapperPost = null; this.post = post; } display.setAuthorName(post.author.name); display.setAuthorId(post.author.id); String oid = api.getAccountDetails().get("id").value; String aid = post.author.numId; display.setDeleteEnabled(aid.equals(oid)); post.author.resolveAvatar(); display.setAuthorAvatar(post.author.avatar); display.setDate(post.date); display.setTime(post.time); display.setEmojiUrls(post.emojiUrls); display.setHtml(post.text); display.setFavourited(post.favourited); display.setBoosted(post.boosted); if (post.attachments.length > 0) { post.attachments[0].resolveImage(); display.setMediaPreview(post.attachments[0].image); } else display.setMediaPreview(null); post.resolveApproximateText(); this.setTitle(post.approximateText); display.resetFocus(); repaint(); } public void readEntity(Tree post) { use(new Post(post)); } public synchronized void openAuthorProfile() { ProfileWindow w = new ProfileWindow(primaire); w.use(post.author); w.setLocationRelativeTo(this); w.setVisible(true); } public synchronized void favourite(boolean favourited) { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); display.setFavouriteBoostEnabled(false); display.paintImmediately(display.getBounds()); RequestListener handler = new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( PostWindow.this, "Tried to favourite post, failed.." + "\n" + eIo.getClass() + ": " + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( PostWindow.this, "Tried to favourite post, failed.." + "\n" + json.get("error").value + "\n(HTTP error code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { PostWindow.this.post.favourited = favourited; } }; api.setPostFavourited(post.id, favourited, handler); display.setCursor(null); display.setFavouriteBoostEnabled(true); display.repaint(); } public synchronized void boost(boolean boosted) { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); display.setFavouriteBoostEnabled(false); display.paintImmediately(display.getBounds()); RequestListener handler = new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( PostWindow.this, "Tried to boost post, failed.." + "\n" + eIo.getClass() + ": " + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( PostWindow.this, "Tried to boost post, failed.." + "\n" + json.get("error").value + "\n(HTTP error code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { PostWindow.this.post.boosted = boosted; } }; api.setPostBoosted(post.id, boosted, handler); display.setCursor(null); display.setFavouriteBoostEnabled(true); display.repaint(); } public synchronized void reply() { String ownId = api.getAccountDetails().get("acct").value; Composition c = Composition.reply(this.post, ownId); ComposeWindow w = primaire.getComposeWindow(); w.setComposition(c); if (!w.isVisible()) { w.setLocation(getX(), getY() + 100); w.setVisible(true); } } public synchronized void openMedia() { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); for (Attachment a: post.attachments) a.resolveImage(); ImageWindow w = new ImageWindow(); w.setTitle("Media - " + this.getTitle()); w.setIconImage(this.getIconImage()); w.showAttachments(post.attachments); w.setLocationRelativeTo(null); w.setVisible(true); display.setCursor(null); } public synchronized void deletePost(boolean redraft) { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); display.setDeleteEnabled(false); display.paintImmediately(display.getBounds()); final String S1 = "Are you sure you'd like to delete this post?\n"; final String S2 = "Are you sure you'd like to delete this post?\n" + "You are redrafting, so a composition window\n" + "should open with its contents filled."; JOptionPane dialog = new JOptionPane(); dialog.setMessageType(JOptionPane.QUESTION_MESSAGE); dialog.setMessage(redraft ? S2 : S1); dialog.setOptions(new String[] { "No", "Yes" }); String title = "Confirm delete"; dialog.createDialog(this, title).setVisible(true); if (!dialog.getValue().equals("Yes")) { display.setCursor(null); display.setDeleteEnabled(true); display.paintImmediately(display.getBounds()); return; } api.deletePost(post.id, new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( PostWindow.this, "Failed to delete post.." + "\n" + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( PostWindow.this, "Failed to delete post.." + "\n" + json.get("error").value + "\n(HTTP code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { setVisible(false); if (redraft) { Composition c = Composition.recover(json); ComposeWindow w = new ComposeWindow(primaire); w.setComposition(c); w.setLocation(getX(), getY() + 100); w.setVisible(true); } } }); display.setCursor(null); display.setDeleteEnabled(true); display.paintImmediately(display.getBounds()); if (!isVisible()) dispose(); } public synchronized void copyPostId() { ClipboardApi.serve(post.id); } public synchronized void copyPostLink() { ClipboardApi.serve(post.uri); } public synchronized void openReplies() { RepliesWindow w = new RepliesWindow(primaire, this); w.showFor(post.id); w.setLocation(getX(), getY() + 100); w.setVisible(true); } // ---%-@-%--- PostWindow(JKomasto primaire) { this.primaire = primaire; this.api = primaire.getMastodonApi(); getContentPane().setPreferredSize(new Dimension(360, 260)); pack(); setDefaultCloseOperation(DISPOSE_ON_CLOSE); setLocationByPlatform(true); display = new PostComponent(this); setContentPane(display); setIconImage(primaire.getProgramIcon()); } } class PostComponent extends JPanel implements ActionListener { private PostWindow primaire; // - -%- - private List authorNameOr; private RichTextPane authorName; private RichTextPane3 body; private JScrollPane bodyScrollPane; private JLabel authorId, time, date; private String[][] emojiUrls; private TwoToggleButton favouriteBoost, replyMisc, nextPrev; private RoundButton profile, media; private JPopupMenu miscMenu; private JMenuItem openReplies, copyPostId, copyPostLink, deletePost, redraftPost; private Image backgroundImage; // ---%-@-%--- public void setAuthorName(String n) { authorNameOr = new RichTextPane.Builder().text(n).finish(); } public void setAuthorId(String n) { authorId.setText(n); } public void setAuthorAvatar(Image n) { profile.setImage(n); } public void setDate(String n) { date.setText(n); } public void setTime(String n) { time.setText(n); } public void setEmojiUrls(String[][] n) { emojiUrls = n; Map emojis = new HashMap<>(); for (String[] entry: n) { emojis.put(entry[0], ImageApi.remote(entry[1])); } body.setEmojis(emojis); } public void setHtml(String n) { body.setText(BasicHTMLParser.parse(n)); } public void setFavourited(boolean a) { favouriteBoost.removeActionListener(this); favouriteBoost.setPrimaryToggled(a); favouriteBoost.addActionListener(this); } public void setBoosted(boolean a) { favouriteBoost.removeActionListener(this); favouriteBoost.setSecondaryToggled(a); favouriteBoost.addActionListener(this); } public void setFavouriteBoostEnabled(boolean a) { favouriteBoost.setEnabled(a); } public void setDeleteEnabled(boolean a) { deletePost.setEnabled(a); redraftPost.setEnabled(a); } public void setMediaPreview(Image n) { media.setImage(n); } public void resetFocus() { media.requestFocusInWindow(); } // - -%- - public void actionPerformed(ActionEvent eA) { Component src = (Component)eA.getSource(); String command = eA.getActionCommand(); if (src == profile) { primaire.openAuthorProfile(); return; } if (src == favouriteBoost) { if (command.equals("favouriteOn")) primaire.favourite(true); if (command.equals("favouriteOff")) primaire.favourite(false); if (command.equals("boostOn")) primaire.boost(true); if (command.equals("boostOff")) primaire.boost(false); return; } if (src == replyMisc) { if (miscMenu.isVisible()) { Component sel = getSelected(miscMenu); if (sel == null) return; assert sel instanceof JMenuItem; ((JMenuItem)sel).doClick(); } else if (command.startsWith("reply")) { primaire.reply(); } else if (command.startsWith("misc")) { int rx = replyMisc.getWidth() / 2; int ry = replyMisc.getHeight() - miscMenu.getHeight(); miscMenu.show(replyMisc, rx, ry); } return; } else miscMenu.setVisible(false); if (src == nextPrev) { if (command.startsWith("next")) { body.nextPage(); } else { body.previousPage(); } // First time an interactive element // doesn't call something in primaire.. return; } if (src == media) { primaire.openMedia(); return; } if (src == openReplies) primaire.openReplies(); if (src == copyPostId) primaire.copyPostId(); if (src == copyPostLink) primaire.copyPostLink(); if (src == deletePost) primaire.deletePost(false); if (src == redraftPost) primaire.deletePost(true); } protected void paintComponent(Graphics g) { g.clearRect(0, 0, getWidth(), getHeight()); int w1 = authorName.getWidth(); FontMetrics fm1 = getFontMetrics(authorName.getFont()); List lay1; lay1 = RichTextPane.layout(authorNameOr, fm1, w1); authorName.setText(lay1); if (backgroundImage != null) { int tw = backgroundImage.getWidth(this); int th = backgroundImage.getHeight(this); if (tw != -1) for (int y = 0; y < getHeight(); y += th) for (int x = 0; x < getWidth(); x += tw) { g.drawImage(backgroundImage, x, y, this); } } ((java.awt.Graphics2D)g).setRenderingHint( java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON ); } // - -%- - private static Component getSelected(JPopupMenu menu) { MenuElement[] sel = MenuSelectionManager.defaultManager() .getSelectedPath(); /* * (知) For some reason, the selection model of the * JPopupMenu doesn't do anything. So we have to * consult this apparently global menu manager. */ for (int o = 0; o < sel.length - 1; ++o) { if (sel[o] == menu) return sel[o + 1].getComponent(); } return null; } // ---%-@-%--- PostComponent(PostWindow primaire) { this.primaire = primaire; emojiUrls = new String[0][]; Border b = BorderFactory.createEmptyBorder(10, 10, 10, 10); Font f1 = new Font("MotoyaLMaru", Font.PLAIN, 18); Font f2 = new Font("MotoyaLMaru", Font.PLAIN, 14); Font f3 = new Font("MotoyaLMaru", Font.PLAIN, 18); profile = new RoundButton(); favouriteBoost = new TwoToggleButton("favourite", "boost"); replyMisc = new TwoToggleButton("reply", "misc"); nextPrev = new TwoToggleButton("next", "prev"); media = new RoundButton(); profile.addActionListener(this); favouriteBoost.addActionListener(this); replyMisc.addActionListener(this); nextPrev.addActionListener(this); media.addActionListener(this); openReplies = new JMenuItem("Browse thread"); copyPostId = new JMenuItem("Copy post ID"); copyPostLink = new JMenuItem("Copy post link"); deletePost = new JMenuItem("Delete post"); redraftPost = new JMenuItem("Delete and redraft post"); openReplies.addActionListener(this); copyPostId.addActionListener(this); copyPostLink.addActionListener(this); deletePost.addActionListener(this); redraftPost.addActionListener(this); miscMenu = new JPopupMenu(); miscMenu.add(openReplies); miscMenu.add(new JSeparator()); miscMenu.add(copyPostId); miscMenu.add(copyPostLink); miscMenu.add(new JSeparator()); miscMenu.add(deletePost); miscMenu.add(new JSeparator()); miscMenu.add(redraftPost); Box buttons = Box.createVerticalBox(); buttons.setOpaque(false); buttons.add(profile); buttons.add(Box.createVerticalStrut(8)); buttons.add(favouriteBoost); buttons.add(Box.createVerticalStrut(8)); buttons.add(replyMisc); buttons.add(Box.createVerticalStrut(8)); buttons.add(nextPrev); buttons.add(Box.createVerticalStrut(8)); buttons.add(media); buttons.setMaximumSize(buttons.getPreferredSize()); Box left = Box.createVerticalBox(); left.setOpaque(false); left.add(buttons); authorId = new JLabel(); authorName = new RichTextPane(); time = new JLabel(); date = new JLabel(); authorId.setFont(f2); date.setFont(f2); authorName.setFont(f1); time.setFont(f1); JPanel top1 = new JPanel(); top1.setOpaque(false); top1.setLayout(new BorderLayout(8, 0)); top1.add(authorId); top1.add(date, BorderLayout.EAST); JPanel top2 = new JPanel(); top2.setOpaque(false); top2.setLayout(new BorderLayout(8, 0)); top2.add(authorName); top2.add(time, BorderLayout.EAST); Box top = Box.createVerticalBox(); top.add(top1); top.add(Box.createVerticalStrut(2)); top.add(top2); body = new RichTextPane3(); body.setFont(f3); /* bodyScrollPane = new JScrollPane( body, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER ); JScrollBar vsb = bodyScrollPane.getVerticalScrollBar(); vsb.setPreferredSize(new Dimension(0, 0)); vsb.setUnitIncrement(16); bodyScrollPane.setBorder(null); bodyScrollPane.setFocusable(true); */ JPanel centre = new JPanel(); centre.setOpaque(false); centre.setLayout(new BorderLayout(0, 8)); centre.add(top, BorderLayout.NORTH); centre.add(body); setLayout(new BorderLayout(8, 0)); add(left, BorderLayout.WEST); add(centre); setBorder(b); backgroundImage = ImageApi.local("postWindow"); } }