From 4ed5b3536cbaa999b7ac5433de65f7fe1a6d2bf0 Mon Sep 17 00:00:00 2001 From: Snowyfox Date: Sun, 1 May 2022 23:05:36 -0400 Subject: [PATCH] Added reactive updating to TimelineWindow and NotificationsWindow Thread unsafe, will probably explode eventually --- JKomasto.java | 32 ++--- MastodonApi.java | 9 +- NotificationsWindow.java | 96 +++++++++---- TimelineWindow.java | 25 +++- TimelineWindowUpdater.java | 230 ------------------------------- WindowUpdater.java | 275 +++++++++++++++++++++++++++++++++++++ 6 files changed, 387 insertions(+), 280 deletions(-) delete mode 100644 TimelineWindowUpdater.java create mode 100644 WindowUpdater.java diff --git a/JKomasto.java b/JKomasto.java index d585f62..d6632b8 100644 --- a/JKomasto.java +++ b/JKomasto.java @@ -32,8 +32,8 @@ JKomasto { private NotificationsWindow notificationsWindow; - private TimelineWindowUpdater - timelineWindowUpdater; + private WindowUpdater + windowUpdater; private MastodonApi api; @@ -46,22 +46,14 @@ JKomasto { public void finishedLogin() { - autoViewWindow.setCursor(new Cursor(Cursor.WAIT_CURSOR)); timelineWindow.setCursor(new Cursor(Cursor.WAIT_CURSOR)); - notificationsWindow.showLatestPage(); timelineWindow.showLatestPage(); - timelineWindow.setLocationByPlatform(true); + notificationsWindow.showLatestPage(); timelineWindow.setVisible(true); + loginWindow.dispose(); - autoViewWindow.setTitle("Auto view - JKomasto"); - //autoViewWindow.setVisible(true); - - loginWindow.dispose(); - autoViewWindow.setCursor(null); timelineWindow.setCursor(null); - - timelineWindowUpdater.addWindow(timelineWindow); } public PostWindow @@ -76,10 +68,16 @@ JKomasto { public NotificationsWindow getNotificationsWindow() { return notificationsWindow; } + public WindowUpdater + getWindowUpdater() { return windowUpdater; } + // ---%-@-%--- public static void - main(String... args) { new JKomasto(); } + main(String... args) + { + new JKomasto().loginWindow.setVisible(true); + } // ---%-@-%--- @@ -87,6 +85,7 @@ JKomasto { JKomasto() { api = new MastodonApi(); + windowUpdater = new WindowUpdater(this); timelineWindow = new TimelineWindow(this); composeWindow = new ComposeWindow(this); @@ -95,15 +94,16 @@ JKomasto { mediaWindow = new ImageWindow(); notificationsWindow = new NotificationsWindow(this); + autoViewWindow.setTitle("Auto view - JKomasto"); + composeWindow.dispose(); autoViewWindow.dispose(); timelineWindow.dispose(); mediaWindow.dispose(); notificationsWindow.dispose(); - loginWindow.setLocationByPlatform(true); - loginWindow.setVisible(true); - timelineWindowUpdater = new TimelineWindowUpdater(this); + timelineWindow.setLocationByPlatform(true); + loginWindow.setLocationByPlatform(true); } } diff --git a/MastodonApi.java b/MastodonApi.java index 9bd8032..ebe2c1e 100644 --- a/MastodonApi.java +++ b/MastodonApi.java @@ -14,6 +14,7 @@ import java.io.FileWriter; import java.io.BufferedReader; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.nio.channels.ClosedByInterruptException; class MastodonApi { @@ -440,6 +441,7 @@ MastodonApi { public void monitorTimeline( TimelineType type, ServerSideEventsListener handler) + throws InterruptedException { String token = accessToken.get("access_token").value; @@ -474,11 +476,16 @@ MastodonApi { input = new InputStreamReader(conn.getInputStream()); BufferedReader br = new BufferedReader(input); - while (true) { + while (true) + { String line = br.readLine(); if (line != null) handler.lineReceived(line); } } + catch (ClosedByInterruptException eIt) + { + throw new InterruptedException(); + } catch (IOException eIo) { handler.connectionFailed(eIo); } } diff --git a/NotificationsWindow.java b/NotificationsWindow.java index 0c24d40..fd5b914 100644 --- a/NotificationsWindow.java +++ b/NotificationsWindow.java @@ -40,6 +40,9 @@ NotificationsWindow extends JFrame { private NotificationsComponent display; + private boolean + showingLatest; + // - -%- - private static int @@ -48,7 +51,7 @@ NotificationsWindow extends JFrame { // ---%-@-%--- public void - displayEntity(Tree entity) + readEntity(Tree entity) { notifications = new ArrayList<>(); for (Tree t: entity) @@ -97,20 +100,45 @@ NotificationsWindow extends JFrame { } } + public void + refresh() + { + String firstId = null; + if (!showingLatest) + { + assert !notifications.isEmpty(); + firstId = notifications.get(0).id; + } + + if (fetchPage(firstId, null)) + { + if (notifications.size() < ROW_COUNT) showLatestPage(); + display.showNotifications(notifications); + } + } + public void showLatestPage() { - fetchPage(null, null); - display.showNotifications(notifications); + if (fetchPage(null, null)) + { + display.showNotifications(notifications); + showingLatest = true; + primaire.getWindowUpdater().add(this); + } } public void showPrevPage() { assert !notifications.isEmpty(); - fetchPage(null, notifications.get(0).id); - if (notifications.size() < ROW_COUNT) showLatestPage(); - display.showNotifications(notifications); + if (fetchPage(null, notifications.get(0).id)) + { + if (notifications.size() < ROW_COUNT) showLatestPage(); + display.showNotifications(notifications); + showingLatest = false; + primaire.getWindowUpdater().remove(this); + } } public void @@ -118,41 +146,51 @@ NotificationsWindow extends JFrame { { assert !notifications.isEmpty(); int last = notifications.size() - 1; - fetchPage(notifications.get(last).id, null); - display.showNotifications(notifications); + if (fetchPage(notifications.get(last).id, null)) + { + display.showNotifications(notifications); + showingLatest = false; + primaire.getWindowUpdater().remove(this); + } } // - -%- - - private void + private boolean fetchPage(String maxId, String minId) { display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); - api.getNotifications( - ROW_COUNT, maxId, minId, - new RequestListener() { + class Handler implements RequestListener { - public void - connectionFailed(IOException eIo) - { - eIo.printStackTrace(); - } + boolean + succeeded = false; - public void - requestFailed(int httpCode, Tree json) - { - System.err.println(httpCode + json.get("error").value); - } + // -=%=- - public void - requestSucceeded(Tree json) - { - displayEntity(json); - } - } - ); + public void + connectionFailed(IOException eIo) + { + eIo.printStackTrace(); + } + + public void + requestFailed(int httpCode, Tree json) + { + System.err.println(httpCode + json.get("error").value); + } + + public void + requestSucceeded(Tree json) + { + readEntity(json); + succeeded = true; + } + } + Handler handler = new Handler(); + api.getNotifications(ROW_COUNT, maxId, minId, handler); display.setCursor(null); repaint(); + return handler.succeeded; } // ---%-@-%--- diff --git a/TimelineWindow.java b/TimelineWindow.java index 5273b3f..532167d 100644 --- a/TimelineWindow.java +++ b/TimelineWindow.java @@ -55,6 +55,9 @@ implements ActionListener { private MastodonApi api; + private WindowUpdater + windowUpdater; + private TimelinePage page; @@ -78,6 +81,9 @@ implements ActionListener { private JMenuItem flipToNewestPost; + private boolean + showingLatest; + // - -%- - private static final int @@ -158,10 +164,14 @@ implements ActionListener { public void refresh() { - assert page.posts != null; - assert page.posts.size() != 0; - Tree first = page.posts.get(0); - String firstId = first.get("id").value; + String firstId = null; + if (!showingLatest) + { + assert page.posts != null; + assert page.posts.size() != 0; + Tree first = page.posts.get(0); + firstId = first.get("id").value; + } display.setCursor(new Cursor(Cursor.WAIT_CURSOR)); api.getTimelinePage( @@ -241,6 +251,8 @@ implements ActionListener { requestSucceeded(Tree json) { readEntity(json); + showingLatest = true; + windowUpdater.add(TimelineWindow.this); } } @@ -294,6 +306,8 @@ implements ActionListener { return; } readEntity(json); + showingLatest = false; + windowUpdater.remove(TimelineWindow.this); } } @@ -345,6 +359,8 @@ implements ActionListener { return; } readEntity(json); + showingLatest = false; + windowUpdater.remove(TimelineWindow.this); } } @@ -639,6 +655,7 @@ implements ActionListener { { this.primaire = primaire; this.api = primaire.getMastodonApi(); + this.windowUpdater = primaire.getWindowUpdater(); getContentPane().setPreferredSize(new Dimension(320, 460)); pack(); diff --git a/TimelineWindowUpdater.java b/TimelineWindowUpdater.java deleted file mode 100644 index 496bea2..0000000 --- a/TimelineWindowUpdater.java +++ /dev/null @@ -1,230 +0,0 @@ - -import java.util.List; -import java.util.ArrayList; -import java.io.IOException; -import cafe.biskuteri.hinoki.Tree; -import javax.sound.sampled.AudioSystem; -import javax.sound.sampled.Clip; -import javax.sound.sampled.AudioInputStream; -import javax.sound.sampled.UnsupportedAudioFileException; -import javax.sound.sampled.LineUnavailableException; -import java.net.URL; - -class -TimelineWindowUpdater { - - private JKomasto - primaire; - - private MastodonApi - api; - -// - -%- - - - private List - timelineUpdatees; - - private List - notificationUpdatees; - - private StringBuilder - event, data; - - private Clip - notificationSound; - -// - -%- - - - private Thread - spublic, - user; - -// ---%-@-%--- - - public void - addWindow(TimelineWindow updatee) - { - timelineUpdatees.add(updatee); - - Connection c = new Connection(); - c.type = updatee.getTimelineType(); - switch (c.type) - { - case FEDERATED: - case LOCAL: - if (spublic != null) return; - spublic = new Thread(c); - spublic.start(); - break; - case HOME: - if (user != null) return; - user = new Thread(c); - user.start(); - break; - } - } - - public void - addWindow(NotificationsWindow updatee) - { - notificationUpdatees.add(updatee); - - Connection c = new Connection(); - c.type = TimelineType.HOME; - if (user != null) return; - user = new Thread(c); - user.start(); - } - - public void - removeWindow(TimelineWindow updatee) - { - timelineUpdatees.remove(updatee); - } - - public void - removeWindow(NotificationsWindow updatee) - { - notificationUpdatees.remove(updatee); - } - -// - -%- - - - private void - handle(TimelineType type, String event, String data) - { - 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)) { - updatee.showLatestPage(); - } - for (NotificationsWindow updatee: notificationUpdatees) { - updatee.showLatestPage(); - } - - if (newNotif) - { - notificationSound.setFramePosition(0); - notificationSound.start(); - } - } - - private List - filter(TimelineType type) - { - List returnee = new ArrayList<>(); - for (TimelineWindow updatee: timelineUpdatees) - 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. - } - - public void - lineReceived(String 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.timelineUpdatees = new ArrayList<>(); - this.notificationUpdatees = new ArrayList<>(); - - loadNotificationSound(); - } - - void - loadNotificationSound() - { - URL url = getClass().getResource("KDE_Dialog_Appear.wav"); - try { - Clip clip = AudioSystem.getClip(); - clip.open(AudioSystem.getAudioInputStream(url)); - notificationSound = clip; - } - catch (LineUnavailableException eLu) { - assert false; - } - catch (UnsupportedAudioFileException eUa) { - assert false; - } - catch (IOException eIo) { - assert false; - } - catch (IllegalArgumentException eIa) { - assert false; - } - } - -} diff --git a/WindowUpdater.java b/WindowUpdater.java new file mode 100644 index 0000000..968f238 --- /dev/null +++ b/WindowUpdater.java @@ -0,0 +1,275 @@ + +import java.util.List; +import java.util.ArrayList; +import java.io.IOException; +import cafe.biskuteri.hinoki.Tree; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.Clip; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.UnsupportedAudioFileException; +import javax.sound.sampled.LineUnavailableException; +import java.net.URL; + +class +WindowUpdater { + + private JKomasto + primaire; + + private MastodonApi + api; + +// - -%- - + + private List + timelineWindows; + + private List + notificationWindows; + + private Clip + notificationSound; + + private Connection + publicConn, + userConn; + +// ---%-@-%--- + + public void + add(TimelineWindow updatee) + { + if (timelineWindows.contains(updatee)) return; + + timelineWindows.add(updatee); + publicConn.reevaluate(); + userConn.reevaluate(); + } + + public void + add(NotificationsWindow updatee) + { + if (notificationWindows.contains(updatee)) return; + + notificationWindows.add(updatee); + userConn.reevaluate(); + } + + public void + remove(TimelineWindow updatee) + { + timelineWindows.remove(updatee); + publicConn.reevaluate(); + userConn.reevaluate(); + } + + public void + remove(NotificationsWindow updatee) + { + notificationWindows.remove(updatee); + userConn.reevaluate(); + } + +// ---%-@-%--- + + private class + Connection + implements Runnable, ServerSideEventsListener { + + private TimelineType + type; + +// -=- + + private Thread + thread; + + private StringBuilder + event, data; + +// -=%=- + + public void + restart() + { + if (thread != null) stop(); + thread = new Thread(this); + thread.start(); + } + + public void + stop() + { + try { + thread.interrupt(); + thread.join(); + thread = null; + } + catch (InterruptedException eIt) { + assert false; + // Who would do that to us.. + } + } + + public void + reevaluate() + { + boolean hasUpdatee = false; + + for (NotificationsWindow updatee: notificationWindows) + if (responsibleFor(updatee)) hasUpdatee = true; + + for (TimelineWindow updatee: timelineWindows) + if (responsibleFor(updatee)) hasUpdatee = true; + + if (!hasUpdatee && thread != null) stop(); + if (hasUpdatee && thread == null) restart(); + } + +// -=- + + private boolean + responsibleFor(TimelineWindow updatee) + { + return type == updatee.getTimelineType(); + } + + private boolean + responsibleFor(NotificationsWindow updatee) + { + return type == TimelineType.HOME; + } + + public void + run() + { + try { + event = new StringBuilder(); + data = new StringBuilder(); + api.monitorTimeline(type, this); + // monitorTimeline should not return + // until the connection is closed. + } + catch (InterruptedException eIt) { } + } + + public void + lineReceived(String line) + { + if (line.startsWith(":")) return; + + if (line.isEmpty()) + { + handle(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 ignore https://html.spec.whatwg.org + * /multipage/server-sent-events.html#dispatchMessage. + * That is because I am not a browser. + */ + } + + private void + handle(String event, String data) + { + assert !data.isEmpty(); + if (event.isEmpty()) return; + + boolean newPost = event.equals("update"); + boolean newNotif = event.equals("notification"); + if (!(newPost || newNotif)) return; + + if (newNotif) + { + notificationSound.setFramePosition(0); + notificationSound.start(); + } + + for (TimelineWindow updatee: timelineWindows) + { + if (!responsibleFor(updatee)) continue; + updatee.refresh(); + /* + * (悪) Note that we're in a separate thread, + * and our windows aren't thread-safe. We could + * probably make them a bit bananas asking + * for a refresh while they're in the middle + * of one. Could we add mutexes? + */ + } + + for (NotificationsWindow updatee: notificationWindows) + { + if (!responsibleFor(updatee)) continue; + updatee.refresh(); + } + } + + 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); + } + + } + +// ---%-@-%--- + + WindowUpdater(JKomasto primaire) + { + this.primaire = primaire; + this.api = primaire.getMastodonApi(); + + this.timelineWindows = new ArrayList<>(); + this.notificationWindows = new ArrayList<>(); + + publicConn = new Connection(); + publicConn.type = TimelineType.FEDERATED; + + userConn = new Connection(); + userConn.type = TimelineType.HOME; + + loadNotificationSound(); + } + + void + loadNotificationSound() + { + URL url = getClass().getResource("KDE_Dialog_Appear.wav"); + try { + Clip clip = AudioSystem.getClip(); + clip.open(AudioSystem.getAudioInputStream(url)); + notificationSound = clip; + } + catch (LineUnavailableException eLu) { + assert false; + } + catch (UnsupportedAudioFileException eUa) { + assert false; + } + catch (IOException eIo) { + assert false; + } + catch (IllegalArgumentException eIa) { + assert false; + } + } + +}