diff --git a/ComposeWindow.java b/ComposeWindow.java old mode 100755 new mode 100644 index 723a84d..a47036d --- a/ComposeWindow.java +++ b/ComposeWindow.java @@ -9,6 +9,7 @@ import javax.swing.JButton; import javax.swing.Box; import javax.swing.BorderFactory; import javax.swing.JOptionPane; +import java.awt.GridLayout; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.event.ActionListener; @@ -52,6 +53,7 @@ ComposeWindow extends JFrame { composition.text = ""; composition.visibility = PostVisibility.MENTIONED; composition.replyToPostId = null; + composition.contentWarning = null; syncDisplayToComposition(); } @@ -59,10 +61,16 @@ ComposeWindow extends JFrame { submit() { syncCompositionToDisplay(); + + if (composition.replyToPostId != null) + assert !composition.replyToPostId.trim().isEmpty(); + if (composition.contentWarning != null) + assert !composition.contentWarning.trim().isEmpty(); + display.setSubmitting(true); api.submit( composition.text, composition.visibility, - composition.replyToPostId, + composition.replyToPostId, composition.contentWarning, new RequestListener() { public void @@ -105,6 +113,7 @@ ComposeWindow extends JFrame { display.setText(composition.text); display.setReplyToPostId(composition.replyToPostId); display.setVisibility(stringFor(composition.visibility)); + display.setContentWarning(composition.contentWarning); } private void @@ -113,7 +122,19 @@ ComposeWindow extends JFrame { composition.text = display.getText(); composition.visibility = visibilityFrom(display.getVisibility()); - composition.replyToPostId = display.getReplyToPostId(); + composition.replyToPostId = + nonEmpty(display.getReplyToPostId()); + composition.contentWarning = + nonEmpty(display.getContentWarning()); + } + +// - -%- - + + private static String + nonEmpty(String s) + { + if (s.trim().isEmpty()) return null; + return s; } // ---%-@-%--- @@ -182,7 +203,7 @@ implements ActionListener { text; private JTextField - reply; + reply, contentWarning; private JComboBox visibility; @@ -217,6 +238,12 @@ implements ActionListener { this.visibility.setSelectedIndex(3); } + public void + setContentWarning(String contentWarning) + { + this.contentWarning.setText(contentWarning); + } + public String getText() { @@ -229,6 +256,12 @@ implements ActionListener { return reply.getText(); } + public String + getContentWarning() + { + return contentWarning.getText(); + } + public String getVisibility() { @@ -265,11 +298,21 @@ implements ActionListener { { this.primaire = primaire; - text = new JTextArea(); - text.setLineWrap(true); - text.setWrapStyleWord(true); - reply = new JTextField(); + JLabel replyLabel = new JLabel("In reply to: "); + replyLabel.setLabelFor(reply); + + contentWarning = new JTextField(); + JLabel cwLabel = new JLabel("Content warning: "); + cwLabel.setLabelFor(contentWarning); + + 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); visibility = new JComboBox<>(new String[] { "Public", @@ -289,11 +332,9 @@ implements ActionListener { bottom.add(Box.createHorizontalStrut(8)); bottom.add(submit); - JPanel top = new JPanel(); - top.setOpaque(false); - top.setLayout(new BorderLayout(8, 0)); - top.add(new JLabel("In reply to: "), BorderLayout.WEST); - top.add(reply); + text = new JTextArea(); + text.setLineWrap(true); + text.setWrapStyleWord(true); setLayout(new BorderLayout(0, 8)); add(top, BorderLayout.NORTH); diff --git a/ImageApi.java b/ImageApi.java new file mode 100644 index 0000000..2f24edb --- /dev/null +++ b/ImageApi.java @@ -0,0 +1,33 @@ + +import javax.swing.ImageIcon; +import java.awt.Image; +import java.awt.Toolkit; +import java.net.URL; +import java.net.MalformedURLException; + +interface +ImageApi { + + public static Image + local(String name) + { + String path = "/graphics/" + name + ".png"; + URL url = ImageApi.class.getResource(name); + if (url == null) return null; + return new ImageIcon(url).getImage(); + } + + public static Image + remote(String urlr) + { + try { + URL url = new URL(urlr); + Toolkit TK = Toolkit.getDefaultToolkit(); + return TK.createImage(url); + } + catch (MalformedURLException eMu) { + return null; + } + } + +} diff --git a/ImageWindow.java b/ImageWindow.java index 789fda2..90def6b 100644 --- a/ImageWindow.java +++ b/ImageWindow.java @@ -90,7 +90,7 @@ ImageWindow extends JFrame { display.getToolTipText() + "\n(Media is of type '" + curr.type + "')" ); - + repaint(); } @@ -99,7 +99,7 @@ ImageWindow extends JFrame { ImageWindow() { setDefaultCloseOperation(DISPOSE_ON_CLOSE); - setSize(400, 400); + setSize(600, 600); display = new ImageComponent(this); showAttachments(new Attachment[0]); @@ -156,6 +156,7 @@ implements setPrev(Image image) { prev.setEnabled(image != null); + prev.setText(image == null ? "<" : ""); prev.setIcon(toIcon(image)); } @@ -163,6 +164,7 @@ implements setNext(Image image) { next.setEnabled(image != null); + next.setText(image == null ? ">" : ""); next.setIcon(toIcon(image)); } @@ -183,14 +185,14 @@ implements } - + public void mousePressed(MouseEvent eM) { dragX = eM.getX(); dragY = eM.getY(); } - + public void mouseDragged(MouseEvent eM) { @@ -221,10 +223,10 @@ implements public void mouseEntered(MouseEvent eM) { } - + public void mouseExited(MouseEvent eM) { } - + public void mouseClicked(MouseEvent eM) { } @@ -250,7 +252,8 @@ implements { if (image == null) { - String str = "(There are no images being displayed.)"; + String str = + "(There are no images being displayed.)"; FontMetrics fm = g.getFontMetrics(); int x = (getWidth() - fm.stringWidth(str)) / 2; int y = (getHeight() + fm.getHeight()) / 2; @@ -285,15 +288,15 @@ implements { this.primaire = primaire; - Dimension BUTTON_SIZE = new Dimension(80, 60); + Dimension BUTTON_SIZE = new Dimension(48, 48); setOpaque(false); scaleImage = true; zoomLevel = 100; - prev = new JButton("<"); + prev = new JButton(); toggle = new JButton("Show unscaled"); - next = new JButton(">"); + next = new JButton(); prev.setPreferredSize(BUTTON_SIZE); next.setPreferredSize(BUTTON_SIZE); prev.addActionListener(this); @@ -306,6 +309,9 @@ implements buttonArea.add(toggle); buttonArea.add(next); + setPrev(null); + setNext(null); + setLayout(new BorderLayout()); add(buttonArea, BorderLayout.SOUTH); add(new Painter(), BorderLayout.CENTER); diff --git a/JKomasto.java b/JKomasto.java old mode 100755 new mode 100644 index 1d1dc09..6aeb06f --- a/JKomasto.java +++ b/JKomasto.java @@ -28,6 +28,9 @@ JKomasto { private ImageWindow mediaWindow; + private TimelineWindowUpdater + timelineWindowUpdater; + private MastodonApi api; @@ -52,6 +55,8 @@ JKomasto { loginWindow.dispose(); autoViewWindow.setCursor(null); timelineWindow.setCursor(null); + + timelineWindowUpdater.addWindow(timelineWindow); } public PostWindow @@ -87,6 +92,8 @@ JKomasto { mediaWindow.dispose(); loginWindow.setLocationByPlatform(true); loginWindow.setVisible(true); + + timelineWindowUpdater = new TimelineWindowUpdater(this); } } @@ -123,6 +130,9 @@ TimelinePage { public TimelineType type; + public String + accountNumId; + public List posts; @@ -143,6 +153,12 @@ Post { public Image authorAvatar; + public String + authorNumId; + + public String + boosterName; + public ZonedDateTime date; @@ -170,6 +186,9 @@ Attachment { public String url; + public String + description; + public Image image; @@ -181,7 +200,8 @@ class Composition { public String - text; + text, + contentWarning; public PostVisibility visibility; diff --git a/LoginWindow.java b/LoginWindow.java old mode 100755 new mode 100644 index 52a7820..cedbd05 --- a/LoginWindow.java +++ b/LoginWindow.java @@ -117,6 +117,7 @@ LoginWindow extends JFrame { requestSucceeded(Tree json) { api.setAccountDetails(json); + serverContacted = true; haveAccountDetails = true; updateStatusDisplay(); } @@ -139,14 +140,21 @@ LoginWindow extends JFrame { ); } display.setCursor(null); - + primaire.finishedLogin(); } public void useInstanceUrl() { + if (display.isAutoLoginToggled()) { useCache(); return; } + String url = display.getInstanceUrl(); + if (url.trim().isEmpty()) { + // Should we show an error dialog..? + display.setInstanceUrl(""); + return; + } if (!hasProtocol(url)) { url = "https://" + url; display.setInstanceUrl(url); @@ -157,10 +165,8 @@ LoginWindow extends JFrame { haveAccessToken = false; haveAccountDetails = false; - if (display.isAutoLoginToggled()) { useCache(); return; } - display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); - api.testUrlConnection(url, new RequestListener() { + api.testUrlConnection(url, new RequestListener() { public void connectionFailed(IOException eIo) @@ -192,7 +198,7 @@ LoginWindow extends JFrame { }); display.setCursor(null); if (!serverContacted) return; - + api.setInstanceUrl(url); display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getAppCredentials(new RequestListener() { @@ -259,12 +265,12 @@ LoginWindow extends JFrame { "\nWe cannot use Desktop.browse(URI) on your\n" + "computer.. You'll have to open your web\n" + "browser yourself, and copy this URL in."; - + JTextField field = new JTextField(); field.setText(uri.toString()); field.setPreferredSize(new Dimension(120, 32)); field.selectAll(); - + JOptionPane.showMessageDialog( LoginWindow.this, new Object[] { MESSAGE1, MESSAGE2, field }, @@ -283,7 +289,7 @@ LoginWindow extends JFrame { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getAccessToken(code, new RequestListener() { - + public void connectionFailed(IOException eIo) { diff --git a/MastodonApi.java b/MastodonApi.java index a1f2a5f..c83004f 100644 --- a/MastodonApi.java +++ b/MastodonApi.java @@ -11,7 +11,9 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.FileReader; import java.io.FileWriter; +import java.io.BufferedReader; import java.io.IOException; +import java.io.UnsupportedEncodingException; class MastodonApi { @@ -77,7 +79,8 @@ MastodonApi { getAppCredentials(RequestListener handler) { assert instanceUrl != null; - try { + try + { URL endpoint = new URL(instanceUrl + "/api/v1/apps"); HttpURLConnection conn; conn = (HttpURLConnection)endpoint.openConnection(); @@ -164,7 +167,8 @@ MastodonApi { URL endpoint = new URL(instanceUrl + s); HttpURLConnection conn; conn = (HttpURLConnection)endpoint.openConnection(); - conn.setRequestProperty("Authorization", "Bearer " + token); + String s2 = "Bearer " + token; + conn.setRequestProperty("Authorization", s2); conn.connect(); doStandardJsonReturn(conn, handler); @@ -174,13 +178,18 @@ MastodonApi { public void getTimelinePage( - TimelineType type, int count, String maxId, String minId, + TimelineType type, String accountId, + int count, String maxId, String minId, RequestListener handler) { String token = accessToken.get("access_token").value; String url = instanceUrl + "/api/v1"; - switch (type) + if (accountId != null) + { + url += "/accounts/" + accountId + "/statuses"; + } + else switch (type) { case FEDERATED: case LOCAL: url += "/timelines/public"; break; @@ -266,13 +275,15 @@ MastodonApi { public void submit( - String text, PostVisibility visibility, String replyTo, + String text, PostVisibility visibility, + String replyTo, String contentWarning, RequestListener handler) { String token = accessToken.get("access_token").value; String visibilityParam = "direct"; - switch (visibility) { + switch (visibility) + { case PUBLIC: visibilityParam = "public"; break; case UNLISTED: visibilityParam = "unlisted"; break; case FOLLOWERS: visibilityParam = "private"; break; @@ -283,7 +294,8 @@ MastodonApi { String url = instanceUrl + "/api/v1/statuses"; try { - text = URLEncoder.encode(text, "UTF-8"); + text = encode(text); + contentWarning = encode(contentWarning); URL endpoint = new URL(url); HttpURLConnection conn; @@ -303,6 +315,9 @@ MastodonApi { if (replyTo != null) { output.write("&in_reply_to_id=" + replyTo); } + if (contentWarning != null) { + output.write("&spoiler_text=" + contentWarning); + } output.close(); doStandardJsonReturn(conn, handler); @@ -310,10 +325,57 @@ MastodonApi { catch (IOException eIo) { handler.connectionFailed(eIo); } } + public void + monitorTimeline( + TimelineType type, ServerSideEventsListener handler) + { + String token = accessToken.get("access_token").value; + + String url = instanceUrl + "/api/v1/streaming"; + switch (type) + { + case FEDERATED: url += "/public"; break; + case LOCAL: url += "/public/local"; break; + case HOME: + case NOTIFICATIONS: url += "/user"; break; + default: assert false; + } + + try + { + URL endpoint = new URL(url); + HttpURLConnection conn; + conn = (HttpURLConnection)endpoint.openConnection(); + String s = "Bearer " + token; + conn.setRequestProperty("Authorization", s); + conn.connect(); + + InputStreamReader input; + int code = conn.getResponseCode(); + if (code >= 300) + { + input = new InputStreamReader(conn.getErrorStream()); + Tree response = JsonConverter.convert(input); + input.close(); + handler.requestFailed(code, response); + return; + } + + input = new InputStreamReader(conn.getInputStream()); + BufferedReader br = new BufferedReader(input); + while (true) { + String line = br.readLine(); + if (line != null) handler.lineReceived(line); + } + } + catch (IOException eIo) { handler.connectionFailed(eIo); } + } + // - -%- - private void - doStandardJsonReturn(HttpURLConnection conn, RequestListener handler) + doStandardJsonReturn( + HttpURLConnection conn, RequestListener handler) throws IOException { InputStreamReader input; @@ -334,7 +396,8 @@ MastodonApi { } private void - returnResponseInTree(HttpURLConnection conn, RequestListener handler) + returnResponseInTree( + HttpURLConnection conn, RequestListener handler) throws IOException { InputStreamReader input; @@ -371,6 +434,19 @@ MastodonApi { return doc; } + private static String + encode(String s) + { + try { + if (s == null) return null; + return URLEncoder.encode(s, "UTF-8"); + } + catch (UnsupportedEncodingException eUe) { + assert false; + return null; + } + } + // ---%-@-%--- public void diff --git a/PostWindow.java b/PostWindow.java old mode 100755 new mode 100644 index 78feee7..404c76a --- a/PostWindow.java +++ b/PostWindow.java @@ -21,6 +21,7 @@ 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; @@ -98,11 +99,12 @@ implements ActionListener { postDisplay.setText(post.text); postDisplay.setFavourited(post.favourited); postDisplay.setBoosted(post.boosted); - postDisplay.setMediaPreview( + postDisplay.setMediaPreview( post.attachments.length == 0 ? null : post.attachments[0].image ); + repliesDisplay.setReplies(replies); postDisplay.resetFocus(); repaint(); @@ -111,7 +113,11 @@ implements ActionListener { public void openAuthorProfile() { - + TimelineWindow w = new TimelineWindow(primaire); + w.showAuthorPosts(post.authorNumId); + w.showLatestPage(); + w.setLocationRelativeTo(this); + w.setVisible(true); } public void @@ -151,8 +157,9 @@ implements ActionListener { }; api.setPostFavourited(post.postId, favourited, handler); - postDisplay.setFavouriteBoostEnabled(true); postDisplay.setCursor(null); + postDisplay.setFavouriteBoostEnabled(true); + postDisplay.repaint(); } public void @@ -192,8 +199,9 @@ implements ActionListener { }; api.setPostBoosted(post.postId, boosted, handler); - postDisplay.setFavouriteBoostEnabled(true); postDisplay.setCursor(null); + postDisplay.setFavouriteBoostEnabled(true); + postDisplay.repaint(); } public void @@ -217,7 +225,7 @@ implements ActionListener { int l = Math.min(40, post.text.length()); w.setTitle(post.text.substring(0, l)); if (!w.isVisible()) { - w.setLocation(getX(), getY() + 100); + w.setLocationRelativeTo(null); w.setVisible(true); } } @@ -227,7 +235,7 @@ implements ActionListener { public void actionPerformed(ActionEvent eA) { - Object src = eA.getSource(); + Component src = (Component)eA.getSource(); if (!(src instanceof JMenuItem)) return; String text = ((JMenuItem)src).getText(); @@ -358,7 +366,7 @@ implements ActionListener { public void actionPerformed(ActionEvent eA) { - Object src = eA.getSource(); + Component src = (Component)eA.getSource(); String command = eA.getActionCommand(); if (src == profile) @@ -412,8 +420,8 @@ implements ActionListener { g.clearRect(0, 0, getWidth(), getHeight()); ((java.awt.Graphics2D)g).setRenderingHint( - java.awt.RenderingHints.KEY_TEXT_ANTIALIASING, - java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON + java.awt.RenderingHints.KEY_ANTIALIASING, + java.awt.RenderingHints.VALUE_ANTIALIAS_ON ); Font f1 = new Font("IPAGothic", Font.PLAIN, 16); @@ -454,13 +462,6 @@ implements ActionListener { // - -%- - - private static ImageIcon - toIcon(Image image) - { - if (image == null) return null; - return new ImageIcon(image); - } - private static List split(String string, int lineLength) { @@ -472,7 +473,7 @@ implements ActionListener { if (word.length() >= lineLength) { word = word.substring(0, lineLength - 4) + "..."; } - if (word.equals("\n")) { + if (word.matches("\n")) { returnee.add(empty(line)); continue; } @@ -504,9 +505,9 @@ implements ActionListener { authorName = authorId = time = text = ""; Dimension buttonSize = new Dimension(20, 40); + Border b = BorderFactory.createEmptyBorder(10, 10, 10, 10); profile = new RoundButton(); - //profile.setPreferredSize(buttonSize); profile.addActionListener(this); favouriteBoost = new TwoToggleButton("favourite", "boost"); @@ -537,7 +538,7 @@ implements ActionListener { Box buttons = Box.createVerticalBox(); buttons.setOpaque(false); buttons.add(ibuttons); - buttons.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + buttons.setBorder(b); setLayout(new BorderLayout()); add(buttons, BorderLayout.WEST); diff --git a/RequestListener.java b/RequestListener.java index 9837a9f..fd80d06 100644 --- a/RequestListener.java +++ b/RequestListener.java @@ -15,3 +15,17 @@ RequestListener { requestSucceeded(Tree json); } + +interface +ServerSideEventsListener { + + void + connectionFailed(IOException eIo); + + void + requestFailed(int httpCode, Tree json); + + void + lineReceived(String line); + +} diff --git a/TimelineWindow.java b/TimelineWindow.java old mode 100755 new mode 100644 index 869c9dd..ddbfa70 --- a/TimelineWindow.java +++ b/TimelineWindow.java @@ -9,7 +9,6 @@ import javax.swing.JMenuItem; import javax.swing.JMenuBar; import javax.swing.JSeparator; import javax.swing.Box; -import javax.swing.ImageIcon; import javax.swing.BorderFactory; import java.awt.BorderLayout; import java.awt.GridBagLayout; @@ -30,6 +29,10 @@ 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; @@ -59,8 +62,6 @@ implements ActionListener { private JMenuItem openHome, - // umm, what about the timeline that's like, notes that your - // post was favourited or replied to? those aren't messages.. openMessages, openLocal, openFederated, @@ -82,21 +83,29 @@ implements ActionListener { setTimelineType(TimelineType type) { page.type = type; - + page.accountNumId = null; + String s1 = type.toString(); s1 = s1.charAt(0) + s1.substring(1).toLowerCase(); setTitle(s1 + " - JKomasto"); - String s2 = type.toString().toLowerCase(); - s2 = "/graphics/" + s2 + ".png"; - URL url = getClass().getResource(s2); - if (url != null) { - ImageIcon icon = new ImageIcon(url); - display.setBackgroundImage(icon.getImage()); - } - else { - display.setBackgroundImage(null); - } + String s2 = type.toString().toLowerCase(); + display.setBackgroundImage(ImageApi.local(s2)); + } + + public TimelineType + getTimelineType() { return page.type; } + + public void + showAuthorPosts(String authorNumId) + { + assert authorNumId != null; + + page.type = TimelineType.FEDERATED; + page.accountNumId = authorNumId; + + setTitle(authorNumId + " - JKomasto"); + display.setBackgroundImage(ImageApi.local("profile")); } public void @@ -104,12 +113,14 @@ implements ActionListener { { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( - page.type, PREVIEW_COUNT, null, null, + page.type, page.accountNumId, + PREVIEW_COUNT, null, null, new RequestListener() { public void connectionFailed(IOException eIo) { + eIo.printStackTrace(); String s = eIo.getClass().getName(); setTitle(s + " - JKomasto"); } @@ -117,6 +128,7 @@ implements ActionListener { public void requestFailed(int httpCode, Tree json) { + System.err.println(json.get("error").value); setTitle(httpCode + " - JKomasto"); // lol... } @@ -124,9 +136,13 @@ implements ActionListener { public void requestSucceeded(Tree json) { - page.posts = toPosts(json); + List posts = toPosts(json); + page.posts = posts; display.setPosts(page.posts); - display.setNextPageAvailable(true); + boolean full = posts.size() >= PREVIEW_COUNT; + display.setNextPageAvailable(full); + display.setPreviousPageAvailable(true); + display.resetFocus(); } } @@ -143,7 +159,8 @@ implements ActionListener { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( - page.type, PREVIEW_COUNT, last.postId, null, + page.type, page.accountNumId, + PREVIEW_COUNT, last.postId, null, new RequestListener() { public void @@ -162,9 +179,19 @@ implements ActionListener { public void requestSucceeded(Tree json) { - page.posts = toPosts(json); + List posts = toPosts(json); + if (posts.size() == 0) { + // We should probably say something + // to the user here? For now, we + // quietly cancel. + return; + } + page.posts = posts; display.setPosts(page.posts); + boolean full = posts.size() >= PREVIEW_COUNT; + display.setNextPageAvailable(full); display.setPreviousPageAvailable(true); + display.resetFocus(); } } @@ -181,7 +208,8 @@ implements ActionListener { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( - page.type, PREVIEW_COUNT, null, first.postId, + page.type, page.accountNumId, + PREVIEW_COUNT, null, first.postId, new RequestListener() { public void @@ -200,17 +228,21 @@ implements ActionListener { public void requestSucceeded(Tree json) { - page.posts = toPosts(json); + List posts = toPosts(json); + if (posts.size() < PREVIEW_COUNT) { + showLatestPage(); + return; + } + page.posts = posts; display.setPosts(page.posts); display.setNextPageAvailable(true); display.setPreviousPageAvailable(true); + display.resetFocus(); } } ); display.setCursor(null); - - if (page.posts.size() < PREVIEW_COUNT) showLatestPage(); } // - -%- - @@ -221,6 +253,15 @@ implements ActionListener { primaire.getAutoViewWindow().showPost(post); } + public void + postOpened(Post post) + { + PostWindow w = new PostWindow(primaire); + w.showPost(post); + w.setLocationRelativeTo(this); + w.setVisible(true); + } + public void actionPerformed(ActionEvent eA) { @@ -273,11 +314,18 @@ implements ActionListener { private static List toPosts(Tree json) { - List posts = new ArrayList<>(); + List posts = new ArrayList<>(); for (Tree post: json.children) { Post addee = new Post(); + if (post.get("reblog").size() != 0) { + Tree a = post.get("account"); + String s = a.get("display_name").value; + addee.boosterName = s; + post = post.get("reblog"); + } + addee.postId = post.get("id").value; try { @@ -304,7 +352,7 @@ implements ActionListener { b.append(" \n "); } if (node.get(0).key.equals("/p")) { - b.append(" \n\n "); + b.append(" \n \n "); } } if (node.key.equals("text")) { @@ -323,51 +371,40 @@ implements ActionListener { else addee.contentWarning = null; Tree account = post.get("account"); - if (post.get("reblog").size() != 0) { - account = post.get("reblog").get("account"); - } - addee.authorId = account.get("acct").value; + addee.authorId = account.get("acct").value; addee.authorName = account.get("username").value; + addee.authorNumId = account.get("id").value; String s2 = account.get("display_name").value; if (!s2.isEmpty()) addee.authorName = s2; - try { - String av = account.get("avatar").value; - ImageIcon icon = new ImageIcon(new URL(av)); - addee.authorAvatar = icon.getImage(); - } - catch (MalformedURLException eMu) { - // Weird bug on their part.. We should - // probably react by using a default avatar. - } + String s3 = account.get("avatar").value; + addee.authorAvatar = ImageApi.remote(s3); + if (addee.authorAvatar == null) { + s3 = "defaultAvatar"; + addee.authorAvatar = ImageApi.local(s3); + } String f = post.get("favourited").value; String b = post.get("reblogged").value; addee.favourited = f.equals("true"); addee.boosted = b.equals("true"); - Tree a1 = post.get("media_attachments"); - Attachment[] a2 = new Attachment[a1.size()]; - for (int o = 0; o < a2.length; ++o) + Tree as1 = post.get("media_attachments"); + Attachment[] as2 = new Attachment[as1.size()]; + for (int o = 0; o < as2.length; ++o) { - a2[o] = new Attachment(); - a2[o].type = a1.get(o).get("type").value; - - a2[o].url = a1.get(o).get("remote_url").value; - if (a2[o].url == null) - a2[o].url = a1.get(o).get("text_url").value; - - a2[o].image = null; - if (a2[o].type.equals("image")) try - { - URL url = new URL(a2[o].url); - ImageIcon icon = new ImageIcon(url); - String desc = a1.get(o).get("description").value; - icon.setDescription(desc); - a2[o].image = icon.getImage(); - } - catch (MalformedURLException eMu) { } + Tree a1 = as1.get(o); + Attachment a2 = as2[o] = new Attachment(); + + a2.type = a1.get("type").value; + String u1 = a1.get("remote_url").value; + String u2 = a1.get("text_url").value; + a2.url = u1 == null ? u2 : u1; + a2.description = a1.get("description").value; + a2.image = null; + if (a2.type.equals("image")) + a2.image = ImageApi.remote(a2.url); } - addee.attachments = a2; + addee.attachments = as2; posts.add(addee); } @@ -418,6 +455,7 @@ implements ActionListener { flipToNewestPost.addActionListener(this); JMenu programMenu = new JMenu("Program"); + programMenu.setMnemonic(KeyEvent.VK_P); programMenu.add(openHome); programMenu.add(openFederated); programMenu.add(new JSeparator()); @@ -426,6 +464,7 @@ implements ActionListener { 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); @@ -449,7 +488,9 @@ implements ActionListener { class TimelineComponent extends JPanel -implements ActionListener, MouseListener { +implements + ActionListener, KeyListener, + MouseListener, FocusListener { private TimelineWindow primaire; @@ -494,14 +535,23 @@ implements ActionListener, MouseListener { Post p = posts.get(o); c.setTopLeft(p.authorName); { + String f = ""; + if (p.boosterName != null) + f += "b"; + if (p.attachments.length > 0) + f += "a"; + + String t; ZonedDateTime now = ZonedDateTime.now(); long d = ChronoUnit.SECONDS.between(p.date, now); long s = Math.abs(d); - if (s < 30) c.setTopRight("now"); - else if (s < 60) c.setTopRight(d + "s"); - else if (s < 3600) c.setTopRight((d / 60) + "m"); - else if (s < 86400) c.setTopRight((d / 3600) + "h"); - else c.setTopRight((d / 86400) + "d"); + if (s < 30) t = "now"; + else if (s < 60) t = d + "s"; + else if (s < 3600) t = (d / 60) + "m"; + else if (s < 86400) t = (d / 3600) + "h"; + else t = (d / 86400) + "d"; + + c.setTopRight(f + " " + t); } if (p.contentWarning != null) c.setBottom("(" + p.contentWarning + ")"); @@ -533,11 +583,19 @@ implements ActionListener, MouseListener { public void setBackgroundImage(Image n) { backgroundImage = n; } + public void + resetFocus() { postPreviews.get(0).requestFocusInWindow(); } + // - -%- - protected void paintComponent(Graphics g) { + ((java.awt.Graphics2D)g).setRenderingHint( + java.awt.RenderingHints.KEY_ANTIALIASING, + java.awt.RenderingHints.VALUE_ANTIALIAS_ON + ); + int w = getWidth(), h = getHeight(); g.clearRect(0, 0, w, h); int h2 = h * 5 / 10, w2 = h2; @@ -545,40 +603,88 @@ implements ActionListener, MouseListener { g.drawImage(backgroundImage, x, y, w2, h2, this); } + private void + select(Object c) + { + assert c instanceof PostPreviewComponent; + PostPreviewComponent p = (PostPreviewComponent)c; + + int offset = postPreviews.indexOf(p); + assert offset != -1; + if (offset < posts.size()) { + primaire.postSelected(posts.get(offset)); + p.setSelected(true); + } + 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) + { + assert c instanceof PostPreviewComponent; + PostPreviewComponent p = (PostPreviewComponent)c; + + int offset = postPreviews.indexOf(p); + assert offset != -1; + if (offset < posts.size()) { + primaire.postOpened(posts.get(offset)); + } + } + 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); } - // (知) First time I'm using one of these..! - - public void - mouseClicked(MouseEvent eM) - { - int offset = postPreviews.indexOf(eM.getSource()); - assert offset != -1; - primaire.postSelected(posts.get(offset)); - postPreviews.get(offset).setSelected(true); - repaint(); - } - public void mouseExited(MouseEvent eM) { if (!hoverSelect) return; - int offset = postPreviews.indexOf(eM.getSource()); - assert offset != -1; - postPreviews.get(offset).setSelected(false); - repaint(); + deselect(eM.getSource()); } - public void - mousePressed(MouseEvent eM) { } + // (知) First time I'm using these two..! public void - mouseReleased(MouseEvent eM) { } + keyPressed(KeyEvent eK) + { + if (eK.getKeyCode() != KeyEvent.VK_ENTER) return; + + PostPreviewComponent selected = null; + for (PostPreviewComponent c: postPreviews) + if (c.getSelected()) selected = c; + + if (selected == null) return; + open(selected); + } public void actionPerformed(ActionEvent eA) @@ -595,6 +701,19 @@ implements ActionListener, MouseListener { */ } + + public void + mousePressed(MouseEvent eM) { } + + public void + mouseReleased(MouseEvent eM) { } + + public void + keyTyped(KeyEvent eK) { } + + public void + keyReleased(KeyEvent eK) { } + // ---%-@-%--- TimelineComponent(TimelineWindow primaire) @@ -608,6 +727,8 @@ implements ActionListener, MouseListener { 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); @@ -632,6 +753,8 @@ implements ActionListener, MouseListener { PostPreviewComponent c = new PostPreviewComponent(); c.reset(); c.addMouseListener(this); + c.addFocusListener(this); + c.addKeyListener(this); centre.add(c, constraints); postPreviews.add(c); } @@ -683,6 +806,9 @@ PostPreviewComponent extends JComponent { else setBackground(new Color(0, 0, 0, 25)); } + public boolean + getSelected() { return selected; } + // - -%- - protected void @@ -699,8 +825,6 @@ PostPreviewComponent extends JComponent { public PostPreviewComponent() { - selected = false; - Font f = new JLabel().getFont(); Font f1 = f.deriveFont(Font.PLAIN, 12f); Font f2 = f.deriveFont(Font.ITALIC, 12f); @@ -725,8 +849,8 @@ PostPreviewComponent extends JComponent { bottom.setFont(f3); bottom.setOpaque(false); + setFocusable(true); setOpaque(false); - setSelected(false); setLayout(new BorderLayout()); add(top, BorderLayout.NORTH); add(bottom); diff --git a/TimelineWindowUpdater.java b/TimelineWindowUpdater.java new file mode 100644 index 0000000..fa9a2aa --- /dev/null +++ b/TimelineWindowUpdater.java @@ -0,0 +1,177 @@ + +import java.util.List; +import java.util.ArrayList; +import java.io.IOException; +import cafe.biskuteri.hinoki.Tree; + +class +TimelineWindowUpdater { + + private JKomasto + primaire; + + private MastodonApi + api; + +// - -%- - + + private List + updatees; + + private StringBuilder + event, data; + +// - -%- - + + private Thread + federated, + local, + home, + notifications; + +// ---%-@-%--- + + public void + addWindow(TimelineWindow updatee) + { + updatees.add(updatee); + + Connection c = new Connection(); + c.type = updatee.getTimelineType(); + Thread t = new Thread(c); + switch (c.type) { + case FEDERATED: + if (federated != null) return; + federated = t; break; + case LOCAL: + if (local != null) return; + local = t; break; + case HOME: + if (home != null) return; + home = t; break; + case NOTIFICATIONS: + if (notifications != null) return; + notifications = t; break; + default: return; + } + t.start(); + + System.err.println(t); + } + + public void + removeWindow(TimelineWindow updatee) + { + updatees.remove(updatee); + } + +// - -%- - + + private void + handle(TimelineType type, String event, String data) + { + System.err.println("Handling " + event + "."); + + assert !data.isEmpty(); + if (event.isEmpty()) return; + + boolean newPost = event.equals("update"); + boolean newNotif = event.equals("notification"); + if (!(newPost || newNotif)) return; + + for (TimelineWindow updatee: filter(type)) { + System.err.println("Refreshing " + updatee); + updatee.showLatestPage(); + } + } + + private List + filter(TimelineType type) + { + List returnee = new ArrayList<>(); + for (TimelineWindow updatee: updatees) + if (updatee.getTimelineType() == type) + returnee.add(updatee); + return returnee; + } + +// ---%-@-%--- + + private class + Connection + implements Runnable, ServerSideEventsListener { + + private TimelineType + type; + +// -=- + + private StringBuilder + event, data; + +// -=%=- + + public void + run() + { + event = new StringBuilder(); + data = new StringBuilder(); + api.monitorTimeline(type, this); + // monitorTimeline should not return + // until the connection is closed. + System.err.println("Finit."); + } + + public void + lineReceived(String line) + { + System.err.println("Line: " + line); + + if (line.startsWith(":")) return; + + if (line.isEmpty()) { + handle(type, event.toString(), data.toString()); + event.delete(0, event.length()); + data.delete(0, event.length()); + } + + if (line.startsWith("data: ")) + data.append(line.substring("data: ".length())); + + if (line.startsWith("event: ")) + event.append(line.substring("event: ".length())); + + /* + * Note that I utterly ignore https://html.spec.whatwg.org + * /multipage/server-sent-events.html#dispatchMessage. + * That is because I am not a browser. + */ + } + + public void + connectionFailed(IOException eIo) + { + // sais pas dois-je faire.. + eIo.printStackTrace(); + } + + public void + requestFailed(int httpCode, Tree json) + { + // mo shiranu + System.err.println(httpCode + ", " + json.get("error").value); + } + + } + +// ---%-@-%--- + + TimelineWindowUpdater(JKomasto primaire) + { + this.primaire = primaire; + this.api = primaire.getMastodonApi(); + + this.updatees = new ArrayList<>(); + } + +} diff --git a/TwoToggleButton.java b/TwoToggleButton.java old mode 100755 new mode 100644 index ff364fd..a767e72 --- a/TwoToggleButton.java +++ b/TwoToggleButton.java @@ -133,6 +133,7 @@ implements KeyListener, MouseListener, FocusListener { case MouseEvent.BUTTON1: togglePrimary(); break; case MouseEvent.BUTTON3: toggleSecondary(); break; } + requestFocusInWindow(); } public void @@ -142,6 +143,7 @@ implements KeyListener, MouseListener, FocusListener { case KeyEvent.VK_SPACE: togglePrimary(); break; case KeyEvent.VK_ENTER: toggleSecondary(); break; } + requestFocusInWindow(); } public void @@ -241,10 +243,12 @@ implements KeyListener, MouseListener, FocusListener { private Image image; +// - -%- - + private int nextEventID = ActionEvent.ACTION_FIRST; -// - -%- - +// - -%- - private static Image button, @@ -261,24 +265,20 @@ implements KeyListener, MouseListener, FocusListener { protected void paintComponent(Graphics g) { - ((java.awt.Graphics2D)g).setRenderingHint( - java.awt.RenderingHints.KEY_TEXT_ANTIALIASING, - java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON - ); - - g.drawImage(button, 0, 0, this); + g.drawImage(button, 0, 0, this); if (!isEnabled()) g.drawImage(disabledOverlay, 0, 0, this); if (isFocusOwner()) g.drawImage(selectedOverlay, 0, 0, this); - if (image == null) return; + if (image == null) return; + int w1 = button.getWidth(this); int h1 = button.getHeight(this); - Shape defaultClip = g.getClip(); - Shape roundClip = new Ellipse2D.Float(6, 6, w1 - 12, h1 - 12); int w2 = image.getWidth(this); int h2 = image.getHeight(this); + if (w2 == -1) w2 = w1; + if (h2 == -1) h2 = h1; if (h2 > w2) { h2 = h2 * w1 / w2; w2 = w1; @@ -287,8 +287,14 @@ implements KeyListener, MouseListener, FocusListener { w2 = w2 * h1 / h2; h2 = h1; } + Shape defaultClip, roundClip; + defaultClip = g.getClip(); + roundClip = new Ellipse2D.Float(6, 6, w1 - 12, h1 - 12); + g.setClip(roundClip); - g.drawImage(image, 0, 0, w2, h2, this); + g.drawImage(image, 0, 0, w2, h2, getParent()); + // I don't know why, but when we repaint ourselves, our + // parent doesn't repaint, so nothing seems to happen. g.setClip(defaultClip); } @@ -343,13 +349,13 @@ implements KeyListener, MouseListener, FocusListener { RoundButton() { if (button == null) loadCommonImages(); - + setModel(new DefaultButtonModel()); setFocusable(true); setOpaque(false); - int w = button.getWidth(null); - int h = button.getHeight(null); + int w = button.getWidth(this); + int h = button.getHeight(this); setPreferredSize(new Dimension(w, h)); this.addKeyListener(this); diff --git a/graphics/Flags.xcf b/graphics/Flags.xcf old mode 100755 new mode 100644 diff --git a/graphics/Hourglass.xcf b/graphics/Hourglass.xcf old mode 100755 new mode 100644 diff --git a/graphics/button.png b/graphics/button.png old mode 100755 new mode 100644 diff --git a/graphics/disabledOverlay.png b/graphics/disabledOverlay.png old mode 100755 new mode 100644 diff --git a/graphics/favouriteToggled.png b/graphics/favouriteToggled.png old mode 100755 new mode 100644 diff --git a/graphics/ref1.png b/graphics/ref1.png old mode 100755 new mode 100644 diff --git a/graphics/selectedOverlay.png b/graphics/selectedOverlay.png old mode 100755 new mode 100644 diff --git a/graphics/test1.png b/graphics/test1.png old mode 100755 new mode 100644 diff --git a/graphics/test2.png b/graphics/test2.png old mode 100755 new mode 100644 diff --git a/graphics/test3.png b/graphics/test3.png old mode 100755 new mode 100644 diff --git a/graphics/test4.png b/graphics/test4.png old mode 100755 new mode 100644