nandi/frqpublic Fork 0
8d531f8
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.

Name every line, and let the backlog hear about a jump

Two reasons a jump to a line in another room did nothing, both of them this
side of the window.

A line is named by the server's `msgid`, and not every line has one: a
replayed backlog can arrive with no tags at all, and a line this client has
just sent has none until it is echoed back. Those lines are on the screen and
in the overview all the same, and pressing one should go to it — so they get
a name of their own, made out of who said it, what it said, when, and where.
A `:local-id` is never sent: a reply, an edit and a reaction all name a
message to the server, and the server knows only the names it gave out.
Everything that is the client's own business with a line goes through
`rooms/row-id` instead, which prefers the server's name and falls back to
ours. Which room you jumped into decided whether jumping worked at all, and
this is half of why.

The other half is that the backlog was never told. `scroll-here` came from a
cell per row — the same trick that keeps a hover from waking a hundred rows —
but a row's cell was deref'd while the seq of rows was realised, which is
neither the row's render nor the backlog's, so the read was recorded against
nobody. `jump-to` changed, the cells changed, and no component was woken to
put the new answer in the tree: the prop reached the window on the render
that opened the room and never again, so a jump that needed a second frame —
which is every jump into a room that has to be built first — never got one.
The backlog reads the pair itself now and hands them down as values. A jump
is a click; re-rendering a backlog on one is affordable in a way that being
wrong about it is not.

