mirror of
https://gitlab.com/biskuteri-cafe/JKomasto2.git
synced 2024-11-20 05:04:51 +01:00
e6fea4c061
(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.)
741 lines
18 KiB
Java
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");
|
|
}
|
|
|
|
}
|