biskuteri-cafe-JKomasto2/PostWindow.java
Snowyfox e6fea4c061 Fixed bug when redraft makes no changes
(Before this, JKomasto and sometimes the Mastodon web client would get '411 Record Not Found' when submitting the same text after deleting and redrafting. Presumably the Mastodon server caches both whether an idempotency key was fulfilled and which post it leads to, and for some reason it looks up the second and fails.)
2022-05-31 03:39:56 -04:00

741 lines
18 KiB
Java

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.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<String> 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<String> json)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Tried to favourite post, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<String> json)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Tried to boost post, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<String> json)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Failed to delete post.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<RichTextPane.Segment>
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<String, Image> 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<RichTextPane.Segment> 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");
}
}