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

Count what is unread from a marker the server cannot move

Unread was an integer on the buffer: bumped per arriving line, zeroed on
open, gone at exit. It could not survive what the server does. A JOIN
replays the backlog and the CHATHISTORY we ask for replays it again, so
every line came back through push-message! and ticked the counter a
second time — and none of it outlived the process anyway, so a restart
said every room was read whether or not it was.

So rooms and their read state go to ~/.config/frq/rooms.edn, and the
count is derived rather than kept. Each room records what it is, when it
last said anything, and how far into it the reader got: a msgid and the
time of it. `after-marker` takes what is past that — by id while that
line is still held, by time once it has aged out — and `recount` adds it
up. A replayed line is older than the marker and counts for nothing,
which is the whole point. Nothing writes a counter down, so there is no
number on disk that can be wrong.

The marker only goes forward. mark-read takes the max of what it had and
what arrived, because a backlog can land after the reader has already
read past it and taking the newest line's time flat would walk the
marker back and re-unread what was read.

Rooms are ours now, not the server's. It has told us we are in rooms we
are not and left out ones we are, so rooms.edn is what decides one
exists and its membership only sets a badge. channels.edn — names, no
markers — migrates in caught up as if read: the alternative announces a
hundred lines the reader has already seen.

Writes are off-thread and throttled to five seconds, forced when a room
is opened. The marker moves on every line that arrives while a room is
on screen and a busy channel would otherwise write per message. A late
write costs the handful of lines since the last one, shown unread again
next run — wrong in the direction that never claims to have read more
than it has.

