biskuteri-cafe-JKomasto2/TimelineWindow.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

1033 lines
25 KiB
Java

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<PostPreviewComponent> 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<String> 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<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> 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<String> 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<String>
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<String> 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<String> 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<String> openee = null;
if (query.startsWith("@")) query = query.substring(1);
List<Object> message = new ArrayList<>();
message.add("Maybe one of these?");
ButtonGroup selGroup = new ButtonGroup();
for (Tree<String> 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<PostPreviewComponent>
postPreviews;
private boolean
hoverSelect;
private Image
backgroundImage;
// - -%- -
static final int
PREVIEW_COUNT = 10;
// ---%-@-%---
public List<PostPreviewComponent>
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);
}
}