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

853 lines
21 KiB
Java

import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.JLabel;
import javax.swing.JComboBox;
import javax.swing.JButton;
import javax.swing.JSeparator;
import javax.swing.Box;
import javax.swing.BorderFactory;
import javax.swing.JOptionPane;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.UIManager;
import javax.swing.JToggleButton;
import javax.swing.ButtonGroup;
import javax.swing.border.Border;
import javax.swing.JPopupMenu;
import javax.swing.JMenuItem;
import javax.swing.JFileChooser;
import javax.swing.ImageIcon;
import javax.swing.text.JTextComponent;
import java.nio.file.Files;
import java.awt.GridLayout;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseEvent;
import java.awt.Cursor;
import java.awt.Color;
import java.awt.Font;
import java.awt.Insets;
import java.awt.FlowLayout;
import java.awt.Component;
import java.awt.Container;
import java.awt.Image;
import java.io.File;
import javax.swing.event.CaretListener;
import javax.swing.event.CaretEvent;
import java.util.List;
import java.util.ArrayList;
import cafe.biskuteri.hinoki.Tree;
import java.io.IOException;
class
ComposeWindow extends JFrame {
private JKomasto
primaire;
private MastodonApi
api;
// - -%- -
private Composition
composition;
private ComposeComponent
contentsDisplay;
private AttachmentsComponent
attachmentsDisplay;
private JTabbedPane
tabs;
// ---%-@-%---
public synchronized void
setComposition(Composition composition)
{
assert composition != null;
this.composition = composition;
syncDisplayToComposition();
}
public synchronized void
newComposition()
{
composition = new Composition();
composition.text = "";
composition.visibility = PostVisibility.UNLISTED;
composition.replyToPostId = null;
composition.contentWarning = null;
syncDisplayToComposition();
}
public synchronized void
submit()
{
syncCompositionToDisplay();
if (composition.replyToPostId != null)
assert !composition.replyToPostId.trim().isEmpty();
if (composition.contentWarning != null)
assert !composition.contentWarning.trim().isEmpty();
// Oh, this is gonna be hell.
/*
* (未) For every attachment in composition,
* attempt to upload it, then fill the media ID.
* Then change MastodonApi#submit to accept an
* array of media IDs. If any upload fails,
* show an error message then return.
*/
contentsDisplay.setSubmitting(true);
api.submit(
composition.text, composition.visibility,
composition.replyToPostId, composition.contentWarning,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
ComposeWindow.this,
"Tried to submit post, failed..."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
ComposeWindow.this,
"Tried to submit post, failed..."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
newComposition();
}
}
);
contentsDisplay.setSubmitting(false);
}
// - -%- -
private synchronized void
syncDisplayToComposition()
{
ComposeComponent d1 = contentsDisplay;
d1.setText(composition.text);
d1.setReplyToPostId(composition.replyToPostId);
d1.setVisibility(stringFor(composition.visibility));
d1.setContentWarning(composition.contentWarning);
AttachmentsComponent d2 = attachmentsDisplay;
d2.setAttachments(composition.attachments);
}
private synchronized void
syncCompositionToDisplay()
{
Composition c = composition;
ComposeComponent d1 = contentsDisplay;
c.text = d1.getText();
c.visibility = visibilityFrom(d1.getVisibility());
c.replyToPostId = nonEmpty(d1.getReplyToPostId());
c.contentWarning = nonEmpty(d1.getContentWarning());
AttachmentsComponent d2 = attachmentsDisplay;
c.attachments = d2.getAttachments();
}
// - -%- -
private static String
nonEmpty(String s)
{
if (s.trim().isEmpty()) return null;
return s;
}
// ---%-@-%---
ComposeWindow(JKomasto primaire)
{
super("Submit a new post");
this.primaire = primaire;
this.api = primaire.getMastodonApi();
Dimension sz = new Dimension(360, 270);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
contentsDisplay = new ComposeComponent(this);
attachmentsDisplay = new AttachmentsComponent(this);
newComposition();
tabs = new JTabbedPane();
contentsDisplay.setPreferredSize(sz);
tabs.addTab("Text", contentsDisplay);
tabs.addTab("Media", attachmentsDisplay);
setBackground((Color)UIManager.get("TabbedPane.tabAreaBackground"));
setContentPane(tabs);
pack();
setIconImage(primaire.getProgramIcon());
}
// - -%- -
private static final String
stringFor(PostVisibility visibility)
{
switch (visibility)
{
case PUBLIC: return "Public";
case UNLISTED: return "Unlisted";
case FOLLOWERS: return "Followers";
case MENTIONED: return "Mentioned";
}
assert false; return null;
}
private static final PostVisibility
visibilityFrom(String string)
{
if (string.equals("Public"))
return PostVisibility.PUBLIC;
if (string.equals("Unlisted"))
return PostVisibility.UNLISTED;
if (string.equals("Followers"))
return PostVisibility.FOLLOWERS;
if (string.equals("Mentioned"))
return PostVisibility.MENTIONED;
assert false; return null;
}
}
class
ComposeComponent extends JPanel
implements ActionListener, CaretListener, KeyListener {
private ComposeWindow
primaire;
// - -%- -
private JTextArea
text;
private JTextField
reply, contentWarning;
private JLabel
textLength;
private JComboBox<String>
visibility;
private JButton
submit;
// ---%-@-%---
public void
setText(String text)
{
this.text.setText(text);
}
public void
setReplyToPostId(String postId)
{
this.reply.setText(postId);
}
public void
setVisibility(String visibility)
{
if (visibility.equals("Public"))
this.visibility.setSelectedIndex(0);
else if (visibility.equals("Unlisted"))
this.visibility.setSelectedIndex(1);
else if (visibility.equals("Followers"))
this.visibility.setSelectedIndex(2);
else if (visibility.equals("Mentioned"))
this.visibility.setSelectedIndex(3);
}
public void
setContentWarning(String contentWarning)
{
this.contentWarning.setText(contentWarning);
}
public String
getText()
{
return text.getText();
}
public String
getReplyToPostId()
{
return reply.getText();
}
public String
getContentWarning()
{
return contentWarning.getText();
}
public String
getVisibility()
{
return (String)visibility.getSelectedItem();
}
public void
setSubmitting(boolean submitting)
{
if (submitting)
{
text.setEnabled(false);
visibility.setEnabled(false);
submit.setEnabled(false);
setCursor(new Cursor(Cursor.WAIT_CURSOR));
}
else
{
text.setEnabled(true);
visibility.setEnabled(true);
submit.setEnabled(true);
setCursor(null);
}
}
// - -%- -
public void
actionPerformed(ActionEvent eA)
{
if (eA.getSource() == submit)
primaire.submit();
}
public void
caretUpdate(CaretEvent eCa) { updateTextLength(); }
public void
keyPressed(KeyEvent eK)
{
boolean esc = eK.getKeyCode() == KeyEvent.VK_ESCAPE;
if (esc)
{
Container fcr = getFocusCycleRootAncestor();
fcr.getFocusTraversalPolicy()
.getComponentAfter(fcr, text)
.requestFocusInWindow();
}
else updateTextLength();
}
public void
keyReleased(KeyEvent eK) { }
public void
keyTyped(KeyEvent eK) { }
private void
updateTextLength()
{
int length = text.getText().length();
/*
* The web interface doesn't do this expensive thing.
* It has an upwards counter, incremented by I'm not
* sure what. Presumably they have some control over
* the text input. I'd rather not, cause I use a
* Japanese IME, I'm going to see how laggy this is.
* It raises our app's system requirements, but, I was
* going to transition it to multithreading anyways,
* I don't think we're going to be very cheap.. Which
* sucks, but the Mastodon API is not helping us here.
*/
textLength.setText(Integer.toString(length));
/*
* Another thing I could do is temporarily move the
* caret to the end and then find its position, then
* seek back. Not sure how much that would help, but
* if this is too laggy, that's what I'd try next.
*/
}
// ---%-@-%---
ComposeComponent(ComposeWindow primaire)
{
this.primaire = primaire;
Border b1 = BorderFactory.createEmptyBorder(8, 8, 8, 8);
Border b2 = BorderFactory.createEmptyBorder(4, 4, 4, 4);
Border b3 = BorderFactory.createLineBorder(Color.GRAY);
Border bc = BorderFactory.createCompoundBorder(b3, b2);
TextActionPopupMenu textActionPopup;
textActionPopup = new TextActionPopupMenu();
reply = new JTextField();
JLabel replyLabel = new JLabel("In reply to: ");
replyLabel.setLabelFor(reply);
reply.addMouseListener(textActionPopup);
contentWarning = new JTextField();
JLabel cwLabel = new JLabel("Content warning: ");
cwLabel.setLabelFor(contentWarning);
contentWarning.addMouseListener(textActionPopup);
JPanel top = new JPanel();
top.setOpaque(false);
top.setLayout(new GridLayout(2, 2, 8, 0));
top.add(replyLabel);
top.add(reply);
top.add(cwLabel);
top.add(contentWarning);
textLength = new JLabel("0");
textLength.setFont(textLength.getFont().deriveFont(14f));
visibility = new JComboBox<>(new String[] {
"Public",
"Unlisted",
"Followers",
"Mentioned"
// Where should we be saving strings..
});
visibility.setPreferredSize(new Dimension(48, 24));
submit = new JButton("Submit");
submit.addActionListener(this);
Box bottom = Box.createHorizontalBox();
bottom.add(Box.createGlue());
bottom.add(textLength);
bottom.add(Box.createHorizontalStrut(12));
bottom.add(visibility);
bottom.add(Box.createHorizontalStrut(12));
bottom.add(submit);
text = new JTextArea();
text.setLineWrap(true);
text.setWrapStyleWord(true);
text.setFont(text.getFont().deriveFont(16f));
text.setBorder(bc);
text.addCaretListener(this);
text.addKeyListener(this);
text.addMouseListener(textActionPopup);
setLayout(new BorderLayout(0, 8));
add(top, BorderLayout.NORTH);
add(text, BorderLayout.CENTER);
add(bottom, BorderLayout.SOUTH);
setBorder(b1);
}
}
class
AttachmentsComponent extends JPanel
implements ActionListener {
private ComposeWindow
primaire;
// - -%- -
private List<Attachment>
working;
private JPanel
selections;
private ButtonGroup
selectionsGroup;
private JToggleButton
attachment1,
attachment2,
attachment3,
attachment4;
private JButton
add;
private JButton
delete,
revert;
private JLabel
descriptionLabel;
private JTextArea
description;
private JFileChooser
chooser;
// ---%-@-%---
public void
setAttachments(Attachment[] n)
{
working.clear();
if (n != null) for (Attachment attachment: n)
{
working.add(attachment);
}
updateButtons();
}
public Attachment[]
getAttachments()
{
return working.toArray(new Attachment[0]);
}
// - -%- -
private void
updateButtons()
{
Dimension sz = add.getPreferredSize();
selections.removeAll();
if (working.size() > 0) selections.add(attachment1);
if (working.size() > 1) selections.add(attachment2);
if (working.size() > 2) selections.add(attachment3);
if (working.size() > 3) selections.add(attachment4);
if (working.size() < 4) selections.add(add);
if (working.size() > 3)
{
attachment4.doClick();
Image i = working.get(3).image;
attachment4.setIcon(new ImageIcon(i));
}
else if (working.size() > 2)
{
attachment3.doClick();
Image i = working.get(2).image;
attachment3.setIcon(new ImageIcon(i));
}
else if (working.size() > 1)
{
attachment2.doClick();
Image i = working.get(1).image;
attachment2.setIcon(new ImageIcon(i));
}
else if (working.size() > 0)
{
attachment1.doClick();
Image i = working.get(0).image;
attachment1.setIcon(new ImageIcon(i));
}
else selectionsGroup.clearSelection();
int bw = sz.width;
int hgap = 4;
int count = selections.getComponents().length;
int w = count * bw + (count - 1) * hgap;
int h = bw;
selections.setPreferredSize(new Dimension(w, h));
selections.setMaximumSize(new Dimension(w, h));
selections.revalidate();
}
public void
actionPerformed(ActionEvent eA)
{
Object src = eA.getSource();
if (false)
{
// Clicked on filled attachment button.
}
if (src == add)
{
int r = chooser.showOpenDialog(this);
if (r != JFileChooser.APPROVE_OPTION) return;
File f = chooser.getSelectedFile();
Attachment a = new Attachment();
a.uploadee = f;
String mime = "", primary = "";
try
{
mime = Files.probeContentType(f.toPath());
primary = mime.split("/")[0];
// Too lazy to instantiate a
// javax.activation.MimeType.
}
catch (IOException eIo) { }
if (primary.equals("image"))
{
String urlr = f.toURI().toString();
a.image = ImageApi.remote(urlr);
}
working.add(a);
updateButtons();
}
if (src == delete)
{
Object sm = selectionsGroup.getSelection();
if (sm == attachment1.getModel())
{
assert working.size() > 0;
working.remove(0);
updateButtons();
}
if (sm == attachment2.getModel())
{
assert working.size() > 1;
working.remove(1);
updateButtons();
}
if (sm == attachment3.getModel())
{
assert working.size() > 2;
working.remove(2);
updateButtons();
}
if (sm == attachment4.getModel())
{
assert working.size() > 3;
working.remove(3);
updateButtons();
}
return;
}
if (src == revert)
{
return;
}
}
// ---%-@-%---
AttachmentsComponent(ComposeWindow primaire)
{
this.primaire = primaire;
Border b1 = BorderFactory.createEmptyBorder(8, 8, 8, 8);
Border b2 = BorderFactory.createEmptyBorder(4, 4, 4, 4);
Border b3 = BorderFactory.createLineBorder(Color.GRAY);
Border b4 = BorderFactory.createEmptyBorder(4, 8, 8, 8);
Border b5 = BorderFactory.createEtchedBorder();
Border bc1 = BorderFactory.createCompoundBorder(b3, b2);
Border bc2 = BorderFactory.createCompoundBorder(b4, b2);
TextActionPopupMenu textActionPopup;
textActionPopup = new TextActionPopupMenu();
chooser = new JFileChooser();
add = new JButton("+");
add.setPreferredSize(new Dimension(32, 32));
add.setMargin(new Insets(0, 0, 0, 0));
add.addActionListener(this);
attachment1 = new JToggleButton("1");
attachment2 = new JToggleButton("2");
attachment3 = new JToggleButton("3");
attachment4 = new JToggleButton("4");
attachment1.setMargin(add.getMargin());
attachment2.setMargin(add.getMargin());
attachment3.setMargin(add.getMargin());
attachment4.setMargin(add.getMargin());
selections = new JPanel();
selections.setOpaque(false);
selections.setLayout(new GridLayout(1, 0, 4, 0));
working = new ArrayList<Attachment>();
selectionsGroup = new ButtonGroup();
selectionsGroup.add(attachment1);
selectionsGroup.add(attachment2);
selectionsGroup.add(attachment3);
selectionsGroup.add(attachment4);
// Have to add selection listener to button group
updateButtons();
JButton del = new JButton("D");
JButton rev = new JButton("R");
JButton ml = new JButton("");
JButton mr = new JButton("");
del.setMargin(new Insets(0, 0, 0, 0));
//ml.setMargin(new Insets(0, 0, 0, 0));
//mr.setMargin(new Insets(0, 0, 0, 0));
rev.setMargin(new Insets(0, 0, 0, 0));
JPanel actions = new JPanel();
actions.setOpaque(false);
actions.setLayout(new GridLayout(1, 4, 4, 4));
actions.add(del);
actions.add(rev);
actions.add(ml);
actions.add(mr);
actions.setPreferredSize(new Dimension(108, 24));
actions.setMaximumSize(new Dimension(108, 24));
delete = new JButton("Delete");
revert = new JButton("Revert");
delete.addActionListener(this);
revert.addActionListener(this);
Box top = Box.createHorizontalBox();
top.add(selections);
top.add(Box.createGlue());
Box bottom = Box.createHorizontalBox();
bottom.add(ml);
bottom.add(mr);
bottom.add(Box.createHorizontalStrut(8));
bottom.add(delete);
bottom.add(Box.createGlue());
bottom.add(revert);
description = new JTextArea();
description.setLineWrap(true);
description.setWrapStyleWord(true);
java.awt.Font f = description.getFont();
description.setFont(f.deriveFont(16f));
description.setBorder(bc1);
description.addMouseListener(textActionPopup);
descriptionLabel = new JLabel("Description");
descriptionLabel.setLabelFor(description);
JPanel row1 = new JPanel();
row1.setOpaque(false);
row1.setLayout(new BorderLayout());
row1.add(descriptionLabel, BorderLayout.NORTH);
row1.add(description, BorderLayout.CENTER);
/*
Box row2 = Box.createHorizontalBox();
row2.add(Box.createGlue());
row2.add(delete);
row2.add(Box.createHorizontalStrut(8));
row2.add(revert);
*/
Box centre = Box.createVerticalBox();
centre.setBorder(b4);
centre.add(row1);
setLayout(new BorderLayout(8, 8));
add(centre, BorderLayout.CENTER);
add(top, BorderLayout.NORTH);
add(bottom, BorderLayout.SOUTH);
setBorder(b1);
}
}
class
TextActionPopupMenu extends JPopupMenu
implements MouseListener, ActionListener {
private JMenuItem
copy,
cut,
paste;
private long
pressed;
// ---%-@-%---
public void
mousePressed(MouseEvent eM)
{
assert eM.getSource() instanceof JTextComponent;
if (!eM.isPopupTrigger()) return;
show((Component)eM.getSource(), eM.getX(), eM.getY());
}
public void
mouseReleased(MouseEvent eM)
{
if (eM.getClickCount() == 0) setVisible(false);
}
public void
mouseClicked(MouseEvent eM) { }
public void
mouseEntered(MouseEvent eM) { }
public void
mouseExited(MouseEvent eM) { }
public void
actionPerformed(ActionEvent eA)
{
assert getInvoker() instanceof JTextComponent;
JTextComponent inv = (JTextComponent)getInvoker();
if (eA.getSource() == copy) inv.copy();
if (eA.getSource() == cut) inv.cut();
if (eA.getSource() == paste) inv.paste();
}
// ---%-@-%---
public
TextActionPopupMenu()
{
copy = new JMenuItem("Copy");
cut = new JMenuItem("Cut");
paste = new JMenuItem("Paste");
copy.addActionListener(this);
cut.addActionListener(this);
paste.addActionListener(this);
add(cut);
add(copy);
add(paste);
}
}