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

Share the room list and the read markers with the phone

`rooms.edn` was ported to the Flutter side a commit ago by writing a second
copy of the desktop's room-list code beside it — a private `room-records`,
`remember-rooms!` and `restore-rooms!` in `frq.main`, each the same shape as
the one in `frq.state`. Two copies of a thing both backends need is what
`common/` is for, so they move there: `frq.rooms` now holds the list and both
halves call it.

Under that was the reason the copy could not simply be the same function. The
marker machinery — `after-marker`, `recount`, `mark-read`, `ensure-channel`
— was jolt-only, so the phone had no `:unread` to write and no way to read
one back. Its `room-records` compensated with a `room-extras` map: the fields
it did not understand, read off the file and merged back on every write, so a
desktop run's marker survived a phone run rather than being silently dropped
and handing the reader their whole history as new.

With the marker shared there is nothing left to preserve blindly. The phone
counts its own unread now: messages carry `:at` off the `time` tag, a line
arriving in the room on screen moves the marker past itself and one arriving
anywhere else is counted against the marker there, and opening a room marks
it read. `:at` also lights up the clock times and day separators in
`frq.screens.chat`, which had been rendering nothing for want of it.

What stays on this side is what is genuinely this side's: `room-list-owned?`
and the adoption rule at 001, because the strict "part what the file does not
hold" answer to freeq announcing memberships that are not real is newer than
anything the desktop does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T07:52:54-07:00 Browse files
f751b7d parent: 23b7999
modified common/frq/rooms.cljc +201 -1
@@ -7,7 +7,9 @@
77 the overview strip's list of what is happening in every other room. `frq.state`
88 re-defs them all, so the thousand lines below them did not move."
99 (:require [clojure.string :as str]
10- [frq.cells :as cells]))
10+ [frq.cells :as cells]
11+ [frq.clock :as clock]
12+ [frq.store :as store]))
1113
1214 (defn dm?
1315 "Whether a buffer is a conversation with a person rather than a room. Every
@@ -126,3 +128,201 @@
126128 ;; last. The turn-taking above is about which lines are in it, not
127129 ;; about where they sit.
128130 (sort-by #(or (:at %) 0) >)))
131+
132+
133+;; ------------------------------------------------------------- the marker
134+;; What has been seen, and what the unread count is derived from. Shared for
135+;; the reason the list is: a marker that only one half keeps is a phone that
136+;; comes back to a hundred lines it has already read.
137+
138+(defn after-marker
139+ "The messages in `buffer` the reader has not seen: everything after its read
140+ marker.
141+
142+ By id where the marked message is still held, and by time otherwise. The id
143+ is the exact answer a msgid survives every revision, so it names the same
144+ line however often the server replays it and the timestamp is what answers
145+ when the marked line has fallen off the end of the buffer or was never in
146+ this run's copy of it.
147+
148+ Derived rather than counted, because a count cannot survive what the server
149+ does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
150+ line of it would tick a counter a second time. Against a marker a replayed
151+ line is simply older than it and counts for nothing."
152+ [buffer]
153+ (let [id (:last-read-id buffer)
154+ at (:last-read-at buffer 0)
155+ msgs (vec (:messages buffer))]
156+ (if (and id (some #(= id (:id %)) msgs))
157+ (vec (rest (drop-while #(not= id (:id %)) msgs)))
158+ (filterv #(> (:at % 0) at) msgs))))
159+
160+(defn mentions-me?
161+ "Whether a line is addressed at the reader by name. Our own lines do not
162+ count saying your own nick is not being called."
163+ [m]
164+ (let [me (str/trim (or @cells/form-nick ""))]
165+ (and (seq me)
166+ (not= (:from m) me)
167+ (str/includes? (str/lower-case (or (:text m) ""))
168+ (str/lower-case me)))))
169+
170+(defn recount
171+ "Answer what the marker says: how many lines are unseen, and whether any of
172+ them names the reader."
173+ [buffer]
174+ ;; Joins, parts, quits and "Joined #room" are the room talking about itself,
175+ ;; not somebody talking in it. They arrive stamped now the join notice is
176+ ;; written the moment we are in so counted, every room you are a member of
177+ ;; sits at one unread from the moment it opens, saying only that you joined
178+ ;; it. The marker still moves past them: they are read, they are just never
179+ ;; what made a room worth looking at.
180+ (let [fresh (remove :system? (after-marker buffer))]
181+ (assoc buffer
182+ :unread (count fresh)
183+ :mention? (boolean (some mentions-me? fresh)))))
184+
185+(defn mark-read
186+ "Move the marker to the newest line this buffer holds. Both halves: the id
187+ for as long as that line is here, and its time for after it is gone.
188+
189+ The time only ever goes forward. A backlog can arrive after the reader has
190+ already read past it, and taking the last line's time unconditionally would
191+ walk the marker backwards and re-unread what was read."
192+ [buffer]
193+ (let [newest (last (:messages buffer))]
194+ (assoc buffer
195+ :unread 0
196+ :mention? false
197+ :last-read-id (:id newest)
198+ :last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
199+
200+;; How far back a room nobody has seen before counts as already read.
201+;;
202+;; A room being joined for the first time replays its whole history, and none
203+;; of that is news the reader was not away for it, they were not here. So a
204+;; new buffer starts caught up rather than at the beginning, or joining a busy
205+;; channel announces a hundred unread posts from before you arrived.
206+;;
207+;; Caught up to a minute ago rather than to this instant, because a live line
208+;; is timestamped by the server and this by our clock: the two disagree by
209+;; whatever the skew is, and a live message stamped a few seconds behind us
210+;; would land under the marker and never be counted. A minute is more skew than
211+;; there will be and far less than the age of any backlog, and what it costs is
212+;; that a message sent in the minute before you joined counts as unread which
213+;; is the harmless direction.
214+(def fresh-room-grace-ms 60000)
215+
216+(defn ensure-channel [m name]
217+ (if (contains? m name)
218+ m
219+ (assoc m name {:name name :messages [] :unread 0
220+ :joined? false :joining? false :accessed 0
221+ ;; What has been seen, and what the count is derived from.
222+ ;; `:unread` and `:mention?` are answers, not records see
223+ ;; `recount`.
224+ :last-read-id nil :mention? false
225+ :last-read-at (max 0 (- (clock/now-ms) fresh-room-grace-ms))
226+ :kind (if (dm? name) :dm :channel)
227+ :peer-did nil :last-activity 0
228+ ;; nick -> mode prefix, for the people panel
229+ :users {}})))
230+
231+;; ---------------------------------------------------------------- the list
232+;; A counter rather than a clock: the list only needs their order, and a
233+;; monotonic tick cannot be surprised by the system time moving.
234+(defonce access-tick (atom 0))
235+
236+(defn room-records
237+ "The rooms as they go to disk: what each one is, when it last said anything,
238+ and how far into it the reader has got.
239+
240+ Every room, not only the ones that have been opened. Being in a channel is
241+ what makes it yours; opening it only says which you looked at last, and that
242+ is what `:accessed` orders them by. Writing down the opened ones alone is how
243+ a client in a dozen channels came back knowing one the rest were left for
244+ the server to remember, which is the thing it does not do.
245+
246+ `:unread` and `:mention?` are not written. They are what the marker adds up
247+ to against the messages in hand, and a count written down is a count that can
248+ be wrong the marker cannot be. `:mention?` rides along all the same, as the
249+ one thing that cannot be recomputed before the history it was derived from
250+ comes back: a room that had your name in it says so on the next run's first
251+ frame rather than a round trip later, and is corrected by `recount` the
252+ moment the backlog lands."
253+ []
254+ (->> (vals @cells/channels)
255+ (sort-by #(- (:accessed % 0)))
256+ (mapv #(select-keys % [:name :kind :peer-did :last-activity
257+ :last-read-id :last-read-at :mention?]))))
258+
259+(defonce ^:private rooms-saved-at (atom 0))
260+
261+(defn remember-rooms!
262+ "Write the room records out, most recently used first: what rooms there are,
263+ in the order they were last used, and how much of each has been read.
264+
265+ Throttled, because the marker moves on every line that arrives while a room
266+ is on screen and a busy channel would otherwise write the file per message.
267+ `force?` is for the moments worth paying for, which is a room being opened,
268+ joined or closed.
269+
270+ A late write costs at most the handful of lines that arrived since the last
271+ one, shown unread again on the next run. That is the right way round: the
272+ marker never claims to have read more than it has.
273+
274+ Shared because both halves have the same thing worth keeping and the same
275+ moments worth keeping it at. The desktop puts this on a thread of its own as
276+ well a jolt answer to a jolt cost, and why `frq.state` keeps its own
277+ wrapper around `save-rooms!` rather than calling this."
278+ ([] (remember-rooms! false))
279+ ([force?]
280+ (let [now (clock/now-ms)]
281+ (when (or force? (> (- now @rooms-saved-at) 5000))
282+ (reset! rooms-saved-at now)
283+ (store/save-rooms! (room-records))))))
284+
285+(defn restore-channels!
286+ "Bring back the rooms of earlier runs, in the order they were last used, each
287+ with the marker saying how much of it had been read.
288+
289+ Empty buffers, not memberships: opening one is what joins it, and a list of
290+ rooms is the part worth keeping the messages in them come from the server.
291+ The tick is seeded so this run's first open still sorts above all of them.
292+
293+ The marker is what makes the returning backlog readable. Without one every
294+ replayed line is new and every room comes back with its whole history
295+ unread; with one, the reader is put back where they were and only what
296+ arrived while they were away is counted. A room migrated from an older frq
297+ has no marker and is caught up as if read, which is the kinder of the two
298+ wrong answers the alternative announces a hundred unread lines the reader
299+ has already seen."
300+ []
301+ (when-let [saved (seq (store/load-rooms))]
302+ (let [ordered (reverse saved)] ; oldest first, so ticks ascend
303+ (swap! cells/channels
304+ (fn [m]
305+ (reduce (fn [acc room]
306+ (let [name (:name room)]
307+ (if (contains? acc name)
308+ acc
309+ (assoc acc name
310+ {:name name :messages [] :unread 0
311+ :joined? false :joining? false
312+ :users {}
313+ :kind (or (:kind room)
314+ (if (dm? name) :dm :channel))
315+ :peer-did (:peer-did room)
316+ :last-activity (:last-activity room 0)
317+ :last-read-id (:last-read-id room)
318+ ;; No marker at all an older frq's list,
319+ ;; or a record that lost it. Read up to
320+ ;; now rather than back to the beginning.
321+ :last-read-at (:last-read-at room
322+ (clock/now-ms))
323+ :mention? (boolean (:mention? room))
324+ :accessed (swap! access-tick inc)})))
325+ )
326+ m
327+ ordered))))
328+ (count saved)))
@@ -7,7 +7,9 @@
7 the overview strip's list of what is happening in every other room. `frq.state`7 the overview strip's list of what is happening in every other room. `frq.state`
8 re-defs them all, so the thousand lines below them did not move."8 re-defs them all, so the thousand lines below them did not move."
9 (:require [clojure.string :as str]9 (:require [clojure.string :as str]
10- [frq.cells :as cells]))10+ [frq.cells :as cells]
11+ [frq.clock :as clock]
12+ [frq.store :as store]))
11 13
12 (defn dm?14 (defn dm?
13 "Whether a buffer is a conversation with a person rather than a room. Every15 "Whether a buffer is a conversation with a person rather than a room. Every
@@ -126,3 +128,201 @@
126 ;; last. The turn-taking above is about which lines are in it, not128 ;; last. The turn-taking above is about which lines are in it, not
127 ;; about where they sit.129 ;; about where they sit.
128 (sort-by #(or (:at %) 0) >)))130 (sort-by #(or (:at %) 0) >)))
131+
132+
133+;; ------------------------------------------------------------- the marker
134+;; What has been seen, and what the unread count is derived from. Shared for
135+;; the reason the list is: a marker that only one half keeps is a phone that
136+;; comes back to a hundred lines it has already read.
137+
138+(defn after-marker
139+ "The messages in `buffer` the reader has not seen: everything after its read
140+ marker.
141+
142+ By id where the marked message is still held, and by time otherwise. The id
143+ is the exact answer a msgid survives every revision, so it names the same
144+ line however often the server replays it and the timestamp is what answers
145+ when the marked line has fallen off the end of the buffer or was never in
146+ this run's copy of it.
147+
148+ Derived rather than counted, because a count cannot survive what the server
149+ does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
150+ line of it would tick a counter a second time. Against a marker a replayed
151+ line is simply older than it and counts for nothing."
152+ [buffer]
153+ (let [id (:last-read-id buffer)
154+ at (:last-read-at buffer 0)
155+ msgs (vec (:messages buffer))]
156+ (if (and id (some #(= id (:id %)) msgs))
157+ (vec (rest (drop-while #(not= id (:id %)) msgs)))
158+ (filterv #(> (:at % 0) at) msgs))))
159+
160+(defn mentions-me?
161+ "Whether a line is addressed at the reader by name. Our own lines do not
162+ count saying your own nick is not being called."
163+ [m]
164+ (let [me (str/trim (or @cells/form-nick ""))]
165+ (and (seq me)
166+ (not= (:from m) me)
167+ (str/includes? (str/lower-case (or (:text m) ""))
168+ (str/lower-case me)))))
169+
170+(defn recount
171+ "Answer what the marker says: how many lines are unseen, and whether any of
172+ them names the reader."
173+ [buffer]
174+ ;; Joins, parts, quits and "Joined #room" are the room talking about itself,
175+ ;; not somebody talking in it. They arrive stamped now the join notice is
176+ ;; written the moment we are in so counted, every room you are a member of
177+ ;; sits at one unread from the moment it opens, saying only that you joined
178+ ;; it. The marker still moves past them: they are read, they are just never
179+ ;; what made a room worth looking at.
180+ (let [fresh (remove :system? (after-marker buffer))]
181+ (assoc buffer
182+ :unread (count fresh)
183+ :mention? (boolean (some mentions-me? fresh)))))
184+
185+(defn mark-read
186+ "Move the marker to the newest line this buffer holds. Both halves: the id
187+ for as long as that line is here, and its time for after it is gone.
188+
189+ The time only ever goes forward. A backlog can arrive after the reader has
190+ already read past it, and taking the last line's time unconditionally would
191+ walk the marker backwards and re-unread what was read."
192+ [buffer]
193+ (let [newest (last (:messages buffer))]
194+ (assoc buffer
195+ :unread 0
196+ :mention? false
197+ :last-read-id (:id newest)
198+ :last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
199+
200+;; How far back a room nobody has seen before counts as already read.
201+;;
202+;; A room being joined for the first time replays its whole history, and none
203+;; of that is news the reader was not away for it, they were not here. So a
204+;; new buffer starts caught up rather than at the beginning, or joining a busy
205+;; channel announces a hundred unread posts from before you arrived.
206+;;
207+;; Caught up to a minute ago rather than to this instant, because a live line
208+;; is timestamped by the server and this by our clock: the two disagree by
209+;; whatever the skew is, and a live message stamped a few seconds behind us
210+;; would land under the marker and never be counted. A minute is more skew than
211+;; there will be and far less than the age of any backlog, and what it costs is
212+;; that a message sent in the minute before you joined counts as unread which
213+;; is the harmless direction.
214+(def fresh-room-grace-ms 60000)
215+
216+(defn ensure-channel [m name]
217+ (if (contains? m name)
218+ m
219+ (assoc m name {:name name :messages [] :unread 0
220+ :joined? false :joining? false :accessed 0
221+ ;; What has been seen, and what the count is derived from.
222+ ;; `:unread` and `:mention?` are answers, not records see
223+ ;; `recount`.
224+ :last-read-id nil :mention? false
225+ :last-read-at (max 0 (- (clock/now-ms) fresh-room-grace-ms))
226+ :kind (if (dm? name) :dm :channel)
227+ :peer-did nil :last-activity 0
228+ ;; nick -> mode prefix, for the people panel
229+ :users {}})))
230+
231+;; ---------------------------------------------------------------- the list
232+;; A counter rather than a clock: the list only needs their order, and a
233+;; monotonic tick cannot be surprised by the system time moving.
234+(defonce access-tick (atom 0))
235+
236+(defn room-records
237+ "The rooms as they go to disk: what each one is, when it last said anything,
238+ and how far into it the reader has got.
239+
240+ Every room, not only the ones that have been opened. Being in a channel is
241+ what makes it yours; opening it only says which you looked at last, and that
242+ is what `:accessed` orders them by. Writing down the opened ones alone is how
243+ a client in a dozen channels came back knowing one the rest were left for
244+ the server to remember, which is the thing it does not do.
245+
246+ `:unread` and `:mention?` are not written. They are what the marker adds up
247+ to against the messages in hand, and a count written down is a count that can
248+ be wrong the marker cannot be. `:mention?` rides along all the same, as the
249+ one thing that cannot be recomputed before the history it was derived from
250+ comes back: a room that had your name in it says so on the next run's first
251+ frame rather than a round trip later, and is corrected by `recount` the
252+ moment the backlog lands."
253+ []
254+ (->> (vals @cells/channels)
255+ (sort-by #(- (:accessed % 0)))
256+ (mapv #(select-keys % [:name :kind :peer-did :last-activity
257+ :last-read-id :last-read-at :mention?]))))
258+
259+(defonce ^:private rooms-saved-at (atom 0))
260+
261+(defn remember-rooms!
262+ "Write the room records out, most recently used first: what rooms there are,
263+ in the order they were last used, and how much of each has been read.
264+
265+ Throttled, because the marker moves on every line that arrives while a room
266+ is on screen and a busy channel would otherwise write the file per message.
267+ `force?` is for the moments worth paying for, which is a room being opened,
268+ joined or closed.
269+
270+ A late write costs at most the handful of lines that arrived since the last
271+ one, shown unread again on the next run. That is the right way round: the
272+ marker never claims to have read more than it has.
273+
274+ Shared because both halves have the same thing worth keeping and the same
275+ moments worth keeping it at. The desktop puts this on a thread of its own as
276+ well a jolt answer to a jolt cost, and why `frq.state` keeps its own
277+ wrapper around `save-rooms!` rather than calling this."
278+ ([] (remember-rooms! false))
279+ ([force?]
280+ (let [now (clock/now-ms)]
281+ (when (or force? (> (- now @rooms-saved-at) 5000))
282+ (reset! rooms-saved-at now)
283+ (store/save-rooms! (room-records))))))
284+
285+(defn restore-channels!
286+ "Bring back the rooms of earlier runs, in the order they were last used, each
287+ with the marker saying how much of it had been read.
288+
289+ Empty buffers, not memberships: opening one is what joins it, and a list of
290+ rooms is the part worth keeping the messages in them come from the server.
291+ The tick is seeded so this run's first open still sorts above all of them.
292+
293+ The marker is what makes the returning backlog readable. Without one every
294+ replayed line is new and every room comes back with its whole history
295+ unread; with one, the reader is put back where they were and only what
296+ arrived while they were away is counted. A room migrated from an older frq
297+ has no marker and is caught up as if read, which is the kinder of the two
298+ wrong answers the alternative announces a hundred unread lines the reader
299+ has already seen."
300+ []
301+ (when-let [saved (seq (store/load-rooms))]
302+ (let [ordered (reverse saved)] ; oldest first, so ticks ascend
303+ (swap! cells/channels
304+ (fn [m]
305+ (reduce (fn [acc room]
306+ (let [name (:name room)]
307+ (if (contains? acc name)
308+ acc
309+ (assoc acc name
310+ {:name name :messages [] :unread 0
311+ :joined? false :joining? false
312+ :users {}
313+ :kind (or (:kind room)
314+ (if (dm? name) :dm :channel))
315+ :peer-did (:peer-did room)
316+ :last-activity (:last-activity room 0)
317+ :last-read-id (:last-read-id room)
318+ ;; No marker at all an older frq's list,
319+ ;; or a record that lost it. Read up to
320+ ;; now rather than back to the beginning.
321+ :last-read-at (:last-read-at room
322+ (clock/now-ms))
323+ :mention? (boolean (:mention? room))
324+ :accessed (swap! access-tick inc)})))
325+ )
326+ m
327+ ordered))))
328+ (count saved)))
modified flutter/src/frq/main.cljd +89 -124
@@ -157,15 +157,6 @@
157157 ;; And whether this session is the adopting one, decided at 001.
158158 (defonce ^:private adopting-rooms? (atom false))
159159
160-;; What the record on disk says that this client has no cell for: the read
161-;; markers, mostly. Flutter has no unread machinery yet, so writing only the
162-;; fields it knows would quietly drop the marker a desktop run had put there
163-;; and hand the reader their whole history as new. Read once, merged back
164-;; under whatever we do know.
165-(defonce ^:private room-extras (atom {}))
166-
167-(defonce ^:private rooms-saved-at (atom 0))
168-
169160 (defn- save-prefs!
170161 "Keep the one flag that has to outlive the run: whether this client has
171162 taken the room list over from the server. Merged into whatever else
@@ -180,68 +171,17 @@
180171 (reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))
181172 nil)
182173
183-(defn- room-records
184- "The rooms as they go on disk: most recently used first, each carrying
185- whatever the file already said about it that this client does not hold."
186- []
187- (->> (vals @cells/channels)
188- (sort-by #(- (:accessed % 0)))
189- (mapv (fn [b]
190- (let [name (:name b)]
191- (merge (get @room-extras name)
192- {:name name
193- :kind (or (:kind b)
194- (if (rooms/dm? name) :dm :channel))}))))))
195-
196-(defn- remember-rooms!
197- "Write the room list out. Throttled, because a room's `:accessed` moves
198- every time one is opened and the write is synchronous here `force?` is for
199- the moments worth paying for, which is the list itself changing.
200-
201- A late write costs at most the rooms touched since the last one, which come
202- back in a slightly stale order. Losing one entirely is the thing to avoid,
203- which is what `force?` is for."
204- ([] (remember-rooms! false))
205- ([force?]
206- (let [now (clock/now-ms)]
207- (when (or force? (> (- now @rooms-saved-at) 5000))
208- (reset! rooms-saved-at now)
209- (store/save-rooms! (room-records)))
210- nil)))
211-
212-(defn- restore-rooms!
213- "Bring back the rooms of earlier runs, in the order they were last used.
214-
215- Empty buffers, not memberships: opening one is what joins it, and the list
216- is the part worth keeping the messages in them come from the server, which
217- replays them on the CHATHISTORY this client asks for at 366.
218-
219- `:accessed` is an ascending count over the file read backwards, so the saved
220- order survives and a room opened *this* run stamped with the wall clock
221- still sorts above every one of them."
222- []
223- (when-let [saved (seq (store/load-rooms))]
224- (reset! room-extras
225- (into {} (map (fn [r] [(:name r) (dissoc r :name :kind)]) saved)))
226- (swap! cells/channels
227- (fn [m]
228- (reduce (fn [acc [i room]]
229- (let [name (:name room)]
230- (if (contains? acc name)
231- acc
232- (assoc acc name
233- {:name name
234- :messages []
235- :unread 0
236- :joined? false
237- :users {}
238- :kind (or (:kind room)
239- (if (rooms/dm? name) :dm :channel))
240- :accessed (inc i)}))))
241- m
242- ;; Oldest first, so the count ascends with the order.
243- (map-indexed vector (reverse saved)))))
244- (count saved)))
174+(def ^:private remember-rooms!
175+ "Write the room list out, most recently used first. `frq.rooms` — the same
176+ records the desktop writes, throttled the same way, so a marker one half
177+ moved is one the other reads back."
178+ rooms/remember-rooms!)
179+
180+(def ^:private restore-rooms!
181+ "Bring back the rooms of earlier runs, in the order they were last used,
182+ each with the marker saying how much of it had been read. `frq.rooms` again:
183+ the buffers this makes are the ones `recount` counts against."
184+ rooms/restore-channels!)
245185
246186 (defn- ask-to-join!
247187 "Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo
@@ -274,13 +214,14 @@
274214 (when (and @cells/editing (not= name (:channel @cells/editing)))
275215 (reset! cells/editing nil)
276216 (reset! cells/draft ""))
277- (swap! cells/channels update name
278- #(merge {:name name :messages [] :unread 0
279- :kind (if (rooms/dm? name) :dm :channel)}
280- %))
281- ;; The wall clock, so a room opened this run sorts above every one
282- ;; `restore-rooms!` counted in.
283- (swap! cells/channels assoc-in [name :accessed] (clock/now-ms))
217+ ;; Opening a room is reading it: the marker goes to the newest line this
218+ ;; buffer holds, which is what stops the backlog that follows from arriving
219+ ;; unread all over again. The tick is `frq.rooms`', so a room opened this
220+ ;; run sorts above every one `restore-rooms!` counted in.
221+ (swap! cells/channels
222+ #(-> (rooms/ensure-channel % name)
223+ (update name rooms/mark-read)
224+ (assoc-in [name :accessed] (swap! rooms/access-tick inc))))
284225 (reset! cells/screen :chat)
285226 ;; Worth a write of its own: the order the list is read in is the order
286227 ;; rooms were last opened, and this is the moment it changes.
@@ -338,7 +279,10 @@
338279 params (vec (:params m))
339280 who (irc/nick-of (:prefix m))
340281 me? (= who (str @cells/form-nick))
341- ensure (fn [m name] (update m name #(merge {:name name :messages [] :unread 0} %)))]
282+ ;; The shared one, so a buffer born here carries the same read marker
283+ ;; the desktop's does — `frq.rooms/recount` has nothing to count
284+ ;; against without it.
285+ ensure rooms/ensure-channel]
342286 (cond
343287 (= "JOIN" cmd)
344288 (let [name (first params)]
@@ -417,7 +361,20 @@
417361 text (last params)
418362 name (if (rooms/dm? target) who target)
419363 edit-of (or (irc/tag-value tags "+draft/edit")
420- (irc/tag-value tags "+edit"))]
364+ (irc/tag-value tags "+edit"))
365+ ;; When the server says the line was written, or now when it says
366+ ;; nothing. The marker is a time as well as an id a replayed
367+ ;; backlog is older than what has been read and has to be able to
368+ ;; say so and the day separators in `frq.screens.chat` have been
369+ ;; waiting on this too.
370+ at (or (clock/parse-time-tag tags) (clock/now-ms))
371+ ;; Reading a room is marking it read: a line arriving in the room
372+ ;; on screen moves the marker past itself, and one arriving
373+ ;; anywhere else is counted against the marker there.
374+ viewing? (and (= :chat @cells/screen) (= name @cells/current))
375+ settle (fn [m] (update m name (if viewing?
376+ rooms/mark-read
377+ rooms/recount)))]
421378 ;; The face, asked for as the line arrives rather than when the row is
422379 ;; built: a row is built during a Flutter frame, and a fetch started
423380 ;; there would be state changed mid-build. `frq.avatars.dart` asks the
@@ -430,49 +387,55 @@
430387 ;; nothing else refers to. One older than the backlog we hold has
431388 ;; nothing here to replace, and is shown as itself rather than lost.
432389 (when (= :absent (edit-message! name edit-of who text))
433- (swap! cells/channels update name
434- #(-> (merge {:name name :messages [] :unread 0} %)
435- (update :messages conj {:from who
436- :text text
437- :images (images-in text)
438- :did (:account m)
439- :id edit-of
440- :edited? true}))))
441- (swap! cells/channels update name
442- #(-> (merge {:name name :messages [] :unread 0} %)
443- (update :messages conj
444- ;; The msgid is what everything after a message
445- ;; names it by a reply points at one, a reaction
446- ;; lands on one. Without it a line is on screen and
447- ;; nothing can be said about it.
448- {:from who
449- :text text
450- ;; Any .png link on the line, and the fetch that
451- ;; makes one a picture rather than a link.
452- :images (images-in text)
453- :did (:account m)
454- ;; Who to look a profile up by: the DID the
455- ;; server put on the line, or the nick when that
456- ;; is handle-shaped. A guest has neither and gets
457- ;; no lookup, which is the honest answer.
458- :actor (profile/actor (:account m) who)
459- :id (irc/tag-value tags "msgid")
460- ;; The server canonicalises +draft/reply to
461- ;; +reply; a client that sent the draft name may
462- ;; still reach us before it does.
463- :reply-to (or (irc/tag-value tags "+reply")
464- (irc/tag-value tags "+draft/reply"))
465- ;; What is already on it, so a reconnect does not
466- ;; start every message empty.
467- ;; What the server says about a line it has
468- ;; already collapsed: replay sends the current
469- ;; text and no `+draft/edit` to hint that it is
470- ;; not the original. This tag is the only trace.
471- :edited? (= "1" (irc/tag-value tags
472- "+freeq.at/edited"))
473- :reactions (reactions/parse-tally
474- (irc/tag-value tags
475- "+freeq.at/reactions"))})))))
390+ (swap! cells/channels
391+ #(-> (rooms/ensure-channel % name)
392+ (update-in [name :messages] conj
393+ {:from who
394+ :text text
395+ :images (images-in text)
396+ :did (:account m)
397+ :id edit-of
398+ :at at
399+ :edited? true})
400+ settle))))
401+ (swap! cells/channels
402+ #(-> (rooms/ensure-channel % name)
403+ (update-in [name :messages] conj
404+ ;; The msgid is what everything after a message
405+ ;; names it by a reply points at one, a reaction
406+ ;; lands on one. Without it a line is on screen and
407+ ;; nothing can be said about it.
408+ {:from who
409+ :text text
410+ ;; Any .png link on the line, and the fetch that
411+ ;; makes one a picture rather than a link.
412+ :images (images-in text)
413+ :did (:account m)
414+ :at at
415+ ;; Who to look a profile up by: the DID the
416+ ;; server put on the line, or the nick when that
417+ ;; is handle-shaped. A guest has neither and gets
418+ ;; no lookup, which is the honest answer.
419+ :actor (profile/actor (:account m) who)
420+ :id (irc/tag-value tags "msgid")
421+ ;; The server canonicalises +draft/reply to
422+ ;; +reply; a client that sent the draft name may
423+ ;; still reach us before it does.
424+ :reply-to (or (irc/tag-value tags "+reply")
425+ (irc/tag-value tags "+draft/reply"))
426+ ;; What is already on it, so a reconnect does not
427+ ;; start every message empty.
428+ ;; What the server says about a line it has
429+ ;; already collapsed: replay sends the current
430+ ;; text and no `+draft/edit` to hint that it is
431+ ;; not the original. This tag is the only trace.
432+ :edited? (= "1" (irc/tag-value tags
433+ "+freeq.at/edited"))
434+ :reactions (reactions/parse-tally
435+ (irc/tag-value tags
436+ "+freeq.at/reactions"))})
437+ (assoc-in [name :last-activity] at)
438+ settle)))
476439
477440 ;; A message that is only tags. A reaction is the one this reads:
478441 ;; `+react` puts an emoji on the message `+reply` names, and the
@@ -494,7 +457,9 @@
494457 ;; Whatever that message did to the list, write it down. Throttled, so a
495458 ;; busy channel does not cost a file write per line but a DM arriving
496459 ;; from someone new is a room that exists only here until this runs, and
497- ;; the old client lost exactly those between runs.
460+ ;; the old client lost exactly those between runs. The read marker moves
461+ ;; on the same path, for the same reason: a line arriving in the room on
462+ ;; screen is a line read.
498463 (remember-rooms!)))
499464
500465 (defn- note! [m]
@@ -157,15 +157,6 @@
157 ;; And whether this session is the adopting one, decided at 001.157 ;; And whether this session is the adopting one, decided at 001.
158 (defonce ^:private adopting-rooms? (atom false))158 (defonce ^:private adopting-rooms? (atom false))
159 159
160-;; What the record on disk says that this client has no cell for: the read
161-;; markers, mostly. Flutter has no unread machinery yet, so writing only the
162-;; fields it knows would quietly drop the marker a desktop run had put there
163-;; and hand the reader their whole history as new. Read once, merged back
164-;; under whatever we do know.
165-(defonce ^:private room-extras (atom {}))
166-
167-(defonce ^:private rooms-saved-at (atom 0))
168-
169 (defn- save-prefs!160 (defn- save-prefs!
170 "Keep the one flag that has to outlive the run: whether this client has161 "Keep the one flag that has to outlive the run: whether this client has
171 taken the room list over from the server. Merged into whatever else162 taken the room list over from the server. Merged into whatever else
@@ -180,68 +171,17 @@
180 (reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))171 (reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))
181 nil)172 nil)
182 173
183-(defn- room-records174+(def ^:private remember-rooms!
184- "The rooms as they go on disk: most recently used first, each carrying175+ "Write the room list out, most recently used first. `frq.rooms` — the same
185- whatever the file already said about it that this client does not hold."176+ records the desktop writes, throttled the same way, so a marker one half
186- []177+ moved is one the other reads back."
187- (->> (vals @cells/channels)178+ rooms/remember-rooms!)
188- (sort-by #(- (:accessed % 0)))179+
189- (mapv (fn [b]180+(def ^:private restore-rooms!
190- (let [name (:name b)]181+ "Bring back the rooms of earlier runs, in the order they were last used,
191- (merge (get @room-extras name)182+ each with the marker saying how much of it had been read. `frq.rooms` again:
192- {:name name183+ the buffers this makes are the ones `recount` counts against."
193- :kind (or (:kind b)184+ rooms/restore-channels!)
194- (if (rooms/dm? name) :dm :channel))}))))))
195-
196-(defn- remember-rooms!
197- "Write the room list out. Throttled, because a room's `:accessed` moves
198- every time one is opened and the write is synchronous here `force?` is for
199- the moments worth paying for, which is the list itself changing.
200-
201- A late write costs at most the rooms touched since the last one, which come
202- back in a slightly stale order. Losing one entirely is the thing to avoid,
203- which is what `force?` is for."
204- ([] (remember-rooms! false))
205- ([force?]
206- (let [now (clock/now-ms)]
207- (when (or force? (> (- now @rooms-saved-at) 5000))
208- (reset! rooms-saved-at now)
209- (store/save-rooms! (room-records)))
210- nil)))
211-
212-(defn- restore-rooms!
213- "Bring back the rooms of earlier runs, in the order they were last used.
214-
215- Empty buffers, not memberships: opening one is what joins it, and the list
216- is the part worth keeping the messages in them come from the server, which
217- replays them on the CHATHISTORY this client asks for at 366.
218-
219- `:accessed` is an ascending count over the file read backwards, so the saved
220- order survives and a room opened *this* run stamped with the wall clock
221- still sorts above every one of them."
222- []
223- (when-let [saved (seq (store/load-rooms))]
224- (reset! room-extras
225- (into {} (map (fn [r] [(:name r) (dissoc r :name :kind)]) saved)))
226- (swap! cells/channels
227- (fn [m]
228- (reduce (fn [acc [i room]]
229- (let [name (:name room)]
230- (if (contains? acc name)
231- acc
232- (assoc acc name
233- {:name name
234- :messages []
235- :unread 0
236- :joined? false
237- :users {}
238- :kind (or (:kind room)
239- (if (rooms/dm? name) :dm :channel))
240- :accessed (inc i)}))))
241- m
242- ;; Oldest first, so the count ascends with the order.
243- (map-indexed vector (reverse saved)))))
244- (count saved)))
245 185
246 (defn- ask-to-join!186 (defn- ask-to-join!
247 "Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo187 "Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo
@@ -274,13 +214,14 @@
274 (when (and @cells/editing (not= name (:channel @cells/editing)))214 (when (and @cells/editing (not= name (:channel @cells/editing)))
275 (reset! cells/editing nil)215 (reset! cells/editing nil)
276 (reset! cells/draft ""))216 (reset! cells/draft ""))
277- (swap! cells/channels update name217+ ;; Opening a room is reading it: the marker goes to the newest line this
278- #(merge {:name name :messages [] :unread 0218+ ;; buffer holds, which is what stops the backlog that follows from arriving
279- :kind (if (rooms/dm? name) :dm :channel)}219+ ;; unread all over again. The tick is `frq.rooms`', so a room opened this
280- %))220+ ;; run sorts above every one `restore-rooms!` counted in.
281- ;; The wall clock, so a room opened this run sorts above every one221+ (swap! cells/channels
282- ;; `restore-rooms!` counted in.222+ #(-> (rooms/ensure-channel % name)
283- (swap! cells/channels assoc-in [name :accessed] (clock/now-ms))223+ (update name rooms/mark-read)
224+ (assoc-in [name :accessed] (swap! rooms/access-tick inc))))
284 (reset! cells/screen :chat)225 (reset! cells/screen :chat)
285 ;; Worth a write of its own: the order the list is read in is the order226 ;; Worth a write of its own: the order the list is read in is the order
286 ;; rooms were last opened, and this is the moment it changes.227 ;; rooms were last opened, and this is the moment it changes.
@@ -338,7 +279,10 @@
338 params (vec (:params m))279 params (vec (:params m))
339 who (irc/nick-of (:prefix m))280 who (irc/nick-of (:prefix m))
340 me? (= who (str @cells/form-nick))281 me? (= who (str @cells/form-nick))
341- ensure (fn [m name] (update m name #(merge {:name name :messages [] :unread 0} %)))]282+ ;; The shared one, so a buffer born here carries the same read marker
283+ ;; the desktop's does — `frq.rooms/recount` has nothing to count
284+ ;; against without it.
285+ ensure rooms/ensure-channel]
342 (cond286 (cond
343 (= "JOIN" cmd)287 (= "JOIN" cmd)
344 (let [name (first params)]288 (let [name (first params)]
@@ -417,7 +361,20 @@
417 text (last params)361 text (last params)
418 name (if (rooms/dm? target) who target)362 name (if (rooms/dm? target) who target)
419 edit-of (or (irc/tag-value tags "+draft/edit")363 edit-of (or (irc/tag-value tags "+draft/edit")
420- (irc/tag-value tags "+edit"))]364+ (irc/tag-value tags "+edit"))
365+ ;; When the server says the line was written, or now when it says
366+ ;; nothing. The marker is a time as well as an id a replayed
367+ ;; backlog is older than what has been read and has to be able to
368+ ;; say so and the day separators in `frq.screens.chat` have been
369+ ;; waiting on this too.
370+ at (or (clock/parse-time-tag tags) (clock/now-ms))
371+ ;; Reading a room is marking it read: a line arriving in the room
372+ ;; on screen moves the marker past itself, and one arriving
373+ ;; anywhere else is counted against the marker there.
374+ viewing? (and (= :chat @cells/screen) (= name @cells/current))
375+ settle (fn [m] (update m name (if viewing?
376+ rooms/mark-read
377+ rooms/recount)))]
421 ;; The face, asked for as the line arrives rather than when the row is378 ;; The face, asked for as the line arrives rather than when the row is
422 ;; built: a row is built during a Flutter frame, and a fetch started379 ;; built: a row is built during a Flutter frame, and a fetch started
423 ;; there would be state changed mid-build. `frq.avatars.dart` asks the380 ;; there would be state changed mid-build. `frq.avatars.dart` asks the
@@ -430,49 +387,55 @@
430 ;; nothing else refers to. One older than the backlog we hold has387 ;; nothing else refers to. One older than the backlog we hold has
431 ;; nothing here to replace, and is shown as itself rather than lost.388 ;; nothing here to replace, and is shown as itself rather than lost.
432 (when (= :absent (edit-message! name edit-of who text))389 (when (= :absent (edit-message! name edit-of who text))
433- (swap! cells/channels update name390+ (swap! cells/channels
434- #(-> (merge {:name name :messages [] :unread 0} %)391+ #(-> (rooms/ensure-channel % name)
435- (update :messages conj {:from who392+ (update-in [name :messages] conj
436- :text text393+ {:from who
437- :images (images-in text)394+ :text text
438- :did (:account m)395+ :images (images-in text)
439- :id edit-of396+ :did (:account m)
440- :edited? true}))))397+ :id edit-of
441- (swap! cells/channels update name398+ :at at
442- #(-> (merge {:name name :messages [] :unread 0} %)399+ :edited? true})
443- (update :messages conj400+ settle))))
444- ;; The msgid is what everything after a message401+ (swap! cells/channels
445- ;; names it by a reply points at one, a reaction402+ #(-> (rooms/ensure-channel % name)
446- ;; lands on one. Without it a line is on screen and403+ (update-in [name :messages] conj
447- ;; nothing can be said about it.404+ ;; The msgid is what everything after a message
448- {:from who405+ ;; names it by a reply points at one, a reaction
449- :text text406+ ;; lands on one. Without it a line is on screen and
450- ;; Any .png link on the line, and the fetch that407+ ;; nothing can be said about it.
451- ;; makes one a picture rather than a link.408+ {:from who
452- :images (images-in text)409+ :text text
453- :did (:account m)410+ ;; Any .png link on the line, and the fetch that
454- ;; Who to look a profile up by: the DID the411+ ;; makes one a picture rather than a link.
455- ;; server put on the line, or the nick when that412+ :images (images-in text)
456- ;; is handle-shaped. A guest has neither and gets413+ :did (:account m)
457- ;; no lookup, which is the honest answer.414+ :at at
458- :actor (profile/actor (:account m) who)415+ ;; Who to look a profile up by: the DID the
459- :id (irc/tag-value tags "msgid")416+ ;; server put on the line, or the nick when that
460- ;; The server canonicalises +draft/reply to417+ ;; is handle-shaped. A guest has neither and gets
461- ;; +reply; a client that sent the draft name may418+ ;; no lookup, which is the honest answer.
462- ;; still reach us before it does.419+ :actor (profile/actor (:account m) who)
463- :reply-to (or (irc/tag-value tags "+reply")420+ :id (irc/tag-value tags "msgid")
464- (irc/tag-value tags "+draft/reply"))421+ ;; The server canonicalises +draft/reply to
465- ;; What is already on it, so a reconnect does not422+ ;; +reply; a client that sent the draft name may
466- ;; start every message empty.423+ ;; still reach us before it does.
467- ;; What the server says about a line it has424+ :reply-to (or (irc/tag-value tags "+reply")
468- ;; already collapsed: replay sends the current425+ (irc/tag-value tags "+draft/reply"))
469- ;; text and no `+draft/edit` to hint that it is426+ ;; What is already on it, so a reconnect does not
470- ;; not the original. This tag is the only trace.427+ ;; start every message empty.
471- :edited? (= "1" (irc/tag-value tags428+ ;; What the server says about a line it has
472- "+freeq.at/edited"))429+ ;; already collapsed: replay sends the current
473- :reactions (reactions/parse-tally430+ ;; text and no `+draft/edit` to hint that it is
474- (irc/tag-value tags431+ ;; not the original. This tag is the only trace.
475- "+freeq.at/reactions"))})))))432+ :edited? (= "1" (irc/tag-value tags
433+ "+freeq.at/edited"))
434+ :reactions (reactions/parse-tally
435+ (irc/tag-value tags
436+ "+freeq.at/reactions"))})
437+ (assoc-in [name :last-activity] at)
438+ settle)))
476 439
477 ;; A message that is only tags. A reaction is the one this reads:440 ;; A message that is only tags. A reaction is the one this reads:
478 ;; `+react` puts an emoji on the message `+reply` names, and the441 ;; `+react` puts an emoji on the message `+reply` names, and the
@@ -494,7 +457,9 @@
494 ;; Whatever that message did to the list, write it down. Throttled, so a457 ;; Whatever that message did to the list, write it down. Throttled, so a
495 ;; busy channel does not cost a file write per line but a DM arriving458 ;; busy channel does not cost a file write per line but a DM arriving
496 ;; from someone new is a room that exists only here until this runs, and459 ;; from someone new is a room that exists only here until this runs, and
497- ;; the old client lost exactly those between runs.460+ ;; the old client lost exactly those between runs. The read marker moves
461+ ;; on the same path, for the same reason: a line arriving in the room on
462+ ;; screen is a line read.
498 (remember-rooms!)))463 (remember-rooms!)))
499 464
500 (defn- note! [m]465 (defn- note! [m]
modified src/frq/state.clj +11 -160
@@ -182,7 +182,7 @@
182182 (swap! jump-tick inc))
183183 ;; A counter rather than a clock: the list only needs their order, and a
184184 ;; monotonic tick cannot be surprised by the system time moving.
185-(defonce access-tick (atom 0))
185+(def access-tick rooms/access-tick)
186186
187187 (def hide-join-part? cells/hide-join-part?)
188188
@@ -253,98 +253,13 @@
253253 (str/starts-with? s "#") s
254254 :else (str "#" s))))
255255
256-(defn- after-marker
257- "The messages in `buffer` the reader has not seen: everything after its read
258- marker.
259-
260- By id where the marked message is still held, and by time otherwise. The id
261- is the exact answer — a msgid survives every revision, so it names the same
262- line however often the server replays it — and the timestamp is what answers
263- when the marked line has fallen off the end of the buffer or was never in
264- this run's copy of it.
265-
266- Derived rather than counted, because a count cannot survive what the server
267- does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
268- line of it would tick a counter a second time. Against a marker a replayed
269- line is simply older than it and counts for nothing."
270- [buffer]
271- (let [id (:last-read-id buffer)
272- at (:last-read-at buffer 0)
273- msgs (vec (:messages buffer))]
274- (if (and id (some #(= id (:id %)) msgs))
275- (vec (rest (drop-while #(not= id (:id %)) msgs)))
276- (filterv #(> (:at % 0) at) msgs))))
277-
278-(defn- mentions-me?
279- "Whether a line is addressed at the reader by name. Our own lines do not
280- count — saying your own nick is not being called."
281- [m]
282- (let [me (str/trim (or @form-nick ""))]
283- (and (seq me)
284- (not= (:from m) me)
285- (str/includes? (str/lower-case (or (:text m) ""))
286- (str/lower-case me)))))
287-
288-(defn- recount
289- "Answer what the marker says: how many lines are unseen, and whether any of
290- them names the reader."
291- [buffer]
292- ;; Joins, parts, quits and "Joined #room" are the room talking about itself,
293- ;; not somebody talking in it. They arrive stamped now — the join notice is
294- ;; written the moment we are in — so counted, every room you are a member of
295- ;; sits at one unread from the moment it opens, saying only that you joined
296- ;; it. The marker still moves past them: they are read, they are just never
297- ;; what made a room worth looking at.
298- (let [fresh (remove :system? (after-marker buffer))]
299- (assoc buffer
300- :unread (count fresh)
301- :mention? (boolean (some mentions-me? fresh)))))
302-
303-(defn- mark-read
304- "Move the marker to the newest line this buffer holds. Both halves: the id
305- for as long as that line is here, and its time for after it is gone.
306-
307- The time only ever goes forward. A backlog can arrive after the reader has
308- already read past it, and taking the last line's time unconditionally would
309- walk the marker backwards and re-unread what was read."
310- [buffer]
311- (let [newest (last (:messages buffer))]
312- (assoc buffer
313- :unread 0
314- :mention? false
315- :last-read-id (:id newest)
316- :last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
317-
318-;; How far back a room nobody has seen before counts as already read.
319-;;
320-;; A room being joined for the first time replays its whole history, and none
321-;; of that is news — the reader was not away for it, they were not here. So a
322-;; new buffer starts caught up rather than at the beginning, or joining a busy
323-;; channel announces a hundred unread posts from before you arrived.
324-;;
325-;; Caught up to a minute ago rather than to this instant, because a live line
326-;; is timestamped by the server and this by our clock: the two disagree by
327-;; whatever the skew is, and a live message stamped a few seconds behind us
328-;; would land under the marker and never be counted. A minute is more skew than
329-;; there will be and far less than the age of any backlog, and what it costs is
330-;; that a message sent in the minute before you joined counts as unread — which
331-;; is the harmless direction.
332-(def ^:private fresh-room-grace-ms 60000)
333-
334-(defn- ensure-channel [m name]
335- (if (contains? m name)
336- m
337- (assoc m name {:name name :messages [] :unread 0
338- :joined? false :joining? false :accessed 0
339- ;; What has been seen, and what the count is derived from.
340- ;; `:unread` and `:mention?` are answers, not records — see
341- ;; `recount`.
342- :last-read-id nil :mention? false
343- :last-read-at (max 0 (- (clock/now-ms) fresh-room-grace-ms))
344- :kind (if (dm? name) :dm :channel)
345- :peer-did nil :last-activity 0
346- ;; nick -> mode prefix, for the people panel
347- :users {}})))
256+(def ^:private after-marker rooms/after-marker)
257+
258+(def ^:private recount rooms/recount)
259+
260+(def ^:private mark-read rooms/mark-read)
261+
262+(def ^:private ensure-channel rooms/ensure-channel)
348263
349264 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
350265 chat view re-renders without every message row watching the media cache."}
@@ -1632,73 +1547,9 @@
16321547 (sort-by #(- (:accessed % 0)))
16331548 (mapv :name)))
16341549
1635-(defn room-records
1636- "The rooms as they go to disk: what each one is, when it last said anything,
1637- and how far into it the reader has got.
1638-
1639- Every room, not only the ones that have been opened. Being in a channel is
1640- what makes it yours; opening it only says which you looked at last, and that
1641- is what `:accessed` orders them by. Writing down the opened ones alone is how
1642- a client in a dozen channels came back knowing one — the rest were left for
1643- the server to remember, which is the thing it does not do.
1644-
1645- `:unread` and `:mention?` are not written. They are what the marker adds up
1646- to against the messages in hand, and a count written down is a count that can
1647- be wrong — the marker cannot be. `:mention?` rides along all the same, as the
1648- one thing that cannot be recomputed before the history it was derived from
1649- comes back: a room that had your name in it says so on the next run's first
1650- frame rather than a round trip later, and is corrected by `recount` the
1651- moment the backlog lands."
1652- []
1653- (->> (vals @channels)
1654- (sort-by #(- (:accessed % 0)))
1655- (mapv #(select-keys % [:name :kind :peer-did :last-activity
1656- :last-read-id :last-read-at :mention?]))))
1657-
1658-(defn restore-channels!
1659- "Bring back the rooms of earlier runs, in the order they were last used, each
1660- with the marker saying how much of it had been read.
1661-
1662- Empty buffers, not memberships: opening one is what joins it, and a list of
1663- rooms is the part worth keeping — the messages in them come from the server.
1664- The tick is seeded so this run's first open still sorts above all of them.
1665-
1666- The marker is what makes the returning backlog readable. Without one every
1667- replayed line is new and every room comes back with its whole history
1668- unread; with one, the reader is put back where they were and only what
1669- arrived while they were away is counted. A room migrated from an older frq
1670- has no marker and is caught up as if read, which is the kinder of the two
1671- wrong answers — the alternative announces a hundred unread lines the reader
1672- has already seen."
1673- []
1674- (when-let [saved (seq (store/load-rooms))]
1675- (let [ordered (reverse saved)] ; oldest first, so ticks ascend
1676- (swap! channels
1677- (fn [m]
1678- (reduce (fn [acc room]
1679- (let [name (:name room)]
1680- (if (contains? acc name)
1681- acc
1682- (assoc acc name
1683- {:name name :messages [] :unread 0
1684- :joined? false :joining? false
1685- :users {}
1686- :kind (or (:kind room)
1687- (if (dm? name) :dm :channel))
1688- :peer-did (:peer-did room)
1689- :last-activity (:last-activity room 0)
1690- :last-read-id (:last-read-id room)
1691- ;; No marker at all — an older frq's list,
1692- ;; or a record that lost it. Read up to
1693- ;; now rather than back to the beginning.
1694- :last-read-at (:last-read-at room
1695- (clock/now-ms))
1696- :mention? (boolean (:mention? room))
1697- :accessed (swap! access-tick inc)})))
1698- )
1699- m
1700- ordered))))
1701- (count saved)))
1550+(def room-records rooms/room-records)
1551+
1552+(def restore-channels! rooms/restore-channels!)
17021553
17031554 (defn message-by-id
17041555 "The message a reply points at, if this buffer still holds it.
@@ -182,7 +182,7 @@
182 (swap! jump-tick inc))182 (swap! jump-tick inc))
183 ;; A counter rather than a clock: the list only needs their order, and a183 ;; A counter rather than a clock: the list only needs their order, and a
184 ;; monotonic tick cannot be surprised by the system time moving.184 ;; monotonic tick cannot be surprised by the system time moving.
185-(defonce access-tick (atom 0))185+(def access-tick rooms/access-tick)
186 186
187 (def hide-join-part? cells/hide-join-part?)187 (def hide-join-part? cells/hide-join-part?)
188 188
@@ -253,98 +253,13 @@
253 (str/starts-with? s "#") s253 (str/starts-with? s "#") s
254 :else (str "#" s))))254 :else (str "#" s))))
255 255
256-(defn- after-marker256+(def ^:private after-marker rooms/after-marker)
257- "The messages in `buffer` the reader has not seen: everything after its read257+
258- marker.258+(def ^:private recount rooms/recount)
259-259+
260- By id where the marked message is still held, and by time otherwise. The id260+(def ^:private mark-read rooms/mark-read)
261- is the exact answer — a msgid survives every revision, so it names the same261+
262- line however often the server replays it — and the timestamp is what answers262+(def ^:private ensure-channel rooms/ensure-channel)
263- when the marked line has fallen off the end of the buffer or was never in
264- this run's copy of it.
265-
266- Derived rather than counted, because a count cannot survive what the server
267- does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
268- line of it would tick a counter a second time. Against a marker a replayed
269- line is simply older than it and counts for nothing."
270- [buffer]
271- (let [id (:last-read-id buffer)
272- at (:last-read-at buffer 0)
273- msgs (vec (:messages buffer))]
274- (if (and id (some #(= id (:id %)) msgs))
275- (vec (rest (drop-while #(not= id (:id %)) msgs)))
276- (filterv #(> (:at % 0) at) msgs))))
277-
278-(defn- mentions-me?
279- "Whether a line is addressed at the reader by name. Our own lines do not
280- count — saying your own nick is not being called."
281- [m]
282- (let [me (str/trim (or @form-nick ""))]
283- (and (seq me)
284- (not= (:from m) me)
285- (str/includes? (str/lower-case (or (:text m) ""))
286- (str/lower-case me)))))
287-
288-(defn- recount
289- "Answer what the marker says: how many lines are unseen, and whether any of
290- them names the reader."
291- [buffer]
292- ;; Joins, parts, quits and "Joined #room" are the room talking about itself,
293- ;; not somebody talking in it. They arrive stamped now — the join notice is
294- ;; written the moment we are in — so counted, every room you are a member of
295- ;; sits at one unread from the moment it opens, saying only that you joined
296- ;; it. The marker still moves past them: they are read, they are just never
297- ;; what made a room worth looking at.
298- (let [fresh (remove :system? (after-marker buffer))]
299- (assoc buffer
300- :unread (count fresh)
301- :mention? (boolean (some mentions-me? fresh)))))
302-
303-(defn- mark-read
304- "Move the marker to the newest line this buffer holds. Both halves: the id
305- for as long as that line is here, and its time for after it is gone.
306-
307- The time only ever goes forward. A backlog can arrive after the reader has
308- already read past it, and taking the last line's time unconditionally would
309- walk the marker backwards and re-unread what was read."
310- [buffer]
311- (let [newest (last (:messages buffer))]
312- (assoc buffer
313- :unread 0
314- :mention? false
315- :last-read-id (:id newest)
316- :last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
317-
318-;; How far back a room nobody has seen before counts as already read.
319-;;
320-;; A room being joined for the first time replays its whole history, and none
321-;; of that is news — the reader was not away for it, they were not here. So a
322-;; new buffer starts caught up rather than at the beginning, or joining a busy
323-;; channel announces a hundred unread posts from before you arrived.
324-;;
325-;; Caught up to a minute ago rather than to this instant, because a live line
326-;; is timestamped by the server and this by our clock: the two disagree by
327-;; whatever the skew is, and a live message stamped a few seconds behind us
328-;; would land under the marker and never be counted. A minute is more skew than
329-;; there will be and far less than the age of any backlog, and what it costs is
330-;; that a message sent in the minute before you joined counts as unread — which
331-;; is the harmless direction.
332-(def ^:private fresh-room-grace-ms 60000)
333-
334-(defn- ensure-channel [m name]
335- (if (contains? m name)
336- m
337- (assoc m name {:name name :messages [] :unread 0
338- :joined? false :joining? false :accessed 0
339- ;; What has been seen, and what the count is derived from.
340- ;; `:unread` and `:mention?` are answers, not records — see
341- ;; `recount`.
342- :last-read-id nil :mention? false
343- :last-read-at (max 0 (- (clock/now-ms) fresh-room-grace-ms))
344- :kind (if (dm? name) :dm :channel)
345- :peer-did nil :last-activity 0
346- ;; nick -> mode prefix, for the people panel
347- :users {}})))
348 263
349 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the264 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
350 chat view re-renders without every message row watching the media cache."}265 chat view re-renders without every message row watching the media cache."}
@@ -1632,73 +1547,9 @@
1632 (sort-by #(- (:accessed % 0)))1547 (sort-by #(- (:accessed % 0)))
1633 (mapv :name)))1548 (mapv :name)))
1634 1549
1635-(defn room-records1550+(def room-records rooms/room-records)
1636- "The rooms as they go to disk: what each one is, when it last said anything,1551+
1637- and how far into it the reader has got.1552+(def restore-channels! rooms/restore-channels!)
1638-
1639- Every room, not only the ones that have been opened. Being in a channel is
1640- what makes it yours; opening it only says which you looked at last, and that
1641- is what `:accessed` orders them by. Writing down the opened ones alone is how
1642- a client in a dozen channels came back knowing one — the rest were left for
1643- the server to remember, which is the thing it does not do.
1644-
1645- `:unread` and `:mention?` are not written. They are what the marker adds up
1646- to against the messages in hand, and a count written down is a count that can
1647- be wrong — the marker cannot be. `:mention?` rides along all the same, as the
1648- one thing that cannot be recomputed before the history it was derived from
1649- comes back: a room that had your name in it says so on the next run's first
1650- frame rather than a round trip later, and is corrected by `recount` the
1651- moment the backlog lands."
1652- []
1653- (->> (vals @channels)
1654- (sort-by #(- (:accessed % 0)))
1655- (mapv #(select-keys % [:name :kind :peer-did :last-activity
1656- :last-read-id :last-read-at :mention?]))))
1657-
1658-(defn restore-channels!
1659- "Bring back the rooms of earlier runs, in the order they were last used, each
1660- with the marker saying how much of it had been read.
1661-
1662- Empty buffers, not memberships: opening one is what joins it, and a list of
1663- rooms is the part worth keeping — the messages in them come from the server.
1664- The tick is seeded so this run's first open still sorts above all of them.
1665-
1666- The marker is what makes the returning backlog readable. Without one every
1667- replayed line is new and every room comes back with its whole history
1668- unread; with one, the reader is put back where they were and only what
1669- arrived while they were away is counted. A room migrated from an older frq
1670- has no marker and is caught up as if read, which is the kinder of the two
1671- wrong answers — the alternative announces a hundred unread lines the reader
1672- has already seen."
1673- []
1674- (when-let [saved (seq (store/load-rooms))]
1675- (let [ordered (reverse saved)] ; oldest first, so ticks ascend
1676- (swap! channels
1677- (fn [m]
1678- (reduce (fn [acc room]
1679- (let [name (:name room)]
1680- (if (contains? acc name)
1681- acc
1682- (assoc acc name
1683- {:name name :messages [] :unread 0
1684- :joined? false :joining? false
1685- :users {}
1686- :kind (or (:kind room)
1687- (if (dm? name) :dm :channel))
1688- :peer-did (:peer-did room)
1689- :last-activity (:last-activity room 0)
1690- :last-read-id (:last-read-id room)
1691- ;; No marker at all — an older frq's list,
1692- ;; or a record that lost it. Read up to
1693- ;; now rather than back to the beginning.
1694- :last-read-at (:last-read-at room
1695- (clock/now-ms))
1696- :mention? (boolean (:mention? room))
1697- :accessed (swap! access-tick inc)})))
1698- )
1699- m
1700- ordered))))
1701- (count saved)))
1702 1553
1703 (defn message-by-id1554 (defn message-by-id
1704 "The message a reply points at, if this buffer still holds it.1555 "The message a reply points at, if this buffer still holds it.