Show what a reply answers, and go there on a click
A reply carries `+draft/reply` naming the message it answers, and freeq stamps every message with a `msgid`, so both halves were already on the wire and only being dropped. The chip above a reply quotes the message it answers — the quote rather than a marker, since a reply is unreadable without knowing what it replies to and that message is usually off the top of the screen. Clicking it scrolls there, on vidya's `:scroll-here`. Sticking to the newest line now follows the reader rather than being always on: while the view is at the end it stays there, and jumping away reports "away", which is what stops the next arriving message from dragging the view off whatever was just jumped to. The way back is the button that was already there. A reply to something older than the buffer says so instead of quoting nothing. Fixes a plain-TCP regression from the outbox change: `flush-outbox!` derefed a queue only TLS connections have, so every raw socket died on its first read — which is every connection the Android build makes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4d7f34d parent: 7e53cb4 modified
README.md +1 -0 | @@ -108,6 +108,7 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 108 | 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 109 | 109 | * Twelve-hour timestamps from the server's own clock, with a heading wherever |
| 110 | 110 | the day changes |
| 111 | +* A chip above a reply quoting what it answers, and a click that goes there | |
| 111 | 112 | * Inline previews for PNG links, fetched once and cached under |
| 112 | 113 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 113 | 114 | * Join/part notices, DMs bucketed under the sender's nick |
| @@ -108,6 +108,7 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead | 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 109 | * Twelve-hour timestamps from the server's own clock, with a heading wherever | 109 | * Twelve-hour timestamps from the server's own clock, with a heading wherever |
| 110 | the day changes | 110 | the day changes |
| 111 | +* A chip above a reply quoting what it answers, and a click that goes there | ||
| 111 | * Inline previews for PNG links, fetched once and cached under | 112 | * Inline previews for PNG links, fetched once and cached under |
| 112 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size | 113 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 113 | * Join/part notices, DMs bucketed under the sender's nick | 114 | * Join/part notices, DMs bucketed under the sender's nick |
modified
src/frq/app.jolt +36 -2 | @@ -218,6 +218,31 @@ | ||
| 218 | 218 | system? [:dim-label {:key j :label (str/trim value)}] |
| 219 | 219 | :else [:label {:key j :label (str/trim value)}])) |
| 220 | 220 | |
| 221 | +(defn- summarise | |
| 222 | + "A message in one line's worth of words." | |
| 223 | + [m limit] | |
| 224 | + (let [text (str/replace (or (:text m) "") #"\s+" " ")] | |
| 225 | + (if (> (count text) limit) | |
| 226 | + (str (subs text 0 (dec limit)) "…") | |
| 227 | + text))) | |
| 228 | + | |
| 229 | +(defn- reply-chip | |
| 230 | + "What a message is replying to, above it, and a way back to it. | |
| 231 | + | |
| 232 | + The chip carries the quote rather than only a marker: a reply is unreadable | |
| 233 | + without knowing what it answers, and the message it answers is usually off | |
| 234 | + the top of the screen. Clicking takes you there." | |
| 235 | + [channel id] | |
| 236 | + [:vbox {:key :reply} | |
| 237 | + (if-let [target (s/message-by-id channel id)] | |
| 238 | + [:button {:label (str "↩ " (:from target) ": " (summarise target 48)) | |
| 239 | + :on-click #(do (reset! s/jump-to id) | |
| 240 | + ;; Off again once the frame that scrolled has | |
| 241 | + ;; been painted, so the reader keeps the view. | |
| 242 | + (vidya/after! 120 (fn [] (reset! s/jump-to nil))))}] | |
| 243 | + ;; The message it answers is older than this buffer goes. | |
| 244 | + [:dim-label {:label "↩ replying to an earlier message"}])]) | |
| 245 | + | |
| 221 | 246 | (defn message-row |
| 222 | 247 | "One message. `prev` is the message above it, which decides whether this one |
| 223 | 248 | repeats the sender. |
| @@ -230,7 +255,12 @@ | ||
| 230 | 255 | (not (:system? m)) |
| 231 | 256 | (not (:system? prev)) |
| 232 | 257 | (= (:from prev) (:from m)))] |
| 233 | - [:vbox {:key i :spacing 2 :margin 0} | |
| 258 | + [:vbox {:key i :spacing 2 :margin 0 | |
| 259 | + ;; The jump target is what a "go to message" click scrolls to. | |
| 260 | + :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))} | |
| 261 | + [:vbox {:key :reply-chip} | |
| 262 | + (when-let [reply-to (:reply-to m)] | |
| 263 | + [reply-chip @s/current reply-to])] | |
| 234 | 264 | ;; A run from one person reads as one block; repeating the nick on every |
| 235 | 265 | ;; line is what made a busy channel look like a list of headers. The time |
| 236 | 266 | ;; rides on that same line, for the same reason: once per run, not once |
| @@ -321,7 +351,11 @@ | ||
| 321 | 351 | [error-note] |
| 322 | 352 | ;; :reserve leaves room for the separator and compose bar below, so the |
| 323 | 353 | ;; list fills whatever the window has and no more. |
| 324 | - [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true | |
| 354 | + ;; Sticking follows the reader rather than a flag of its own: while the | |
| 355 | + ;; view is at the end it stays there, and a jump to an old message reports | |
| 356 | + ;; "away", which is what stops the next arriving line from dragging the | |
| 357 | + ;; view off whatever was jumped to. | |
| 358 | + [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom @s/at-present? | |
| 325 | 359 | :scroll-to-bottom @s/jump-tick |
| 326 | 360 | :on-change #(reset! s/at-present? (= "end" %))} |
| 327 | 361 | (if (seq (:messages buffer)) |
| @@ -218,6 +218,31 @@ | |||
| 218 | system? [:dim-label {:key j :label (str/trim value)}] | 218 | system? [:dim-label {:key j :label (str/trim value)}] |
| 219 | :else [:label {:key j :label (str/trim value)}])) | 219 | :else [:label {:key j :label (str/trim value)}])) |
| 220 | 220 | ||
| 221 | +(defn- summarise | ||
| 222 | + "A message in one line's worth of words." | ||
| 223 | + [m limit] | ||
| 224 | + (let [text (str/replace (or (:text m) "") #"\s+" " ")] | ||
| 225 | + (if (> (count text) limit) | ||
| 226 | + (str (subs text 0 (dec limit)) "…") | ||
| 227 | + text))) | ||
| 228 | + | ||
| 229 | +(defn- reply-chip | ||
| 230 | + "What a message is replying to, above it, and a way back to it. | ||
| 231 | + | ||
| 232 | + The chip carries the quote rather than only a marker: a reply is unreadable | ||
| 233 | + without knowing what it answers, and the message it answers is usually off | ||
| 234 | + the top of the screen. Clicking takes you there." | ||
| 235 | + [channel id] | ||
| 236 | + [:vbox {:key :reply} | ||
| 237 | + (if-let [target (s/message-by-id channel id)] | ||
| 238 | + [:button {:label (str "↩ " (:from target) ": " (summarise target 48)) | ||
| 239 | + :on-click #(do (reset! s/jump-to id) | ||
| 240 | + ;; Off again once the frame that scrolled has | ||
| 241 | + ;; been painted, so the reader keeps the view. | ||
| 242 | + (vidya/after! 120 (fn [] (reset! s/jump-to nil))))}] | ||
| 243 | + ;; The message it answers is older than this buffer goes. | ||
| 244 | + [:dim-label {:label "↩ replying to an earlier message"}])]) | ||
| 245 | + | ||
| 221 | (defn message-row | 246 | (defn message-row |
| 222 | "One message. `prev` is the message above it, which decides whether this one | 247 | "One message. `prev` is the message above it, which decides whether this one |
| 223 | repeats the sender. | 248 | repeats the sender. |
| @@ -230,7 +255,12 @@ | |||
| 230 | (not (:system? m)) | 255 | (not (:system? m)) |
| 231 | (not (:system? prev)) | 256 | (not (:system? prev)) |
| 232 | (= (:from prev) (:from m)))] | 257 | (= (:from prev) (:from m)))] |
| 233 | - [:vbox {:key i :spacing 2 :margin 0} | 258 | + [:vbox {:key i :spacing 2 :margin 0 |
| 259 | + ;; The jump target is what a "go to message" click scrolls to. | ||
| 260 | + :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))} | ||
| 261 | + [:vbox {:key :reply-chip} | ||
| 262 | + (when-let [reply-to (:reply-to m)] | ||
| 263 | + [reply-chip @s/current reply-to])] | ||
| 234 | ;; A run from one person reads as one block; repeating the nick on every | 264 | ;; A run from one person reads as one block; repeating the nick on every |
| 235 | ;; line is what made a busy channel look like a list of headers. The time | 265 | ;; line is what made a busy channel look like a list of headers. The time |
| 236 | ;; rides on that same line, for the same reason: once per run, not once | 266 | ;; rides on that same line, for the same reason: once per run, not once |
| @@ -321,7 +351,11 @@ | |||
| 321 | [error-note] | 351 | [error-note] |
| 322 | ;; :reserve leaves room for the separator and compose bar below, so the | 352 | ;; :reserve leaves room for the separator and compose bar below, so the |
| 323 | ;; list fills whatever the window has and no more. | 353 | ;; list fills whatever the window has and no more. |
| 324 | - [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true | 354 | + ;; Sticking follows the reader rather than a flag of its own: while the |
| 355 | + ;; view is at the end it stays there, and a jump to an old message reports | ||
| 356 | + ;; "away", which is what stops the next arriving line from dragging the | ||
| 357 | + ;; view off whatever was jumped to. | ||
| 358 | + [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom @s/at-present? | ||
| 325 | :scroll-to-bottom @s/jump-tick | 359 | :scroll-to-bottom @s/jump-tick |
| 326 | :on-change #(reset! s/at-present? (= "end" %))} | 360 | :on-change #(reset! s/at-present? (= "end" %))} |
| 327 | (if (seq (:messages buffer)) | 361 | (if (seq (:messages buffer)) |
modified
src/frq/irc.jolt +20 -1 | @@ -73,6 +73,16 @@ | ||
| 73 | 73 | :command (str/upper-case (or (first parts) "")) |
| 74 | 74 | :params (cond-> (vec (rest parts)) trailing (conj trailing))})) |
| 75 | 75 | |
| 76 | +(defn tag-value | |
| 77 | + "One IRCv3 tag's value, or nil. Tag values escape `;` and space; the ids and | |
| 78 | + timestamps read here contain neither, so they are taken as they come." | |
| 79 | + [tags key] | |
| 80 | + (when tags | |
| 81 | + (some (fn [pair] | |
| 82 | + (let [[k v] (str/split pair #"=" 2)] | |
| 83 | + (when (= k key) v))) | |
| 84 | + (str/split tags #";")))) | |
| 85 | + | |
| 76 | 86 | (defn nick-of |
| 77 | 87 | "The nick half of a `nick!user@host` prefix." |
| 78 | 88 | [prefix] |
| @@ -126,9 +136,18 @@ | ||
| 126 | 136 | (locking (:lock conn) (swap! (:outbox conn) conj text)) |
| 127 | 137 | (locking (:lock conn) (write! conn text))))) |
| 128 | 138 | |
| 139 | +(declare flush-outbox-tls!) | |
| 140 | + | |
| 129 | 141 | (defn- flush-outbox! |
| 130 | - "Write whatever has been queued. Only ever called on the reader thread." | |
| 142 | + "Write whatever has been queued. Only ever called on the reader thread. | |
| 143 | + | |
| 144 | + Only a TLS connection has a queue: a raw socket is written straight from | |
| 145 | + whichever thread is sending, so there is nothing here to drain." | |
| 131 | 146 | [conn] |
| 147 | + (when (:outbox conn) | |
| 148 | + (flush-outbox-tls! conn))) | |
| 149 | + | |
| 150 | +(defn- flush-outbox-tls! [conn] | |
| 132 | 151 | (let [pending (locking (:lock conn) |
| 133 | 152 | (let [q @(:outbox conn)] |
| 134 | 153 | (reset! (:outbox conn) []) |
| @@ -73,6 +73,16 @@ | |||
| 73 | :command (str/upper-case (or (first parts) "")) | 73 | :command (str/upper-case (or (first parts) "")) |
| 74 | :params (cond-> (vec (rest parts)) trailing (conj trailing))})) | 74 | :params (cond-> (vec (rest parts)) trailing (conj trailing))})) |
| 75 | 75 | ||
| 76 | +(defn tag-value | ||
| 77 | + "One IRCv3 tag's value, or nil. Tag values escape `;` and space; the ids and | ||
| 78 | + timestamps read here contain neither, so they are taken as they come." | ||
| 79 | + [tags key] | ||
| 80 | + (when tags | ||
| 81 | + (some (fn [pair] | ||
| 82 | + (let [[k v] (str/split pair #"=" 2)] | ||
| 83 | + (when (= k key) v))) | ||
| 84 | + (str/split tags #";")))) | ||
| 85 | + | ||
| 76 | (defn nick-of | 86 | (defn nick-of |
| 77 | "The nick half of a `nick!user@host` prefix." | 87 | "The nick half of a `nick!user@host` prefix." |
| 78 | [prefix] | 88 | [prefix] |
| @@ -126,9 +136,18 @@ | |||
| 126 | (locking (:lock conn) (swap! (:outbox conn) conj text)) | 136 | (locking (:lock conn) (swap! (:outbox conn) conj text)) |
| 127 | (locking (:lock conn) (write! conn text))))) | 137 | (locking (:lock conn) (write! conn text))))) |
| 128 | 138 | ||
| 139 | +(declare flush-outbox-tls!) | ||
| 140 | + | ||
| 129 | (defn- flush-outbox! | 141 | (defn- flush-outbox! |
| 130 | - "Write whatever has been queued. Only ever called on the reader thread." | 142 | + "Write whatever has been queued. Only ever called on the reader thread. |
| 143 | + | ||
| 144 | + Only a TLS connection has a queue: a raw socket is written straight from | ||
| 145 | + whichever thread is sending, so there is nothing here to drain." | ||
| 131 | [conn] | 146 | [conn] |
| 147 | + (when (:outbox conn) | ||
| 148 | + (flush-outbox-tls! conn))) | ||
| 149 | + | ||
| 150 | +(defn- flush-outbox-tls! [conn] | ||
| 132 | (let [pending (locking (:lock conn) | 151 | (let [pending (locking (:lock conn) |
| 133 | (let [q @(:outbox conn)] | 152 | (let [q @(:outbox conn)] |
| 134 | (reset! (:outbox conn) []) | 153 | (reset! (:outbox conn) []) |
modified
src/frq/state.jolt +29 -4 | @@ -114,9 +114,10 @@ | ||
| 114 | 114 | The extras are what the message carried beyond its text: `:at` when it was |
| 115 | 115 | said, from the server's own `time` tag where there is one, and `:did` who |
| 116 | 116 | said it, from the `account` tag — an identity that outlasts whatever nick |
| 117 | - they are using today." | |
| 117 | + they are using today. `:id` names this message so a reply can point at it, | |
| 118 | + and `:reply-to` is the one it answers." | |
| 118 | 119 | ([channel from text] (push-message! channel from text {})) |
| 119 | - ([channel from text {:keys [at did]}] | |
| 120 | + ([channel from text {:keys [at did id reply-to]}] | |
| 120 | 121 | (let [at (or at (clock/now-ms)) |
| 121 | 122 | who (avatars/actor did from)] |
| 122 | 123 | (doseq [url (media/image-urls text)] |
| @@ -133,7 +134,10 @@ | ||
| 133 | 134 | {:from from :text text :system? (= "*" from) |
| 134 | 135 | :actor who |
| 135 | 136 | :images (media/image-urls text) |
| 136 | - :at at}) | |
| 137 | + :at at | |
| 138 | + ;; `:id` is what a reply points at, and | |
| 139 | + ;; `:reply-to` is what this one points at. | |
| 140 | + :id id :reply-to reply-to}) | |
| 137 | 141 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) |
| 138 | 142 | |
| 139 | 143 | (defn open-channel! |
| @@ -176,10 +180,19 @@ | ||
| 176 | 180 | ;; backlog is hours or weeks old, and stamping it with |
| 177 | 181 | ;; the moment it arrived would say it all happened now. |
| 178 | 182 | at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) |
| 183 | + tags (:tags msg) | |
| 179 | 184 | ;; a DM addressed to us belongs in a buffer named for the |
| 180 | 185 | ;; sender, not for our own nick |
| 181 | 186 | buffer (if (str/starts-with? (or target "") "#") target from)] |
| 182 | - (push-message! buffer from text {:at at :did (:account msg)})) | |
| 187 | + (push-message! buffer from text | |
| 188 | + {:at at | |
| 189 | + :did (:account msg) | |
| 190 | + :id (irc/tag-value tags "msgid") | |
| 191 | + ;; The server canonicalises +draft/reply to | |
| 192 | + ;; +reply; a client that sent the draft name | |
| 193 | + ;; may still reach us before it does. | |
| 194 | + :reply-to (or (irc/tag-value tags "+reply") | |
| 195 | + (irc/tag-value tags "+draft/reply"))})) | |
| 183 | 196 | "JOIN" (let [ch (first params)] |
| 184 | 197 | (if (= from @form-nick) |
| 185 | 198 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| @@ -471,6 +484,18 @@ | ||
| 471 | 484 | ordered)))) |
| 472 | 485 | (count saved))) |
| 473 | 486 | |
| 487 | +(defn message-by-id | |
| 488 | + "The message a reply points at, if this buffer still holds it." | |
| 489 | + [channel id] | |
| 490 | + (when id | |
| 491 | + (first (filter #(= id (:id %)) (get-in @channels [channel :messages]))))) | |
| 492 | + | |
| 493 | +;; The message a "go to" is currently aiming at. Set for the frame that scrolls | |
| 494 | +;; to it and taken off again — a scroll target that stays set would pin the | |
| 495 | +;; view there and take scrolling away from the reader. | |
| 496 | +(defonce jump-to (atom nil)) | |
| 497 | + | |
| 498 | + | |
| 474 | 499 | (defn last-preview [buffer] |
| 475 | 500 | (if-let [m (last (:messages buffer))] |
| 476 | 501 | (str (:from m) ": " (:text m)) |
| @@ -114,9 +114,10 @@ | |||
| 114 | The extras are what the message carried beyond its text: `:at` when it was | 114 | The extras are what the message carried beyond its text: `:at` when it was |
| 115 | said, from the server's own `time` tag where there is one, and `:did` who | 115 | said, from the server's own `time` tag where there is one, and `:did` who |
| 116 | said it, from the `account` tag — an identity that outlasts whatever nick | 116 | said it, from the `account` tag — an identity that outlasts whatever nick |
| 117 | - they are using today." | 117 | + they are using today. `:id` names this message so a reply can point at it, |
| 118 | + and `:reply-to` is the one it answers." | ||
| 118 | ([channel from text] (push-message! channel from text {})) | 119 | ([channel from text] (push-message! channel from text {})) |
| 119 | - ([channel from text {:keys [at did]}] | 120 | + ([channel from text {:keys [at did id reply-to]}] |
| 120 | (let [at (or at (clock/now-ms)) | 121 | (let [at (or at (clock/now-ms)) |
| 121 | who (avatars/actor did from)] | 122 | who (avatars/actor did from)] |
| 122 | (doseq [url (media/image-urls text)] | 123 | (doseq [url (media/image-urls text)] |
| @@ -133,7 +134,10 @@ | |||
| 133 | {:from from :text text :system? (= "*" from) | 134 | {:from from :text text :system? (= "*" from) |
| 134 | :actor who | 135 | :actor who |
| 135 | :images (media/image-urls text) | 136 | :images (media/image-urls text) |
| 136 | - :at at}) | 137 | + :at at |
| 138 | + ;; `:id` is what a reply points at, and | ||
| 139 | + ;; `:reply-to` is what this one points at. | ||
| 140 | + :id id :reply-to reply-to}) | ||
| 137 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) | 141 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) |
| 138 | 142 | ||
| 139 | (defn open-channel! | 143 | (defn open-channel! |
| @@ -176,10 +180,19 @@ | |||
| 176 | ;; backlog is hours or weeks old, and stamping it with | 180 | ;; backlog is hours or weeks old, and stamping it with |
| 177 | ;; the moment it arrived would say it all happened now. | 181 | ;; the moment it arrived would say it all happened now. |
| 178 | at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) | 182 | at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) |
| 183 | + tags (:tags msg) | ||
| 179 | ;; a DM addressed to us belongs in a buffer named for the | 184 | ;; a DM addressed to us belongs in a buffer named for the |
| 180 | ;; sender, not for our own nick | 185 | ;; sender, not for our own nick |
| 181 | buffer (if (str/starts-with? (or target "") "#") target from)] | 186 | buffer (if (str/starts-with? (or target "") "#") target from)] |
| 182 | - (push-message! buffer from text {:at at :did (:account msg)})) | 187 | + (push-message! buffer from text |
| 188 | + {:at at | ||
| 189 | + :did (:account msg) | ||
| 190 | + :id (irc/tag-value tags "msgid") | ||
| 191 | + ;; The server canonicalises +draft/reply to | ||
| 192 | + ;; +reply; a client that sent the draft name | ||
| 193 | + ;; may still reach us before it does. | ||
| 194 | + :reply-to (or (irc/tag-value tags "+reply") | ||
| 195 | + (irc/tag-value tags "+draft/reply"))})) | ||
| 183 | "JOIN" (let [ch (first params)] | 196 | "JOIN" (let [ch (first params)] |
| 184 | (if (= from @form-nick) | 197 | (if (= from @form-nick) |
| 185 | (let [fresh? (empty? (get-in @channels [ch :messages]))] | 198 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| @@ -471,6 +484,18 @@ | |||
| 471 | ordered)))) | 484 | ordered)))) |
| 472 | (count saved))) | 485 | (count saved))) |
| 473 | 486 | ||
| 487 | +(defn message-by-id | ||
| 488 | + "The message a reply points at, if this buffer still holds it." | ||
| 489 | + [channel id] | ||
| 490 | + (when id | ||
| 491 | + (first (filter #(= id (:id %)) (get-in @channels [channel :messages]))))) | ||
| 492 | + | ||
| 493 | +;; The message a "go to" is currently aiming at. Set for the frame that scrolls | ||
| 494 | +;; to it and taken off again — a scroll target that stays set would pin the | ||
| 495 | +;; view there and take scrolling away from the reader. | ||
| 496 | +(defonce jump-to (atom nil)) | ||
| 497 | + | ||
| 498 | + | ||
| 474 | (defn last-preview [buffer] | 499 | (defn last-preview [buffer] |
| 475 | (if-let [m (last (:messages buffer))] | 500 | (if-let [m (last (:messages buffer))] |
| 476 | (str (:from m) ": " (:text m)) | 501 | (str (:from m) ": " (:text m)) |