Open a conversation at its newest line
The backlog's scroll was remembered under one name for every room, so the viewport carried where you were in the last conversation into the next one: read back through yesterday in one channel, open another, and the second opened in the middle of its backlog. Flutter feels it hardest, because switching rooms there rebuilds the tree into the same shape — Flutter keeps the State, and the offset in it, while the backlog underneath becomes another conversation entirely. So the key carries the room. A conversation with nothing saved under its name opens at the end, which is what `end-ward!` already did for a viewport it had never seen, and the jump button's answer comes right for free: a new key has no remembered reply, so `watch-end!` says "end" on the first frame. The Flutter half needs one more thing, since its controller outlives what it is showing: the key that controller is serving, kept in an atom beside it. A widget handed a new key is holding an offset from a room the reader has left, and that offset cannot be asked whether they are at the end of this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
35f0e9b parent: 131c6db modified
common/frq/screens/chat.cljc +22 -15 | @@ -907,22 +907,29 @@ | ||
| 907 | 907 | (defn- messages-scroll-key |
| 908 | 908 | "What the backlog's scroll position is remembered under. |
| 909 | 909 | |
| 910 | - One name in a window: `:scroll-to-bottom` is how the jump button is answered | |
| 911 | - there, and the position under that name is the one the reader left behind. | |
| 912 | - | |
| 913 | - The terminal backend has no `:scroll-to-bottom` — a viewport there is moved | |
| 914 | - by the wheel and the page keys and by nothing else — but it does open a | |
| 915 | - sticky viewport it has never seen at the bottom, which is the same thing | |
| 916 | - said differently. So a jump renames the viewport: the tick that asks the | |
| 917 | - window to scroll gives the terminal a name with no position saved under it, | |
| 918 | - and the newest line is what it opens on. | |
| 919 | - | |
| 920 | - Only on a jump, so scrolling and every message that arrives between two | |
| 921 | - jumps still find the position where they left it." | |
| 910 | + A name per room, because one name for every conversation is a viewport | |
| 911 | + that carries where you were in the last room into the next one: read back | |
| 912 | + through yesterday in one channel, open another, and the second opens in | |
| 913 | + the middle of its backlog rather than at the newest line. A conversation | |
| 914 | + you have never opened has nothing saved under its name, and a viewport | |
| 915 | + with nothing saved opens at the end — which is what opening a room should | |
| 916 | + do, and now does without anyone having to ask for it. | |
| 917 | + | |
| 918 | + The tick as well, for the terminal. That backend has no | |
| 919 | + `:scroll-to-bottom` — a viewport there is moved by the wheel and the page | |
| 920 | + keys and by nothing else — but it does open a sticky viewport it has never | |
| 921 | + seen at the bottom, which is the same thing said differently. So a jump | |
| 922 | + renames the viewport: the tick that asks the window to scroll gives the | |
| 923 | + terminal a name with no position saved under it, and the newest line is | |
| 924 | + what it opens on. | |
| 925 | + | |
| 926 | + Only on a jump and on a change of room, so scrolling and every message that | |
| 927 | + arrives between two jumps still find the position where they left it." | |
| 922 | 928 | [] |
| 923 | - (if @terminal? | |
| 924 | - (str "chat-messages-" @cells/jump-tick) | |
| 925 | - "chat-messages")) | |
| 929 | + (let [room (str @cells/current)] | |
| 930 | + (if @terminal? | |
| 931 | + (str "chat-messages-" @cells/jump-tick "-" room) | |
| 932 | + (str "chat-messages-" room)))) | |
| 926 | 933 | |
| 927 | 934 | (def sidebar-width 320) |
| 928 | 935 | |
| @@ -907,22 +907,29 @@ | |||
| 907 | (defn- messages-scroll-key | 907 | (defn- messages-scroll-key |
| 908 | "What the backlog's scroll position is remembered under. | 908 | "What the backlog's scroll position is remembered under. |
| 909 | 909 | ||
| 910 | - One name in a window: `:scroll-to-bottom` is how the jump button is answered | 910 | + A name per room, because one name for every conversation is a viewport |
| 911 | - there, and the position under that name is the one the reader left behind. | 911 | + that carries where you were in the last room into the next one: read back |
| 912 | - | 912 | + through yesterday in one channel, open another, and the second opens in |
| 913 | - The terminal backend has no `:scroll-to-bottom` — a viewport there is moved | 913 | + the middle of its backlog rather than at the newest line. A conversation |
| 914 | - by the wheel and the page keys and by nothing else — but it does open a | 914 | + you have never opened has nothing saved under its name, and a viewport |
| 915 | - sticky viewport it has never seen at the bottom, which is the same thing | 915 | + with nothing saved opens at the end — which is what opening a room should |
| 916 | - said differently. So a jump renames the viewport: the tick that asks the | 916 | + do, and now does without anyone having to ask for it. |
| 917 | - window to scroll gives the terminal a name with no position saved under it, | 917 | + |
| 918 | - and the newest line is what it opens on. | 918 | + The tick as well, for the terminal. That backend has no |
| 919 | - | 919 | + `:scroll-to-bottom` — a viewport there is moved by the wheel and the page |
| 920 | - Only on a jump, so scrolling and every message that arrives between two | 920 | + keys and by nothing else — but it does open a sticky viewport it has never |
| 921 | - jumps still find the position where they left it." | 921 | + seen at the bottom, which is the same thing said differently. So a jump |
| 922 | + renames the viewport: the tick that asks the window to scroll gives the | ||
| 923 | + terminal a name with no position saved under it, and the newest line is | ||
| 924 | + what it opens on. | ||
| 925 | + | ||
| 926 | + Only on a jump and on a change of room, so scrolling and every message that | ||
| 927 | + arrives between two jumps still find the position where they left it." | ||
| 922 | [] | 928 | [] |
| 923 | - (if @terminal? | 929 | + (let [room (str @cells/current)] |
| 924 | - (str "chat-messages-" @cells/jump-tick) | 930 | + (if @terminal? |
| 925 | - "chat-messages")) | 931 | + (str "chat-messages-" @cells/jump-tick "-" room) |
| 932 | + (str "chat-messages-" room)))) | ||
| 926 | 933 | ||
| 927 | (def sidebar-width 320) | 934 | (def sidebar-width 320) |
| 928 | 935 | ||
modified
flutter/src/frq/hiccup.cljd +19 -4 | @@ -253,8 +253,17 @@ | ||
| 253 | 253 | |
| 254 | 254 | After the frame, because the extent being scrolled to is the height of |
| 255 | 255 | content that has not been laid out yet at the point this is called." |
| 256 | - [k ^m/ScrollController ctrl token] | |
| 257 | - (let [mark (get @scroll-marks k ::fresh) | |
| 256 | + [k ^m/ScrollController ctrl last-key token] | |
| 257 | + (let [;; A widget handed a new key is showing something it has never | |
| 258 | + ;; shown — switching rooms rebuilds the tree into the same shape, so | |
| 259 | + ;; Flutter keeps the State and the offset in it while the backlog | |
| 260 | + ;; underneath becomes another conversation. Whatever this key was | |
| 261 | + ;; told last time, the offset the controller is holding is where the | |
| 262 | + ;; reader was in the room they have just left, so the answer to "are | |
| 263 | + ;; they at the end" is not in it: treat the pair as new. | |
| 264 | + moved (not= k @last-key) | |
| 265 | + _ (reset! last-key k) | |
| 266 | + mark (if moved ::fresh (get @scroll-marks k ::fresh)) | |
| 258 | 267 | ;; Not hinted here: a controller with no clients has no position, so |
| 259 | 268 | ;; this is nil as often as not, and a non-nullable hint on the binding |
| 260 | 269 | ;; is a cast that fails before `(nil? pos)` below ever gets to run. |
| @@ -997,13 +1006,19 @@ | ||
| 997 | 1006 | ;; two live scroll views briefly shared it and it asserted — |
| 998 | 1007 | ;; "ScrollController attached to multiple scroll views", which took |
| 999 | 1008 | ;; the whole screen. |
| 1000 | - :managed [ctrl (m/ScrollController)] | |
| 1009 | + ;; | |
| 1010 | + ;; And an atom beside it for the key that controller is serving — | |
| 1011 | + ;; a fact about this widget and not about the key, which is the | |
| 1012 | + ;; whole point of it: see `end-ward!`. `:dispose nil` because an | |
| 1013 | + ;; atom has no resource to give back. | |
| 1014 | + :managed [ctrl (m/ScrollController) | |
| 1015 | + last-key (atom nil) :dispose nil] | |
| 1001 | 1016 | ;; `here-ward!` after `end-ward!`, so the callback that aims at a |
| 1002 | 1017 | ;; line is queued behind the one that aims at the end: the end's |
| 1003 | 1018 | ;; runs first, sees the pending jump and stands down, and then this |
| 1004 | 1019 | ;; one clears it. |
| 1005 | 1020 | :let [_ (watch-end! k ctrl on-change) |
| 1006 | - _ (end-ward! k ctrl token) | |
| 1021 | + _ (end-ward! k ctrl last-key token) | |
| 1007 | 1022 | _ (here-ward!)] |
| 1008 | 1023 | (m/SingleChildScrollView |
| 1009 | 1024 | .controller ctrl |
| @@ -253,8 +253,17 @@ | |||
| 253 | 253 | ||
| 254 | After the frame, because the extent being scrolled to is the height of | 254 | After the frame, because the extent being scrolled to is the height of |
| 255 | content that has not been laid out yet at the point this is called." | 255 | content that has not been laid out yet at the point this is called." |
| 256 | - [k ^m/ScrollController ctrl token] | 256 | + [k ^m/ScrollController ctrl last-key token] |
| 257 | - (let [mark (get @scroll-marks k ::fresh) | 257 | + (let [;; A widget handed a new key is showing something it has never |
| 258 | + ;; shown — switching rooms rebuilds the tree into the same shape, so | ||
| 259 | + ;; Flutter keeps the State and the offset in it while the backlog | ||
| 260 | + ;; underneath becomes another conversation. Whatever this key was | ||
| 261 | + ;; told last time, the offset the controller is holding is where the | ||
| 262 | + ;; reader was in the room they have just left, so the answer to "are | ||
| 263 | + ;; they at the end" is not in it: treat the pair as new. | ||
| 264 | + moved (not= k @last-key) | ||
| 265 | + _ (reset! last-key k) | ||
| 266 | + mark (if moved ::fresh (get @scroll-marks k ::fresh)) | ||
| 258 | ;; Not hinted here: a controller with no clients has no position, so | 267 | ;; Not hinted here: a controller with no clients has no position, so |
| 259 | ;; this is nil as often as not, and a non-nullable hint on the binding | 268 | ;; this is nil as often as not, and a non-nullable hint on the binding |
| 260 | ;; is a cast that fails before `(nil? pos)` below ever gets to run. | 269 | ;; is a cast that fails before `(nil? pos)` below ever gets to run. |
| @@ -997,13 +1006,19 @@ | |||
| 997 | ;; two live scroll views briefly shared it and it asserted — | 1006 | ;; two live scroll views briefly shared it and it asserted — |
| 998 | ;; "ScrollController attached to multiple scroll views", which took | 1007 | ;; "ScrollController attached to multiple scroll views", which took |
| 999 | ;; the whole screen. | 1008 | ;; the whole screen. |
| 1000 | - :managed [ctrl (m/ScrollController)] | 1009 | + ;; |
| 1010 | + ;; And an atom beside it for the key that controller is serving — | ||
| 1011 | + ;; a fact about this widget and not about the key, which is the | ||
| 1012 | + ;; whole point of it: see `end-ward!`. `:dispose nil` because an | ||
| 1013 | + ;; atom has no resource to give back. | ||
| 1014 | + :managed [ctrl (m/ScrollController) | ||
| 1015 | + last-key (atom nil) :dispose nil] | ||
| 1001 | ;; `here-ward!` after `end-ward!`, so the callback that aims at a | 1016 | ;; `here-ward!` after `end-ward!`, so the callback that aims at a |
| 1002 | ;; line is queued behind the one that aims at the end: the end's | 1017 | ;; line is queued behind the one that aims at the end: the end's |
| 1003 | ;; runs first, sees the pending jump and stands down, and then this | 1018 | ;; runs first, sees the pending jump and stands down, and then this |
| 1004 | ;; one clears it. | 1019 | ;; one clears it. |
| 1005 | :let [_ (watch-end! k ctrl on-change) | 1020 | :let [_ (watch-end! k ctrl on-change) |
| 1006 | - _ (end-ward! k ctrl token) | 1021 | + _ (end-ward! k ctrl last-key token) |
| 1007 | _ (here-ward!)] | 1022 | _ (here-ward!)] |
| 1008 | (m/SingleChildScrollView | 1023 | (m/SingleChildScrollView |
| 1009 | .controller ctrl | 1024 | .controller ctrl |
modified
src/frq/state.clj +6 -4 | @@ -113,10 +113,12 @@ | ||
| 113 | 113 | (defn overview-back! |
| 114 | 114 | "Back to the room the strip took you out of. |
| 115 | 115 | |
| 116 | - The room, and not the place in it: the backlog's scroll is remembered under | |
| 117 | - one name for every conversation — see `messages-scroll-key` — so what comes | |
| 118 | - back is the room and whatever that one viewport is currently showing of it. | |
| 119 | - A place of your own in every room is a bigger change than this button." | |
| 116 | + The room, and with it the place in it: the backlog's scroll is remembered | |
| 117 | + under a name per conversation — see `messages-scroll-key` — so a backend | |
| 118 | + that keeps a position per name lands back where the strip found you. One | |
| 119 | + that only knows whether a viewport is new to it, as the Flutter side does, | |
| 120 | + brings you back to the newest line instead; both beat the middle of the | |
| 121 | + last room you were in, which is what one name for every room gave." | |
| 120 | 122 | [] |
| 121 | 123 | (when-let [room @overview-return] |
| 122 | 124 | (reset! overview-return nil) |
| @@ -113,10 +113,12 @@ | |||
| 113 | (defn overview-back! | 113 | (defn overview-back! |
| 114 | "Back to the room the strip took you out of. | 114 | "Back to the room the strip took you out of. |
| 115 | 115 | ||
| 116 | - The room, and not the place in it: the backlog's scroll is remembered under | 116 | + The room, and with it the place in it: the backlog's scroll is remembered |
| 117 | - one name for every conversation — see `messages-scroll-key` — so what comes | 117 | + under a name per conversation — see `messages-scroll-key` — so a backend |
| 118 | - back is the room and whatever that one viewport is currently showing of it. | 118 | + that keeps a position per name lands back where the strip found you. One |
| 119 | - A place of your own in every room is a bigger change than this button." | 119 | + that only knows whether a viewport is new to it, as the Flutter side does, |
| 120 | + brings you back to the newest line instead; both beat the middle of the | ||
| 121 | + last room you were in, which is what one name for every room gave." | ||
| 120 | [] | 122 | [] |
| 121 | (when-let [room @overview-return] | 123 | (when-let [room @overview-return] |
| 122 | (reset! overview-return nil) | 124 | (reset! overview-return nil) |