nandi/frqpublic Fork 0
4d7f34d
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-08-30T11:46:41-07:00 Browse files
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.
108108 * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead
109109 * Twelve-hour timestamps from the server's own clock, with a heading wherever
110110 the day changes
111+* A chip above a reply quoting what it answers, and a click that goes there
111112 * Inline previews for PNG links, fetched once and cached under
112113 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
113114 * 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 instead108 * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead
109 * Twelve-hour timestamps from the server's own clock, with a heading wherever109 * Twelve-hour timestamps from the server's own clock, with a heading wherever
110 the day changes110 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 under112 * Inline previews for PNG links, fetched once and cached under
112 `$XDG_CACHE_HOME/frq/media`; click one to see it full size113 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
113 * Join/part notices, DMs bucketed under the sender's nick114 * Join/part notices, DMs bucketed under the sender's nick
modified src/frq/app.jolt +36 -2
@@ -218,6 +218,31 @@
218218 system? [:dim-label {:key j :label (str/trim value)}]
219219 :else [:label {:key j :label (str/trim value)}]))
220220
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+
221246 (defn message-row
222247 "One message. `prev` is the message above it, which decides whether this one
223248 repeats the sender.
@@ -230,7 +255,12 @@
230255 (not (:system? m))
231256 (not (:system? prev))
232257 (= (: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])]
234264 ;; A run from one person reads as one block; repeating the nick on every
235265 ;; line is what made a busy channel look like a list of headers. The time
236266 ;; rides on that same line, for the same reason: once per run, not once
@@ -321,7 +351,11 @@
321351 [error-note]
322352 ;; :reserve leaves room for the separator and compose bar below, so the
323353 ;; 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?
325359 :scroll-to-bottom @s/jump-tick
326360 :on-change #(reset! s/at-present? (= "end" %))}
327361 (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-row246 (defn message-row
222 "One message. `prev` is the message above it, which decides whether this one247 "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 every264 ;; 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 time265 ;; 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 once266 ;; 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 the352 ;; :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 true354+ ;; 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-tick359 :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 @@
7373 :command (str/upper-case (or (first parts) ""))
7474 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
7575
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+
7686 (defn nick-of
7787 "The nick half of a `nick!user@host` prefix."
7888 [prefix]
@@ -126,9 +136,18 @@
126136 (locking (:lock conn) (swap! (:outbox conn) conj text))
127137 (locking (:lock conn) (write! conn text)))))
128138
139+(declare flush-outbox-tls!)
140+
129141 (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."
131146 [conn]
147+ (when (:outbox conn)
148+ (flush-outbox-tls! conn)))
149+
150+(defn- flush-outbox-tls! [conn]
132151 (let [pending (locking (:lock conn)
133152 (let [q @(:outbox conn)]
134153 (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-of86 (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 @@
114114 The extras are what the message carried beyond its text: `:at` when it was
115115 said, from the server's own `time` tag where there is one, and `:did` who
116116 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."
118119 ([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]}]
120121 (let [at (or at (clock/now-ms))
121122 who (avatars/actor did from)]
122123 (doseq [url (media/image-urls text)]
@@ -133,7 +134,10 @@
133134 {:from from :text text :system? (= "*" from)
134135 :actor who
135136 :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})
137141 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
138142
139143 (defn open-channel!
@@ -176,10 +180,19 @@
176180 ;; backlog is hours or weeks old, and stamping it with
177181 ;; the moment it arrived would say it all happened now.
178182 at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms))
183+ tags (:tags msg)
179184 ;; a DM addressed to us belongs in a buffer named for the
180185 ;; sender, not for our own nick
181186 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"))}))
183196 "JOIN" (let [ch (first params)]
184197 (if (= from @form-nick)
185198 (let [fresh? (empty? (get-in @channels [ch :messages]))]
@@ -471,6 +484,18 @@
471484 ordered))))
472485 (count saved)))
473486
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+
474499 (defn last-preview [buffer]
475500 (if-let [m (last (:messages buffer))]
476501 (str (:from m) ": " (:text m))
@@ -114,9 +114,10 @@
114 The extras are what the message carried beyond its text: `:at` when it was114 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` who115 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 nick116 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 who135 :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 with180 ;; 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 the184 ;; a DM addressed to us belongs in a buffer named for the
180 ;; sender, not for our own nick185 ;; 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))