Found with `JOLT_SCROLL_LOG`, which is in jolt-native for the next one:
neither of these was going to be found by reading, and the first fix for the
second one was an accident — a debug line that deref'd `jump-to` in the
backlog's own render, and so subscribed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T23:38:19-07:00 Browse files
8d531f8 parent: 433c3c8
modified common/frq/rooms.cljc +20 -0
@@ -14,6 +14,26 @@
1414 [name]
1515 (and (seq name) (not (str/starts-with? name "#"))))
1616
17+(defn row-id
18+ "What this client calls a line: the server's name for it where there is one,
19+ and the name it was given here where there is not.
20+
21+ freeq tags a message with a `msgid` and that is a line's identity everywhere
22+ it matters — a reply points at one, an edit rewrites one, a reaction lands on
23+ one. But not every line arrives with one: a replayed backlog can come over
24+ with no tags at all, and a line this client has just sent has none until the
25+ server echoes it back.
26+
27+ Those lines are not nameless to the reader, though. They are on the screen,
28+ they are in the overview, and pressing one should go to it — so `frq.state`
29+ gives them a `:local-id` made out of what they are. It is never sent: a
30+ reply, an edit and a reaction all name a message to the server, and the
31+ server knows only the names it gave out. This is for everything that is the
32+ client's own business with a line — which one to scroll to, and which one to
33+ mark when it gets there."
34+ [m]
35+ (or (:id m) (:local-id m)))
36+
1737 (defn last-preview [buffer]
1838 (if-let [m (last (:messages buffer))]
1939 (str (:from m) ": " (:text m))
@@ -14,6 +14,26 @@
14 [name]14 [name]
15 (and (seq name) (not (str/starts-with? name "#"))))15 (and (seq name) (not (str/starts-with? name "#"))))
16 16
17+(defn row-id
18+ "What this client calls a line: the server's name for it where there is one,
19+ and the name it was given here where there is not.
20+
21+ freeq tags a message with a `msgid` and that is a line's identity everywhere
22+ it matters — a reply points at one, an edit rewrites one, a reaction lands on
23+ one. But not every line arrives with one: a replayed backlog can come over
24+ with no tags at all, and a line this client has just sent has none until the
25+ server echoes it back.
26+
27+ Those lines are not nameless to the reader, though. They are on the screen,
28+ they are in the overview, and pressing one should go to it — so `frq.state`
29+ gives them a `:local-id` made out of what they are. It is never sent: a
30+ reply, an edit and a reaction all name a message to the server, and the
31+ server knows only the names it gave out. This is for everything that is the
32+ client's own business with a line — which one to scroll to, and which one to
33+ mark when it gets there."
34+ [m]
35+ (or (:id m) (:local-id m)))
36+
17 (defn last-preview [buffer]37 (defn last-preview [buffer]
18 (if-let [m (last (:messages buffer))]38 (if-let [m (last (:messages buffer))]
19 (str (:from m) ": " (:text m))39 (str (:from m) ": " (:text m))
modified common/frq/screens/chat.cljc +38 -24
@@ -753,16 +753,16 @@
753753 Sender above the text, not beside it: a wrapping label in a horizontal row
754754 lays out against the row's width rather than the column's, so one long URL
755755 drags every line that follows it off the left edge."
756- [i m]
757- (let [;; What a jump landed on wears a surface of its own for a moment, so
756+ [i m jump highlight]
757+ (let [;; What this line is called, and what a jump is aiming at and landed
758+ ;; on. Handed down rather than asked for here — `message-rows` says
759+ ;; why this pair is not two cells like everything else on this row.
760+ ;;
761+ ;; What a jump landed on wears a surface of its own for a moment, so
758762 ;; the answer to "which one was I sent to" is on the screen rather
759763 ;; than in the reader's count of rows.
760- ;; Through `derived`, as is everything below that asks about shared
761- ;; state: a row that read the highlight itself re-rendered for every
762- ;; jump anywhere in the backlog.
763- highlit? (boolean (and (:id m)
764- (cells/derived-value [:highlit (:id m)]
765- #(= (:id m) @cells/highlight))))]
764+ rid (rooms/row-id m)
765+ highlit? (boolean (and rid (= rid highlight)))]
766766 [:vbox {:key i :spacing 2 :margin 0
767767 ;; Clear of the right edge: the actions ride that edge, and the
768768 ;; list's scrollbar rides it too — without this the ↩ is what the
@@ -773,9 +773,10 @@
773773 ;; closed-up line to belong to.
774774 :margin-top 10
775775 ;; The jump target is what a "go to message" click scrolls to.
776- :scroll-here (boolean (and (:id m)
777- (cells/derived-value [:jump (:id m)]
778- #(= (:id m) @cells/jump-to))))}
776+ ;; By `row-id`, not by `:id`: a line the server never named is
777+ ;; still a line on the screen, and a jump aimed at it has to be
778+ ;; able to say which one it means.
779+ :scroll-here (boolean (and rid (= rid jump)))}
779780 ;; The gap above, where the margin cannot be one. `:margin-top 10` is
780781 ;; most of a row in a window and nothing at all in a terminal — ten points
781782 ;; against a row of sixteen, rounded down, because a gap that thin is what
@@ -805,7 +806,24 @@
805806 A backlog can reach back weeks, and `11:04 AM` says nothing about which day
806807 it was. The heading is what makes the time above it mean something."
807808 [messages]
808- (mapcat (fn [i m]
809+ ;; What a jump is aiming at and what it landed on, read here in a render
810+ ;; and handed down as two plain values.
811+ ;;
812+ ;; They were a cell per row, so that a jump woke the two rows it moved
813+ ;; between rather than the whole backlog, which is what `derived-value` is
814+ ;; for and what it still does for a hover. But a row's cell was deref'd
815+ ;; while this seq was realised, which is neither the row's render nor this
816+ ;; one, so the read was recorded against nobody: `jump-to` changed, the cells
817+ ;; changed, and no component was woken to put the new answer in the tree. The
818+ ;; prop reached the window on the render that opened the room and never
819+ ;; again — so a jump that needed a second frame, which is every jump into a
820+ ;; room that has to be built first, never got one.
821+ ;;
822+ ;; A jump is a click. Re-rendering a backlog on one is affordable in a way
823+ ;; that being wrong about it is not.
824+ (let [jump @cells/jump-to
825+ highlight @cells/highlight]
826+ (mapcat (fn [i m]
809827 (let [prev (when (pos? i) (nth messages (dec i)))
810828 day (some-> (:at m) clock/day)
811829 new-day? (and day (not= day (some-> (:at prev) clock/day)))]
@@ -818,10 +836,10 @@
818836 ;; and a line that arrives anywhere but the end — a backlog
819837 ;; replay, an echo taking the place of what was sent — shifts
820838 ;; every row after it onto the widgets of its neighbour.
821- true (conj ^{:key (or (:id m) (str "row-" i))}
822- [message-row i m]))))
823- (range (count messages))
824- messages))
839+ true (conj ^{:key (or (rooms/row-id m) (str "row-" i))}
840+ [message-row i m jump highlight]))))
841+ (range (count messages))
842+ messages)))
825843
826844 (defn- messages-scroll-key
827845 "What the backlog's scroll position is remembered under.
@@ -894,9 +912,8 @@
894912 ;; about, and a jump that landed on the newest message instead would answer
895913 ;; a question nobody asked from a strip that was showing the answer.
896914 ;;
897- ;; Unless the line has no id to aim at one this client has sent and the
898- ;; server has not echoed back yet and then the room is all there is to
899- ;; offer.
915+ ;; Every line has a name to aim at the server's where it gave one, and
916+ ;; `rooms/row-id`'s where it did not so every row here goes somewhere.
900917 ;;
901918 ;; 600ms to land and five seconds marked: the room is a room away, so the
902919 ;; screen it scrolls in has to be built before there is anything to scroll,
@@ -905,11 +922,8 @@
905922 ;; I pressed", and it has to still be there when they have finished
906923 ;; recognising where they are.
907924 [:button {:label (:channel m)
908- :on-click (if (:id m)
909- #(do (actions/leaving-for-overview!)
910- (goto-message! (:channel m) (:id m) 600 5000))
911- #(do (actions/leaving-for-overview!)
912- (actions/open-channel! (:channel m))))}]
925+ :on-click #(do (actions/leaving-for-overview!)
926+ (goto-message! (:channel m) (rooms/row-id m) 600 5000))}]
913927 ;; And the line itself, as something to read rather than to press. It was a
914928 ;; link for a moment, which made the strip two things at once: a line the
915929 ;; server has echoed back has an id to aim at and a line this client has
@@ -753,16 +753,16 @@
753 Sender above the text, not beside it: a wrapping label in a horizontal row753 Sender above the text, not beside it: a wrapping label in a horizontal row
754 lays out against the row's width rather than the column's, so one long URL754 lays out against the row's width rather than the column's, so one long URL
755 drags every line that follows it off the left edge."755 drags every line that follows it off the left edge."
756- [i m]756+ [i m jump highlight]
757- (let [;; What a jump landed on wears a surface of its own for a moment, so757+ (let [;; What this line is called, and what a jump is aiming at and landed
758+ ;; on. Handed down rather than asked for here — `message-rows` says
759+ ;; why this pair is not two cells like everything else on this row.
760+ ;;
761+ ;; What a jump landed on wears a surface of its own for a moment, so
758 ;; the answer to "which one was I sent to" is on the screen rather762 ;; the answer to "which one was I sent to" is on the screen rather
759 ;; than in the reader's count of rows.763 ;; than in the reader's count of rows.
760- ;; Through `derived`, as is everything below that asks about shared764+ rid (rooms/row-id m)
761- ;; state: a row that read the highlight itself re-rendered for every765+ highlit? (boolean (and rid (= rid highlight)))]
762- ;; jump anywhere in the backlog.
763- highlit? (boolean (and (:id m)
764- (cells/derived-value [:highlit (:id m)]
765- #(= (:id m) @cells/highlight))))]
766 [:vbox {:key i :spacing 2 :margin 0766 [:vbox {:key i :spacing 2 :margin 0
767 ;; Clear of the right edge: the actions ride that edge, and the767 ;; Clear of the right edge: the actions ride that edge, and the
768 ;; list's scrollbar rides it too — without this the ↩ is what the768 ;; list's scrollbar rides it too — without this the ↩ is what the
@@ -773,9 +773,10 @@
773 ;; closed-up line to belong to.773 ;; closed-up line to belong to.
774 :margin-top 10774 :margin-top 10
775 ;; The jump target is what a "go to message" click scrolls to.775 ;; The jump target is what a "go to message" click scrolls to.
776- :scroll-here (boolean (and (:id m)776+ ;; By `row-id`, not by `:id`: a line the server never named is
777- (cells/derived-value [:jump (:id m)]777+ ;; still a line on the screen, and a jump aimed at it has to be
778- #(= (:id m) @cells/jump-to))))}778+ ;; able to say which one it means.
779+ :scroll-here (boolean (and rid (= rid jump)))}
779 ;; The gap above, where the margin cannot be one. `:margin-top 10` is780 ;; The gap above, where the margin cannot be one. `:margin-top 10` is
780 ;; most of a row in a window and nothing at all in a terminal — ten points781 ;; most of a row in a window and nothing at all in a terminal — ten points
781 ;; against a row of sixteen, rounded down, because a gap that thin is what782 ;; against a row of sixteen, rounded down, because a gap that thin is what
@@ -805,7 +806,24 @@
805 A backlog can reach back weeks, and `11:04 AM` says nothing about which day806 A backlog can reach back weeks, and `11:04 AM` says nothing about which day
806 it was. The heading is what makes the time above it mean something."807 it was. The heading is what makes the time above it mean something."
807 [messages]808 [messages]
808- (mapcat (fn [i m]809+ ;; What a jump is aiming at and what it landed on, read here in a render
810+ ;; and handed down as two plain values.
811+ ;;
812+ ;; They were a cell per row, so that a jump woke the two rows it moved
813+ ;; between rather than the whole backlog, which is what `derived-value` is
814+ ;; for and what it still does for a hover. But a row's cell was deref'd
815+ ;; while this seq was realised, which is neither the row's render nor this
816+ ;; one, so the read was recorded against nobody: `jump-to` changed, the cells
817+ ;; changed, and no component was woken to put the new answer in the tree. The
818+ ;; prop reached the window on the render that opened the room and never
819+ ;; again — so a jump that needed a second frame, which is every jump into a
820+ ;; room that has to be built first, never got one.
821+ ;;
822+ ;; A jump is a click. Re-rendering a backlog on one is affordable in a way
823+ ;; that being wrong about it is not.
824+ (let [jump @cells/jump-to
825+ highlight @cells/highlight]
826+ (mapcat (fn [i m]
809 (let [prev (when (pos? i) (nth messages (dec i)))827 (let [prev (when (pos? i) (nth messages (dec i)))
810 day (some-> (:at m) clock/day)828 day (some-> (:at m) clock/day)
811 new-day? (and day (not= day (some-> (:at prev) clock/day)))]829 new-day? (and day (not= day (some-> (:at prev) clock/day)))]
@@ -818,10 +836,10 @@
818 ;; and a line that arrives anywhere but the end — a backlog836 ;; and a line that arrives anywhere but the end — a backlog
819 ;; replay, an echo taking the place of what was sent — shifts837 ;; replay, an echo taking the place of what was sent — shifts
820 ;; every row after it onto the widgets of its neighbour.838 ;; every row after it onto the widgets of its neighbour.
821- true (conj ^{:key (or (:id m) (str "row-" i))}839+ true (conj ^{:key (or (rooms/row-id m) (str "row-" i))}
822- [message-row i m]))))840+ [message-row i m jump highlight]))))
823- (range (count messages))841+ (range (count messages))
824- messages))842+ messages)))
825 843
826 (defn- messages-scroll-key844 (defn- messages-scroll-key
827 "What the backlog's scroll position is remembered under.845 "What the backlog's scroll position is remembered under.
@@ -894,9 +912,8 @@
894 ;; about, and a jump that landed on the newest message instead would answer912 ;; about, and a jump that landed on the newest message instead would answer
895 ;; a question nobody asked from a strip that was showing the answer.913 ;; a question nobody asked from a strip that was showing the answer.
896 ;;914 ;;
897- ;; Unless the line has no id to aim at one this client has sent and the915+ ;; Every line has a name to aim at the server's where it gave one, and
898- ;; server has not echoed back yet and then the room is all there is to916+ ;; `rooms/row-id`'s where it did not so every row here goes somewhere.
899- ;; offer.
900 ;;917 ;;
901 ;; 600ms to land and five seconds marked: the room is a room away, so the918 ;; 600ms to land and five seconds marked: the room is a room away, so the
902 ;; screen it scrolls in has to be built before there is anything to scroll,919 ;; screen it scrolls in has to be built before there is anything to scroll,
@@ -905,11 +922,8 @@
905 ;; I pressed", and it has to still be there when they have finished922 ;; I pressed", and it has to still be there when they have finished
906 ;; recognising where they are.923 ;; recognising where they are.
907 [:button {:label (:channel m)924 [:button {:label (:channel m)
908- :on-click (if (:id m)925+ :on-click #(do (actions/leaving-for-overview!)
909- #(do (actions/leaving-for-overview!)926+ (goto-message! (:channel m) (rooms/row-id m) 600 5000))}]
910- (goto-message! (:channel m) (:id m) 600 5000))
911- #(do (actions/leaving-for-overview!)
912- (actions/open-channel! (:channel m))))}]
913 ;; And the line itself, as something to read rather than to press. It was a927 ;; And the line itself, as something to read rather than to press. It was a
914 ;; link for a moment, which made the strip two things at once: a line the928 ;; link for a moment, which made the strip two things at once: a line the
915 ;; server has echoed back has an id to aim at and a line this client has929 ;; server has echoed back has an id to aim at and a line this client has
modified src/frq/state.clj +28 -3
@@ -344,6 +344,26 @@
344344 chat view re-renders without every message row watching the media cache."}
345345 media-tick (atom 0))
346346
347+(defn local-id
348+ "A name for a line the server did not name.
349+
350+ freeq tags a message with a `msgid` and that is a line's identity everywhere
351+ it matters a reply points at one, an edit rewrites one, a reaction lands on
352+ one. But not every line arrives with one: a replayed backlog can come over
353+ with no tags at all, and a line this client has just sent has none until the
354+ server echoes it back.
355+
356+ Those lines are not nameless to the reader, though. They are on the screen,
357+ they are in the overview, and pressing one should go to it. So they get a
358+ name made out of what they are: who said it, what it said, when, and where.
359+ Two lines identical in all four are the same line as far as anything this
360+ client does with one is concerned.
361+
362+ `local-` because it is this client's alone, and it is never sent: the
363+ server knows only the names it gave out."
364+ [channel from text at]
365+ (str "local-" (hash [channel from text at])))
366+
347367 (defn push-message!
348368 "Append a line to a buffer, creating it if needed, and bump the unread count
349369 unless that buffer is the one on screen. Any image it links to is fetched in
@@ -358,6 +378,8 @@
358378 ([channel from text] (push-message! channel from text {}))
359379 ([channel from text {:keys [at did id reply-to reactions edited?]}]
360380 (let [at (or at (clock/now-ms))
381+ ;; A name of our own where the server gave none. See `local-id`.
382+ mine (when-not id (local-id channel from text at))
361383 who (avatars/actor did from)
362384 ;; A room reaching the store matters more than the throttle does: a
363385 ;; connection joins every channel at once, and the writes for all but
@@ -430,7 +452,7 @@
430452 :at at
431453 ;; `:id` is what a reply points at, and
432454 ;; `:reply-to` is what this one points at.
433- :id id :reply-to reply-to
455+ :id id :local-id mine :reply-to reply-to
434456 ;; The sender has since rewritten this line.
435457 ;; Replay says so with a tag rather than by
436458 ;; sending the revision, so a message can
@@ -1851,10 +1873,13 @@
18511873 (count saved)))
18521874
18531875 (defn message-by-id
1854- "The message a reply points at, if this buffer still holds it."
1876+ "The message a reply points at, if this buffer still holds it.
1877+
1878+ By either name, since a jump may be aiming at a line the server never gave
1879+ one to. See `local-id`."
18551880 [channel id]
18561881 (when id
1857- (first (filter #(= id (:id %)) (get-in @channels [channel :messages])))))
1882+ (first (filter #(= id (rooms/row-id %)) (get-in @channels [channel :messages])))))
18581883
18591884 (defn react-from-picker!
18601885 "Put the chosen emoji on the message the picker was opened for, and close it.
@@ -344,6 +344,26 @@
344 chat view re-renders without every message row watching the media cache."}344 chat view re-renders without every message row watching the media cache."}
345 media-tick (atom 0))345 media-tick (atom 0))
346 346
347+(defn local-id
348+ "A name for a line the server did not name.
349+
350+ freeq tags a message with a `msgid` and that is a line's identity everywhere
351+ it matters a reply points at one, an edit rewrites one, a reaction lands on
352+ one. But not every line arrives with one: a replayed backlog can come over
353+ with no tags at all, and a line this client has just sent has none until the
354+ server echoes it back.
355+
356+ Those lines are not nameless to the reader, though. They are on the screen,
357+ they are in the overview, and pressing one should go to it. So they get a
358+ name made out of what they are: who said it, what it said, when, and where.
359+ Two lines identical in all four are the same line as far as anything this
360+ client does with one is concerned.
361+
362+ `local-` because it is this client's alone, and it is never sent: the
363+ server knows only the names it gave out."
364+ [channel from text at]
365+ (str "local-" (hash [channel from text at])))
366+
347 (defn push-message!367 (defn push-message!
348 "Append a line to a buffer, creating it if needed, and bump the unread count368 "Append a line to a buffer, creating it if needed, and bump the unread count
349 unless that buffer is the one on screen. Any image it links to is fetched in369 unless that buffer is the one on screen. Any image it links to is fetched in
@@ -358,6 +378,8 @@
358 ([channel from text] (push-message! channel from text {}))378 ([channel from text] (push-message! channel from text {}))
359 ([channel from text {:keys [at did id reply-to reactions edited?]}]379 ([channel from text {:keys [at did id reply-to reactions edited?]}]
360 (let [at (or at (clock/now-ms))380 (let [at (or at (clock/now-ms))
381+ ;; A name of our own where the server gave none. See `local-id`.
382+ mine (when-not id (local-id channel from text at))
361 who (avatars/actor did from)383 who (avatars/actor did from)
362 ;; A room reaching the store matters more than the throttle does: a384 ;; A room reaching the store matters more than the throttle does: a
363 ;; connection joins every channel at once, and the writes for all but385 ;; connection joins every channel at once, and the writes for all but
@@ -430,7 +452,7 @@
430 :at at452 :at at
431 ;; `:id` is what a reply points at, and453 ;; `:id` is what a reply points at, and
432 ;; `:reply-to` is what this one points at.454 ;; `:reply-to` is what this one points at.
433- :id id :reply-to reply-to455+ :id id :local-id mine :reply-to reply-to
434 ;; The sender has since rewritten this line.456 ;; The sender has since rewritten this line.
435 ;; Replay says so with a tag rather than by457 ;; Replay says so with a tag rather than by
436 ;; sending the revision, so a message can458 ;; sending the revision, so a message can
@@ -1851,10 +1873,13 @@
1851 (count saved)))1873 (count saved)))
1852 1874
1853 (defn message-by-id1875 (defn message-by-id
1854- "The message a reply points at, if this buffer still holds it."1876+ "The message a reply points at, if this buffer still holds it.
1877+
1878+ By either name, since a jump may be aiming at a line the server never gave
1879+ one to. See `local-id`."
1855 [channel id]1880 [channel id]
1856 (when id1881 (when id
1857- (first (filter #(= id (:id %)) (get-in @channels [channel :messages])))))1882+ (first (filter #(= id (rooms/row-id %)) (get-in @channels [channel :messages])))))
1858 1883
1859 (defn react-from-picker!1884 (defn react-from-picker!
1860 "Put the chosen emoji on the message the picker was opened for, and close it.1885 "Put the chosen emoji on the message the picker was opened for, and close it.