/* 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.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.border.Border; import javax.swing.JPopupMenu; import javax.swing.JMenuItem; import javax.swing.JFileChooser; import javax.swing.ImageIcon; import javax.swing.text.JTextComponent; import javax.swing.undo.UndoableEdit; import javax.swing.undo.UndoManager; import javax.swing.event.UndoableEditListener; import javax.swing.event.UndoableEditEvent; 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.event.ComponentListener; import java.awt.event.ComponentEvent; 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(); //tabs.setCursor(new Cursor(Cursor.WAIT_CURSOR)); /* * setCursor only works for components that are enabled. * I don't think there's any technical reason for this, * but it's what it is.. rely on the enablement visuals * or a statusbar to indicate to user. * * If we really wanted it, I suspect we could use a glass * pane (or a scroll pane with no scrolling) to have an * enabled pass-through on top. * * Technically contentsDisplay and attachmentsDisplay * themselves aren't disabled. But disabling the tab pane * covers both the tab area and content area. We can't * just the tab area, except maybe by disabling the * individual tabs. */ tabs.setEnabled(false); tabs.setSelectedComponent(contentsDisplay); contentsDisplay.setSubmitting(true); tabs.paintImmediately(tabs.getBounds()); boolean uploadsOkay = true; for (Attachment a: composition.attachments) { if (a.id != null) continue; // Assume it had already been uploaded. api.uploadFile( a.uploadee, a.description, new RequestListener() { public void connectionFailed(IOException eIo) { JOptionPane.showMessageDialog( ComposeWindow.this, "Tried to upload attachment, failed..." + "\n" + eIo.getMessage() ); } public void requestFailed(int httpCode, Tree json) { JOptionPane.showMessageDialog( ComposeWindow.this, "Tried to upload attachment, failed..." + "\n" + json.get("error").value + "\n(HTTP code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { a.id = json.get("id").value; } }); uploadsOkay &= a.id != null; } if (!uploadsOkay) { contentsDisplay.setSubmitting(false); tabs.setEnabled(true); //tabs.setCursor(null); return; } int amt = composition.attachments.length; String[] mediaIDs = new String[amt]; for (int o = 0; o < mediaIDs.length; ++o) mediaIDs[o] = composition.attachments[o].id; api.submit( composition.text, composition.visibility, composition.replyToPostId, composition.contentWarning, mediaIDs, 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 json) { JOptionPane.showMessageDialog( ComposeWindow.this, "Tried to submit post, failed..." + "\n" + json.get("error").value + "\n(HTTP error code: " + httpCode + ")" ); } public void requestSucceeded(Tree json) { newComposition(); } } ); contentsDisplay.setSubmitting(false); tabs.setEnabled(true); tabs.setCursor(null); } // - -%- - 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 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); } else { text.setEnabled(true); visibility.setEnabled(true); submit.setEnabled(true); } } // - -%- - 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 ComponentListener, ActionListener { private ComposeWindow primaire; // - -%- - private List working; private JPanel selections; private JToggleButton attachment1, attachment2, attachment3, attachment4, selected; private JButton add; private JButton delete, revert; private JLabel descriptionLabel; private JTextArea description; private JFileChooser chooser; private UndoManager descriptionUndos; // ---%-@-%--- 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(); selected = null; if (working.size() > 0) { selections.add(attachment1); Image i = working.get(0).image; attachment1.setIcon(new ImageIcon(i)); } if (working.size() > 1) { selections.add(attachment2); Image i = working.get(1).image; attachment2.setIcon(new ImageIcon(i)); } if (working.size() > 2) { selections.add(attachment3); Image i = working.get(2).image; attachment3.setIcon(new ImageIcon(i)); } if (working.size() > 3) { selections.add(attachment4); Image i = working.get(3).image; attachment4.setIcon(new ImageIcon(i)); } if (working.size() < 4) selections.add(add); if (working.size() > 3) open(attachment4); else if (working.size() > 2) open(attachment3); else if (working.size() > 1) open(attachment2); else if (working.size() > 0) open(attachment1); 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(); delete.setEnabled(selected != null); revert.setEnabled(selected != null); description.setEnabled(selected != null); } public void actionPerformed(ActionEvent eA) { Object src = eA.getSource(); 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; a.description = ""; a.type = "unknown"; 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); a.type = "image"; } if (selected != null) open(selected); // Save first before resetting working.add(a); updateButtons(); } if (src == delete) { assert selected != null; working.remove(getAttachmentFor(selected)); updateButtons(); return; } if (src != add && selections.isAncestorOf((Component)src)) { assert src instanceof JToggleButton; if (src == selected) preview(getAttachmentFor(src)); else open((JToggleButton)src); return; } if (src == revert) { while (descriptionUndos.canUndo()) descriptionUndos.undo(); return; } } private Attachment getAttachmentFor(Object button) { if (button == null) return null; assert button instanceof JToggleButton; assert selections.isAncestorOf((Component)button); int index = 0; if (button == attachment4) index = 4; if (button == attachment3) index = 3; if (button == attachment2) index = 2; if (button == attachment1) index = 1; assert index != 0; assert index <= working.size(); return working.get(index - 1); } private void open(JToggleButton button) { assert selections.isAncestorOf(button); if (selected != null) { Attachment a = getAttachmentFor(selected); a.description = description.getText(); selected.setSelected(false); } Attachment a = getAttachmentFor(button); description.setText(a.description); descriptionUndos.discardAllEdits(); (selected = button).setSelected(true); } public void componentHidden(ComponentEvent eC) { if (selected != null) open(selected); } private void preview(Attachment a) { ImageWindow w = new ImageWindow(); w.showAttachments(new Attachment[] { a } ); w.setTitle("Attachment preview"); w.setVisible(true); } public void componentShown(ComponentEvent eC) { } public void componentMoved(ComponentEvent eC) { } public void componentResized(ComponentEvent eC) { } // ---%-@-%--- 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()); attachment1.addActionListener(this); attachment2.addActionListener(this); attachment3.addActionListener(this); attachment4.addActionListener(this); selections = new JPanel(); selections.setOpaque(false); selections.setLayout(new GridLayout(1, 0, 4, 0)); working = new ArrayList(); Box top = Box.createHorizontalBox(); top.add(selections); top.add(Box.createGlue()); delete = new JButton("Delete"); revert = new JButton("Revert"); JButton ml = new JButton("←"); JButton mr = new JButton("→"); delete.addActionListener(this); revert.addActionListener(this); 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); descriptionUndos = new UndoManager(); description.getDocument(). addUndoableEditListener(descriptionUndos); updateButtons(); JPanel row1 = new JPanel(); row1.setOpaque(false); row1.setLayout(new BorderLayout()); row1.add(descriptionLabel, BorderLayout.NORTH); row1.add(description, BorderLayout.CENTER); 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); this.addComponentListener(this); } } 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); } }