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>
234c54f parent: ca5b5a3 modified
src/frq/app.jolt +5 -1 | @@ -200,7 +200,11 @@ | ||
| 200 | 200 | (:joined? buffer) "joined" |
| 201 | 201 | :else "not joined") |
| 202 | 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 | 208 | [:dim-label {:label (s/last-preview buffer)}] |
| 205 | 209 | (when-not @terminal? [open-button name])])) |
| 206 | 210 | |
| @@ -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 @@ | ||
| 199 | 199 | |
| 200 | 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! | |
| 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))))))) | |
| 210 | 224 | |
| 211 | 225 | (defn dm? |
| 212 | 226 | "Whether a buffer is a conversation with a person rather than a room. Every |
| @@ -220,11 +234,73 @@ | ||
| 220 | 234 | (str/starts-with? s "#") s |
| 221 | 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 | 293 | (defn- ensure-channel [m name] |
| 224 | 294 | (if (contains? m name) |
| 225 | 295 | m |
| 226 | 296 | (assoc m name {:name name :messages [] :unread 0 |
| 227 | 297 | :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 | 304 | ;; nick -> mode prefix, for the people panel |
| 229 | 305 | :users {}}))) |
| 230 | 306 | |
| @@ -298,8 +374,20 @@ | ||
| 298 | 374 | :edited? (boolean edited?) |
| 299 | 375 | ;; emoji -> the nicks who put it there |
| 300 | 376 | :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!))) | |
| 303 | 391 | |
| 304 | 392 | (defn open-channel! |
| 305 | 393 | "Show a buffer. A channel we are not in is joined on the way — a row can |
| @@ -320,9 +408,9 @@ | ||
| 320 | 408 | (reset! editing nil) |
| 321 | 409 | (reset! draft "")) |
| 322 | 410 | (swap! channels #(-> (ensure-channel % name) |
| 323 | - (assoc-in [name :unread] 0) | |
| 411 | + (update name mark-read) | |
| 324 | 412 | (assoc-in [name :accessed] (swap! access-tick inc)))) |
| 325 | - (remember-channels!) | |
| 413 | + (remember-rooms! true) | |
| 326 | 414 | ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a |
| 327 | 415 | ;; second JOIN sent in the meantime is what makes the server replay nothing. |
| 328 | 416 | (let [buffer (get @channels name)] |
| @@ -1499,24 +1587,65 @@ | ||
| 1499 | 1587 | (sort-by #(- (:accessed % 0))) |
| 1500 | 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 | 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 | 1612 | Empty buffers, not memberships: opening one is what joins it, and a list of |
| 1506 | 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 | 1625 | (let [ordered (reverse saved)] ; oldest first, so ticks ascend |
| 1511 | 1626 | (swap! channels |
| 1512 | 1627 | (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 | + ) | |
| 1520 | 1649 | m |
| 1521 | 1650 | ordered)))) |
| 1522 | 1651 | (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 not | 205 | + |
| 206 | - wait on a file, and the order it records is read from the same atom either | 206 | +(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. Every | 226 | "Whether a buffer is a conversation with a person rather than a room. Every |
| @@ -220,11 +234,73 @@ | |||
| 220 | (str/starts-with? s "#") s | 234 | (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 | m | 295 | m |
| 226 | (assoc m name {:name name :messages [] :unread 0 | 296 | (assoc m name {:name name :messages [] :unread 0 |
| 227 | :joined? false :joining? false :accessed 0 | 297 | :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 panel | 304 | ;; 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 there | 375 | ;; 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 can | 393 | "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 a | 414 | ;; `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 of | 1612 | 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 ascend | 1625 | (let [ordered (reverse saved)] ; oldest first, so ticks ascend |
| 1511 | (swap! channels | 1626 | (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 | - acc | 1630 | + (if (contains? acc name) |
| 1516 | - (assoc acc name {:name name :messages [] :unread 0 | 1631 | + acc |
| 1517 | - :joined? false :joining? false | 1632 | + (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 | m | 1649 | m |
| 1521 | ordered)))) | 1650 | ordered)))) |
| 1522 | (count saved))) | 1651 | (count saved))) |
modified
src/frq/store.jolt +32 -7 | @@ -45,9 +45,9 @@ | ||
| 45 | 45 | (defn channels-file [] (str (config-dir) "/channels.edn")) |
| 46 | 46 | |
| 47 | 47 | (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." | |
| 51 | 51 | [] |
| 52 | 52 | (let [path (channels-file)] |
| 53 | 53 | (when (host/file-exists? path) |
| @@ -56,12 +56,37 @@ | ||
| 56 | 56 | (when (vector? v) (filterv string? v))) |
| 57 | 57 | (catch Exception _ nil))))) |
| 58 | 58 | |
| 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] | |
| 62 | 87 | (try |
| 63 | 88 | (host/mkdirs! (config-dir)) |
| 64 | - (spit (channels-file) (pr-str (vec names))) | |
| 89 | + (spit (rooms-file) (pr-str (vec rooms))) | |
| 65 | 90 | true |
| 66 | 91 | (catch Exception _ false))) |
| 67 | 92 | |
| @@ -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-channels | 47 | (defn load-channels |
| 48 | - "The channels this client has opened, in the order it last used them. Unlike | 48 | + "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 an | 49 | + 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 | (try | 87 | (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 | true | 90 | true |
| 66 | (catch Exception _ false))) | 91 | (catch Exception _ false))) |
| 67 | 92 | ||