Mentions ride along: recount marks a room whose unread lines carry the
reader's nick, and the row says `◆ @ 3` rather than `● 3`. That one flag
is cached on disk despite being derived — it is the only thing that
cannot be recomputed before the history it came from is back, so a room
that had your name in it says so on the first frame and is corrected
when the backlog lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-06T02:11:40-07:00 Browse files
234c54f parent: ca5b5a3
modified src/frq/app.jolt +5 -1
@@ -200,7 +200,11 @@
200200 (:joined? buffer) "joined"
201201 :else "not joined")
202202 :live (boolean (:joined? buffer))}]
203- (when (pos? unread) [:label {:label (str "● " unread)}])]
203+ ;; A mention is not more unread, it is different unread: the dot says
204+ ;; how much there is and the name says it was aimed at you. Both or
205+ ;; neither — a room with your name in it always has a line to count.
206+ (when (pos? unread)
207+ [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]
204208 [:dim-label {:label (s/last-preview buffer)}]
205209 (when-not @terminal? [open-button name])]))
206210
@@ -200,7 +200,11 @@
200 (:joined? buffer) "joined"200 (:joined? buffer) "joined"
201 :else "not joined")201 :else "not joined")
202 :live (boolean (:joined? buffer))}]202 :live (boolean (:joined? buffer))}]
203- (when (pos? unread) [:label {:label (str "● " unread)}])]203+ ;; A mention is not more unread, it is different unread: the dot says
204+ ;; how much there is and the name says it was aimed at you. Both or
205+ ;; neither — a room with your name in it always has a line to count.
206+ (when (pos? unread)
207+ [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]
204 [:dim-label {:label (s/last-preview buffer)}]208 [:dim-label {:label (s/last-preview buffer)}]
205 (when-not @terminal? [open-button name])]))209 (when-not @terminal? [open-button name])]))
206 210
modified src/frq/state.jolt +150 -21
@@ -199,14 +199,28 @@
199199
200200 (defn connected? [] (some? @conn))
201201
202-(declare channel-order request-names!)
202+(declare channel-order room-records request-names!)
203203
204-(defn- remember-channels!
205- "Write the order out. Off the caller's thread: opening a channel should not
206- wait on a file, and the order it records is read from the same atom either
207- way."
208- []
209- (future (store/save-channels! (channel-order))))
204+(defonce ^:private rooms-saved-at (atom 0))
205+
206+(defn- remember-rooms!
207+ "Write the room records out: what rooms there are, in the order they were
208+ last used, and how much of each has been read.
209+
210+ Off the caller's thread, because opening a room should not wait on a file.
211+ Throttled, because the marker moves on every line that arrives while a room
212+ is on screen and a busy channel would otherwise write the file per message
213+ `force?` is for the moments worth paying for, which is a room being opened.
214+
215+ A late write costs at most the handful of lines that arrived since the last
216+ one, shown unread again on the next run. That is the right way round: the
217+ marker never claims to have read more than it has."
218+ ([] (remember-rooms! false))
219+ ([force?]
220+ (let [now (clock/now-ms)]
221+ (when (or force? (> (- now @rooms-saved-at) 5000))
222+ (reset! rooms-saved-at now)
223+ (future (store/save-rooms! (room-records)))))))
210224
211225 (defn dm?
212226 "Whether a buffer is a conversation with a person rather than a room. Every
@@ -220,11 +234,73 @@
220234 (str/starts-with? s "#") s
221235 :else (str "#" s))))
222236
237+(defn- after-marker
238+ "The messages in `buffer` the reader has not seen: everything after its read
239+ marker.
240+
241+ By id where the marked message is still held, and by time otherwise. The id
242+ is the exact answer a msgid survives every revision, so it names the same
243+ line however often the server replays it and the timestamp is what answers
244+ when the marked line has fallen off the end of the buffer or was never in
245+ this run's copy of it.
246+
247+ Derived rather than counted, because a count cannot survive what the server
248+ does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
249+ line of it would tick a counter a second time. Against a marker a replayed
250+ line is simply older than it and counts for nothing."
251+ [buffer]
252+ (let [id (:last-read-id buffer)
253+ at (:last-read-at buffer 0)
254+ msgs (vec (:messages buffer))]
255+ (if (and id (some #(= id (:id %)) msgs))
256+ (vec (rest (drop-while #(not= id (:id %)) msgs)))
257+ (filterv #(> (:at % 0) at) msgs))))
258+
259+(defn- mentions-me?
260+ "Whether a line is addressed at the reader by name. Our own lines do not
261+ count saying your own nick is not being called."
262+ [m]
263+ (let [me (str/trim (or @form-nick ""))]
264+ (and (seq me)
265+ (not= (:from m) me)
266+ (str/includes? (str/lower-case (or (:text m) ""))
267+ (str/lower-case me)))))
268+
269+(defn- recount
270+ "Answer what the marker says: how many lines are unseen, and whether any of
271+ them names the reader."
272+ [buffer]
273+ (let [fresh (after-marker buffer)]
274+ (assoc buffer
275+ :unread (count fresh)
276+ :mention? (boolean (some mentions-me? fresh)))))
277+
278+(defn- mark-read
279+ "Move the marker to the newest line this buffer holds. Both halves: the id
280+ for as long as that line is here, and its time for after it is gone.
281+
282+ The time only ever goes forward. A backlog can arrive after the reader has
283+ already read past it, and taking the last line's time unconditionally would
284+ walk the marker backwards and re-unread what was read."
285+ [buffer]
286+ (let [newest (last (:messages buffer))]
287+ (assoc buffer
288+ :unread 0
289+ :mention? false
290+ :last-read-id (:id newest)
291+ :last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
292+
223293 (defn- ensure-channel [m name]
224294 (if (contains? m name)
225295 m
226296 (assoc m name {:name name :messages [] :unread 0
227297 :joined? false :joining? false :accessed 0
298+ ;; What has been seen, and what the count is derived from.
299+ ;; `:unread` and `:mention?` are answers, not records see
300+ ;; `recount`.
301+ :last-read-id nil :last-read-at 0 :mention? false
302+ :kind (if (dm? name) :dm :channel)
303+ :peer-did nil :last-activity 0
228304 ;; nick -> mode prefix, for the people panel
229305 :users {}})))
230306
@@ -298,8 +374,20 @@
298374 :edited? (boolean edited?)
299375 ;; emoji -> the nicks who put it there
300376 :reactions (or reactions {})})
301- (update-in [channel :unread]
302- (if viewing? (constantly 0) inc))))))))))
377+ (assoc-in [channel :last-activity] at)
378+ ;; A DM is a room named after whoever is in it, and a
379+ ;; nick is not a name that lasts. The DID is, so the
380+ ;; record keeps it the first time the other end says
381+ ;; anything ours would name the wrong side.
382+ (cond-> (and (dm? channel) did (not= from @form-nick))
383+ (assoc-in [channel :peer-did] did))
384+ ;; Reading a room *is* marking it read: a line that
385+ ;; arrives while it is on screen moves the marker past
386+ ;; itself. Everything else re-derives, so a line arriving
387+ ;; in a room nobody is looking at costs a recount of that
388+ ;; room and nothing more.
389+ (update channel (if viewing? mark-read recount))))))))
390+ (remember-rooms!)))
303391
304392 (defn open-channel!
305393 "Show a buffer. A channel we are not in is joined on the way — a row can
@@ -320,9 +408,9 @@
320408 (reset! editing nil)
321409 (reset! draft ""))
322410 (swap! channels #(-> (ensure-channel % name)
323- (assoc-in [name :unread] 0)
411+ (update name mark-read)
324412 (assoc-in [name :accessed] (swap! access-tick inc))))
325- (remember-channels!)
413+ (remember-rooms! true)
326414 ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
327415 ;; second JOIN sent in the meantime is what makes the server replay nothing.
328416 (let [buffer (get @channels name)]
@@ -1499,24 +1587,65 @@
14991587 (sort-by #(- (:accessed % 0)))
15001588 (mapv :name)))
15011589
1590+(defn room-records
1591+ "The rooms as they go to disk: what each one is, when it last said anything,
1592+ and how far into it the reader has got.
1593+
1594+ `:unread` and `:mention?` are not written. They are what the marker adds up
1595+ to against the messages in hand, and a count written down is a count that can
1596+ be wrong the marker cannot be. `:mention?` rides along all the same, as the
1597+ one thing that cannot be recomputed before the history it was derived from
1598+ comes back: a room that had your name in it says so on the next run's first
1599+ frame rather than a round trip later, and is corrected by `recount` the
1600+ moment the backlog lands."
1601+ []
1602+ (->> (vals @channels)
1603+ (filter #(pos? (:accessed % 0)))
1604+ (sort-by #(- (:accessed % 0)))
1605+ (mapv #(select-keys % [:name :kind :peer-did :last-activity
1606+ :last-read-id :last-read-at :mention?]))))
1607+
15021608 (defn restore-channels!
1503- "Bring back the channels of earlier runs, in the order they were last used.
1609+ "Bring back the rooms of earlier runs, in the order they were last used, each
1610+ with the marker saying how much of it had been read.
15041611
15051612 Empty buffers, not memberships: opening one is what joins it, and a list of
15061613 rooms is the part worth keeping the messages in them come from the server.
1507- The tick is seeded so this run's first open still sorts above all of them."
1614+ The tick is seeded so this run's first open still sorts above all of them.
1615+
1616+ The marker is what makes the returning backlog readable. Without one every
1617+ replayed line is new and every room comes back with its whole history
1618+ unread; with one, the reader is put back where they were and only what
1619+ arrived while they were away is counted. A room migrated from an older frq
1620+ has no marker and is caught up as if read, which is the kinder of the two
1621+ wrong answers the alternative announces a hundred unread lines the reader
1622+ has already seen."
15081623 []
1509- (when-let [saved (seq (store/load-channels))]
1624+ (when-let [saved (seq (store/load-rooms))]
15101625 (let [ordered (reverse saved)] ; oldest first, so ticks ascend
15111626 (swap! channels
15121627 (fn [m]
1513- (reduce (fn [acc name]
1514- (if (contains? acc name)
1515- acc
1516- (assoc acc name {:name name :messages [] :unread 0
1517- :joined? false :joining? false
1518- :users {}
1519- :accessed (swap! access-tick inc)})))
1628+ (reduce (fn [acc room]
1629+ (let [name (:name room)]
1630+ (if (contains? acc name)
1631+ acc
1632+ (assoc acc name
1633+ {:name name :messages [] :unread 0
1634+ :joined? false :joining? false
1635+ :users {}
1636+ :kind (or (:kind room)
1637+ (if (dm? name) :dm :channel))
1638+ :peer-did (:peer-did room)
1639+ :last-activity (:last-activity room 0)
1640+ :last-read-id (:last-read-id room)
1641+ ;; No marker at all an older frq's list,
1642+ ;; or a record that lost it. Read up to
1643+ ;; now rather than back to the beginning.
1644+ :last-read-at (:last-read-at room
1645+ (clock/now-ms))
1646+ :mention? (boolean (:mention? room))
1647+ :accessed (swap! access-tick inc)})))
1648+ )
15201649 m
15211650 ordered))))
15221651 (count saved)))
@@ -199,14 +199,28 @@
199 199
200 (defn connected? [] (some? @conn))200 (defn connected? [] (some? @conn))
201 201
202-(declare channel-order request-names!)202+(declare channel-order room-records request-names!)
203 203
204-(defn- remember-channels!204+(defonce ^:private rooms-saved-at (atom 0))
205- "Write the order out. Off the caller's thread: opening a channel should not205+
206- wait on a file, and the order it records is read from the same atom either206+(defn- remember-rooms!
207- way."207+ "Write the room records out: what rooms there are, in the order they were
208- []208+ last used, and how much of each has been read.
209- (future (store/save-channels! (channel-order))))209+
210+ Off the caller's thread, because opening a room should not wait on a file.
211+ Throttled, because the marker moves on every line that arrives while a room
212+ is on screen and a busy channel would otherwise write the file per message
213+ `force?` is for the moments worth paying for, which is a room being opened.
214+
215+ A late write costs at most the handful of lines that arrived since the last
216+ one, shown unread again on the next run. That is the right way round: the
217+ marker never claims to have read more than it has."
218+ ([] (remember-rooms! false))
219+ ([force?]
220+ (let [now (clock/now-ms)]
221+ (when (or force? (> (- now @rooms-saved-at) 5000))
222+ (reset! rooms-saved-at now)
223+ (future (store/save-rooms! (room-records)))))))
210 224
211 (defn dm?225 (defn dm?
212 "Whether a buffer is a conversation with a person rather than a room. Every226 "Whether a buffer is a conversation with a person rather than a room. Every
@@ -220,11 +234,73 @@
220 (str/starts-with? s "#") s234 (str/starts-with? s "#") s
221 :else (str "#" s))))235 :else (str "#" s))))
222 236
237+(defn- after-marker
238+ "The messages in `buffer` the reader has not seen: everything after its read
239+ marker.
240+
241+ By id where the marked message is still held, and by time otherwise. The id
242+ is the exact answer a msgid survives every revision, so it names the same
243+ line however often the server replays it and the timestamp is what answers
244+ when the marked line has fallen off the end of the buffer or was never in
245+ this run's copy of it.
246+
247+ Derived rather than counted, because a count cannot survive what the server
248+ does: a JOIN replays the backlog and CHATHISTORY replays it again, and every
249+ line of it would tick a counter a second time. Against a marker a replayed
250+ line is simply older than it and counts for nothing."
251+ [buffer]
252+ (let [id (:last-read-id buffer)
253+ at (:last-read-at buffer 0)
254+ msgs (vec (:messages buffer))]
255+ (if (and id (some #(= id (:id %)) msgs))
256+ (vec (rest (drop-while #(not= id (:id %)) msgs)))
257+ (filterv #(> (:at % 0) at) msgs))))
258+
259+(defn- mentions-me?
260+ "Whether a line is addressed at the reader by name. Our own lines do not
261+ count saying your own nick is not being called."
262+ [m]
263+ (let [me (str/trim (or @form-nick ""))]
264+ (and (seq me)
265+ (not= (:from m) me)
266+ (str/includes? (str/lower-case (or (:text m) ""))
267+ (str/lower-case me)))))
268+
269+(defn- recount
270+ "Answer what the marker says: how many lines are unseen, and whether any of
271+ them names the reader."
272+ [buffer]
273+ (let [fresh (after-marker buffer)]
274+ (assoc buffer
275+ :unread (count fresh)
276+ :mention? (boolean (some mentions-me? fresh)))))
277+
278+(defn- mark-read
279+ "Move the marker to the newest line this buffer holds. Both halves: the id
280+ for as long as that line is here, and its time for after it is gone.
281+
282+ The time only ever goes forward. A backlog can arrive after the reader has
283+ already read past it, and taking the last line's time unconditionally would
284+ walk the marker backwards and re-unread what was read."
285+ [buffer]
286+ (let [newest (last (:messages buffer))]
287+ (assoc buffer
288+ :unread 0
289+ :mention? false
290+ :last-read-id (:id newest)
291+ :last-read-at (max (:last-read-at buffer 0) (:at newest 0)))))
292+
223 (defn- ensure-channel [m name]293 (defn- ensure-channel [m name]
224 (if (contains? m name)294 (if (contains? m name)
225 m295 m
226 (assoc m name {:name name :messages [] :unread 0296 (assoc m name {:name name :messages [] :unread 0
227 :joined? false :joining? false :accessed 0297 :joined? false :joining? false :accessed 0
298+ ;; What has been seen, and what the count is derived from.
299+ ;; `:unread` and `:mention?` are answers, not records see
300+ ;; `recount`.
301+ :last-read-id nil :last-read-at 0 :mention? false
302+ :kind (if (dm? name) :dm :channel)
303+ :peer-did nil :last-activity 0
228 ;; nick -> mode prefix, for the people panel304 ;; nick -> mode prefix, for the people panel
229 :users {}})))305 :users {}})))
230 306
@@ -298,8 +374,20 @@
298 :edited? (boolean edited?)374 :edited? (boolean edited?)
299 ;; emoji -> the nicks who put it there375 ;; emoji -> the nicks who put it there
300 :reactions (or reactions {})})376 :reactions (or reactions {})})
301- (update-in [channel :unread]377+ (assoc-in [channel :last-activity] at)
302- (if viewing? (constantly 0) inc))))))))))378+ ;; A DM is a room named after whoever is in it, and a
379+ ;; nick is not a name that lasts. The DID is, so the
380+ ;; record keeps it the first time the other end says
381+ ;; anything ours would name the wrong side.
382+ (cond-> (and (dm? channel) did (not= from @form-nick))
383+ (assoc-in [channel :peer-did] did))
384+ ;; Reading a room *is* marking it read: a line that
385+ ;; arrives while it is on screen moves the marker past
386+ ;; itself. Everything else re-derives, so a line arriving
387+ ;; in a room nobody is looking at costs a recount of that
388+ ;; room and nothing more.
389+ (update channel (if viewing? mark-read recount))))))))
390+ (remember-rooms!)))
303 391
304 (defn open-channel!392 (defn open-channel!
305 "Show a buffer. A channel we are not in is joined on the way — a row can393 "Show a buffer. A channel we are not in is joined on the way — a row can
@@ -320,9 +408,9 @@
320 (reset! editing nil)408 (reset! editing nil)
321 (reset! draft ""))409 (reset! draft ""))
322 (swap! channels #(-> (ensure-channel % name)410 (swap! channels #(-> (ensure-channel % name)
323- (assoc-in [name :unread] 0)411+ (update name mark-read)
324 (assoc-in [name :accessed] (swap! access-tick inc))))412 (assoc-in [name :accessed] (swap! access-tick inc))))
325- (remember-channels!)413+ (remember-rooms! true)
326 ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a414 ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
327 ;; second JOIN sent in the meantime is what makes the server replay nothing.415 ;; second JOIN sent in the meantime is what makes the server replay nothing.
328 (let [buffer (get @channels name)]416 (let [buffer (get @channels name)]
@@ -1499,24 +1587,65 @@
1499 (sort-by #(- (:accessed % 0)))1587 (sort-by #(- (:accessed % 0)))
1500 (mapv :name)))1588 (mapv :name)))
1501 1589
1590+(defn room-records
1591+ "The rooms as they go to disk: what each one is, when it last said anything,
1592+ and how far into it the reader has got.
1593+
1594+ `:unread` and `:mention?` are not written. They are what the marker adds up
1595+ to against the messages in hand, and a count written down is a count that can
1596+ be wrong the marker cannot be. `:mention?` rides along all the same, as the
1597+ one thing that cannot be recomputed before the history it was derived from
1598+ comes back: a room that had your name in it says so on the next run's first
1599+ frame rather than a round trip later, and is corrected by `recount` the
1600+ moment the backlog lands."
1601+ []
1602+ (->> (vals @channels)
1603+ (filter #(pos? (:accessed % 0)))
1604+ (sort-by #(- (:accessed % 0)))
1605+ (mapv #(select-keys % [:name :kind :peer-did :last-activity
1606+ :last-read-id :last-read-at :mention?]))))
1607+
1502 (defn restore-channels!1608 (defn restore-channels!
1503- "Bring back the channels of earlier runs, in the order they were last used.1609+ "Bring back the rooms of earlier runs, in the order they were last used, each
1610+ with the marker saying how much of it had been read.
1504 1611
1505 Empty buffers, not memberships: opening one is what joins it, and a list of1612 Empty buffers, not memberships: opening one is what joins it, and a list of
1506 rooms is the part worth keeping the messages in them come from the server.1613 rooms is the part worth keeping the messages in them come from the server.
1507- The tick is seeded so this run's first open still sorts above all of them."1614+ The tick is seeded so this run's first open still sorts above all of them.
1615+
1616+ The marker is what makes the returning backlog readable. Without one every
1617+ replayed line is new and every room comes back with its whole history
1618+ unread; with one, the reader is put back where they were and only what
1619+ arrived while they were away is counted. A room migrated from an older frq
1620+ has no marker and is caught up as if read, which is the kinder of the two
1621+ wrong answers the alternative announces a hundred unread lines the reader
1622+ has already seen."
1508 []1623 []
1509- (when-let [saved (seq (store/load-channels))]1624+ (when-let [saved (seq (store/load-rooms))]
1510 (let [ordered (reverse saved)] ; oldest first, so ticks ascend1625 (let [ordered (reverse saved)] ; oldest first, so ticks ascend
1511 (swap! channels1626 (swap! channels
1512 (fn [m]1627 (fn [m]
1513- (reduce (fn [acc name]1628+ (reduce (fn [acc room]
1514- (if (contains? acc name)1629+ (let [name (:name room)]
1515- acc1630+ (if (contains? acc name)
1516- (assoc acc name {:name name :messages [] :unread 01631+ acc
1517- :joined? false :joining? false1632+ (assoc acc name
1518- :users {}1633+ {:name name :messages [] :unread 0
1519- :accessed (swap! access-tick inc)})))1634+ :joined? false :joining? false
1635+ :users {}
1636+ :kind (or (:kind room)
1637+ (if (dm? name) :dm :channel))
1638+ :peer-did (:peer-did room)
1639+ :last-activity (:last-activity room 0)
1640+ :last-read-id (:last-read-id room)
1641+ ;; No marker at all an older frq's list,
1642+ ;; or a record that lost it. Read up to
1643+ ;; now rather than back to the beginning.
1644+ :last-read-at (:last-read-at room
1645+ (clock/now-ms))
1646+ :mention? (boolean (:mention? room))
1647+ :accessed (swap! access-tick inc)})))
1648+ )
1520 m1649 m
1521 ordered))))1650 ordered))))
1522 (count saved)))1651 (count saved)))
modified src/frq/store.jolt +32 -7
@@ -45,9 +45,9 @@
4545 (defn channels-file [] (str (config-dir) "/channels.edn"))
4646
4747 (defn load-channels
48- "The channels this client has opened, in the order it last used them. Unlike
49- the session beside it this is not a credential — just names — so it is an
50- ordinary file."
48+ "The channels an older frq wrote: names alone, most recently used first.
49+ Read only to migrate them into `rooms.edn`, which says the same and more —
50+ nothing writes this file any more."
5151 []
5252 (let [path (channels-file)]
5353 (when (host/file-exists? path)
@@ -56,12 +56,37 @@
5656 (when (vector? v) (filterv string? v)))
5757 (catch Exception _ nil)))))
5858
59-(defn save-channels!
60- "Write the channel names, most recently used first."
61- [names]
59+(defn rooms-file [] (str (config-dir) "/rooms.edn"))
60+
61+(defn load-rooms
62+ "The rooms this client knows, most recently used first, each carrying the
63+ read marker that says how much of it has been seen.
64+
65+ This is the authority for what rooms exist. The server forgets them — it has
66+ told us we are in rooms we are not and left out ones we are — so a room is
67+ gone when the reader closes it here and not before.
68+
69+ A record that will not parse is dropped rather than defaulted: a room with a
70+ broken marker would count its whole history unread, which is worse than a
71+ room that starts over. An older frq's `channels.edn` migrates in as names
72+ with no marker, which is exactly what it knew."
73+ []
74+ (let [path (rooms-file)]
75+ (or (when (host/file-exists? path)
76+ (try
77+ (let [v (edn/read-string (slurp path))]
78+ (when (vector? v)
79+ (filterv #(and (map? %) (string? (:name %)) (seq (:name %))) v)))
80+ (catch Exception _ nil)))
81+ (when-let [names (seq (load-channels))]
82+ (mapv (fn [n] {:name n}) names)))))
83+
84+(defn save-rooms!
85+ "Write the room records, most recently used first."
86+ [rooms]
6287 (try
6388 (host/mkdirs! (config-dir))
64- (spit (channels-file) (pr-str (vec names)))
89+ (spit (rooms-file) (pr-str (vec rooms)))
6590 true
6691 (catch Exception _ false)))
6792
@@ -45,9 +45,9 @@
45 (defn channels-file [] (str (config-dir) "/channels.edn"))45 (defn channels-file [] (str (config-dir) "/channels.edn"))
46 46
47 (defn load-channels47 (defn load-channels
48- "The channels this client has opened, in the order it last used them. Unlike48+ "The channels an older frq wrote: names alone, most recently used first.
49- the session beside it this is not a credential — just names — so it is an49+ Read only to migrate them into `rooms.edn`, which says the same and more —
50- ordinary file."50+ nothing writes this file any more."
51 []51 []
52 (let [path (channels-file)]52 (let [path (channels-file)]
53 (when (host/file-exists? path)53 (when (host/file-exists? path)
@@ -56,12 +56,37 @@
56 (when (vector? v) (filterv string? v)))56 (when (vector? v) (filterv string? v)))
57 (catch Exception _ nil)))))57 (catch Exception _ nil)))))
58 58
59-(defn save-channels!59+(defn rooms-file [] (str (config-dir) "/rooms.edn"))
60- "Write the channel names, most recently used first."60+
61- [names]61+(defn load-rooms
62+ "The rooms this client knows, most recently used first, each carrying the
63+ read marker that says how much of it has been seen.
64+
65+ This is the authority for what rooms exist. The server forgets them — it has
66+ told us we are in rooms we are not and left out ones we are — so a room is
67+ gone when the reader closes it here and not before.
68+
69+ A record that will not parse is dropped rather than defaulted: a room with a
70+ broken marker would count its whole history unread, which is worse than a
71+ room that starts over. An older frq's `channels.edn` migrates in as names
72+ with no marker, which is exactly what it knew."
73+ []
74+ (let [path (rooms-file)]
75+ (or (when (host/file-exists? path)
76+ (try
77+ (let [v (edn/read-string (slurp path))]
78+ (when (vector? v)
79+ (filterv #(and (map? %) (string? (:name %)) (seq (:name %))) v)))
80+ (catch Exception _ nil)))
81+ (when-let [names (seq (load-channels))]
82+ (mapv (fn [n] {:name n}) names)))))
83+
84+(defn save-rooms!
85+ "Write the room records, most recently used first."
86+ [rooms]
62 (try87 (try
63 (host/mkdirs! (config-dir))88 (host/mkdirs! (config-dir))
64- (spit (channels-file) (pr-str (vec names)))89+ (spit (rooms-file) (pr-str (vec rooms)))
65 true90 true
66 (catch Exception _ false)))91 (catch Exception _ false)))
67 92