Merge remote-tracking branch 'origin/main' into worktree-clickable-links
# Conflicts: # src/frq/app.jolt # src/frq/irc.jolt # src/frq/state.jolt
modified
README.md +7 -1 | @@ -17,6 +17,7 @@ src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | ||
| 17 | 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory |
| 18 | 18 | src/frq/avatars.jolt profile pictures, by DID or handle |
| 19 | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 20 | +src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings | |
| 20 | 21 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 21 | 22 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 22 | 23 | src/frq/app.jolt the screens |
| @@ -59,7 +60,8 @@ job: POST the fragment back to itself. What comes back is a single-use SASL | ||
| 59 | 60 | from the durable one at `/session` and skip the browser. |
| 60 | 61 | |
| 61 | 62 | The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so |
| 62 | -a restart resumes without one, along with the handle and nick it belongs to. | |
| 63 | +a restart resumes without one, along with the handle and nick it belongs to — | |
| 64 | +and it connects on its own at launch when one is there. | |
| 63 | 65 | The web-token beside it is single-use and deliberately not saved. A token the |
| 64 | 66 | broker no longer honours is dropped — from disk and memory — and the browser |
| 65 | 67 | flow runs once more, rather than failing the same way on every Connect. |
| @@ -104,10 +106,14 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 104 | 106 | * Auto-joins `#test` on `irc.freeq.at` |
| 105 | 107 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` |
| 106 | 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 109 | +* Twelve-hour timestamps from the server's own clock, with a heading wherever | |
| 110 | + the day changes | |
| 107 | 111 | * Inline previews for PNG links, fetched once and cached under |
| 108 | 112 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 109 | 113 | * Join/part notices, DMs bucketed under the sender's nick |
| 110 | 114 | * Discover list, search over buffers, disconnect |
| 115 | +* The rooms you have opened, remembered across runs and listed in the order | |
| 116 | + you last used them (`$XDG_CONFIG_HOME/frq/channels.edn`) | |
| 111 | 117 | * Conversations listed most recently opened first |
| 112 | 118 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 113 | 119 | message with |
| @@ -17,6 +17,7 @@ src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | |||
| 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory | 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory |
| 18 | src/frq/avatars.jolt profile pictures, by DID or handle | 18 | src/frq/avatars.jolt profile pictures, by DID or handle |
| 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 20 | +src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings | ||
| 20 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 21 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 21 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | 22 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 22 | src/frq/app.jolt the screens | 23 | src/frq/app.jolt the screens |
| @@ -59,7 +60,8 @@ job: POST the fragment back to itself. What comes back is a single-use SASL | |||
| 59 | from the durable one at `/session` and skip the browser. | 60 | from the durable one at `/session` and skip the browser. |
| 60 | 61 | ||
| 61 | The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so | 62 | The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so |
| 62 | -a restart resumes without one, along with the handle and nick it belongs to. | 63 | +a restart resumes without one, along with the handle and nick it belongs to — |
| 64 | +and it connects on its own at launch when one is there. | ||
| 63 | The web-token beside it is single-use and deliberately not saved. A token the | 65 | The web-token beside it is single-use and deliberately not saved. A token the |
| 64 | broker no longer honours is dropped — from disk and memory — and the browser | 66 | broker no longer honours is dropped — from disk and memory — and the browser |
| 65 | flow runs once more, rather than failing the same way on every Connect. | 67 | flow runs once more, rather than failing the same way on every Connect. |
| @@ -104,10 +106,14 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 104 | * Auto-joins `#test` on `irc.freeq.at` | 106 | * Auto-joins `#test` on `irc.freeq.at` |
| 105 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` | 107 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` |
| 106 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead | 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 109 | +* Twelve-hour timestamps from the server's own clock, with a heading wherever | ||
| 110 | + the day changes | ||
| 107 | * Inline previews for PNG links, fetched once and cached under | 111 | * Inline previews for PNG links, fetched once and cached under |
| 108 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size | 112 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 109 | * Join/part notices, DMs bucketed under the sender's nick | 113 | * Join/part notices, DMs bucketed under the sender's nick |
| 110 | * Discover list, search over buffers, disconnect | 114 | * Discover list, search over buffers, disconnect |
| 115 | +* The rooms you have opened, remembered across runs and listed in the order | ||
| 116 | + you last used them (`$XDG_CONFIG_HOME/frq/channels.edn`) | ||
| 111 | * Conversations listed most recently opened first | 117 | * Conversations listed most recently opened first |
| 112 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each | 118 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 113 | message with | 119 | message with |
modified
src/frq/app.jolt +42 -10 | @@ -9,6 +9,7 @@ | ||
| 9 | 9 | [glimmer.core :as ui] |
| 10 | 10 | [glimmer-vidya.core :as vidya] |
| 11 | 11 | [frq.avatars :as avatars] |
| 12 | + [frq.clock :as clock] | |
| 12 | 13 | [frq.media :as media] |
| 13 | 14 | [frq.platform :as platform] |
| 14 | 15 | [frq.state :as s])) |
| @@ -231,11 +232,13 @@ | ||
| 231 | 232 | (= (:from prev) (:from m)))] |
| 232 | 233 | [:vbox {:key i :spacing 2 :margin 0} |
| 233 | 234 | ;; A run from one person reads as one block; repeating the nick on every |
| 234 | - ;; line is what made a busy channel look like a list of headers. | |
| 235 | + ;; line is what made a busy channel look like a list of headers. The time | |
| 236 | + ;; rides on that same line, for the same reason: once per run, not once | |
| 237 | + ;; per line. | |
| 235 | 238 | [:vbox {:key :who} |
| 236 | 239 | (when-not (or same-sender? (:system? m)) |
| 237 | - ;; The picture sits beside the name rather than beside the message: a | |
| 238 | - ;; run of lines from one person has one header, and this belongs to it. | |
| 240 | + ;; Everything that is about the person rather than the line: their | |
| 241 | + ;; picture, their name, and when they started saying this. | |
| 239 | 242 | [:hbox {:spacing 6} |
| 240 | 243 | ;; Always an avatar, picture or not: the initial stands in until the |
| 241 | 244 | ;; fetch lands, and for the guests who have no profile at all, which |
| @@ -245,7 +248,9 @@ | ||
| 245 | 248 | [:avatar {:label (:from m) |
| 246 | 249 | :src (or (avatars/path-when-ready (:actor m)) "") |
| 247 | 250 | :size 22}]) |
| 248 | - [:dim-label {:label (:from m)}]])] | |
| 251 | + [:dim-label {:label (:from m)}] | |
| 252 | + (when-let [at (:at m)] | |
| 253 | + [:dim-label {:label (clock/clock-time at)}])])] | |
| 249 | 254 | [:vbox {:key :text :spacing 2} |
| 250 | 255 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 251 | 256 | (text-runs (:text m)))] |
| @@ -262,9 +267,28 @@ | ||
| 262 | 267 | :max-height 260 |
| 263 | 268 | :on-click #(reset! s/lightbox {:path path :url url})}]))))]])) |
| 264 | 269 | |
| 265 | -(defn- message-rows [messages] | |
| 266 | - (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m]) | |
| 267 | - messages)) | |
| 270 | +(defn- day-separator [day-key label] | |
| 271 | + [:vbox {:key day-key :spacing 4 :margin 0} | |
| 272 | + [:separator {}] | |
| 273 | + [:dim-label {:label label}]]) | |
| 274 | + | |
| 275 | +(defn- message-rows | |
| 276 | + "The messages, with a heading wherever the day changes. | |
| 277 | + | |
| 278 | + A backlog can reach back weeks, and `11:04 AM` says nothing about which day | |
| 279 | + it was. The heading is what makes the time above it mean something." | |
| 280 | + [messages] | |
| 281 | + (mapcat (fn [i m] | |
| 282 | + (let [prev (when (pos? i) (nth messages (dec i))) | |
| 283 | + day (some-> (:at m) clock/day) | |
| 284 | + new-day? (and day (not= day (some-> (:at prev) clock/day)))] | |
| 285 | + (cond-> [] | |
| 286 | + new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m)))) | |
| 287 | + ;; A new day breaks the run: the first line under a heading | |
| 288 | + ;; names its sender and its time, whoever spoke last night. | |
| 289 | + true (conj [message-row i (when-not new-day? prev) m])))) | |
| 290 | + (range (count messages)) | |
| 291 | + messages)) | |
| 268 | 292 | |
| 269 | 293 | (defn lightbox-screen |
| 270 | 294 | "One picture, as big as the window allows. |
| @@ -377,7 +401,15 @@ | ||
| 377 | 401 | [chats-screen]))) |
| 378 | 402 | |
| 379 | 403 | (defn -main [& _] |
| 380 | - ;; Before the window: a saved sign-in decides which mode the connect screen | |
| 381 | - ;; opens in, and what it says. | |
| 382 | - (s/restore-session!) | |
| 404 | + ;; Before the window: the rooms this client has been in, and a saved sign-in | |
| 405 | + ;; deciding which mode the connect screen opens in and what it says. | |
| 406 | + (s/restore-channels!) | |
| 407 | + (when (s/restore-session!) | |
| 408 | + ;; And then it connects on its own. A remembered account has already said | |
| 409 | + ;; what it wants; making it say so again at every launch is a click that | |
| 410 | + ;; carries no information. It is a timer rather than a call here so the | |
| 411 | + ;; window is up first — the connect screen with its status is what the | |
| 412 | + ;; user should be looking at while this happens, and if it fails, the | |
| 413 | + ;; error lands somewhere visible. | |
| 414 | + (vidya/after! 150 s/connect!)) | |
| 383 | 415 | (ui/run app :title "frq" :width 520 :height 860)) |
| @@ -9,6 +9,7 @@ | |||
| 9 | [glimmer.core :as ui] | 9 | [glimmer.core :as ui] |
| 10 | [glimmer-vidya.core :as vidya] | 10 | [glimmer-vidya.core :as vidya] |
| 11 | [frq.avatars :as avatars] | 11 | [frq.avatars :as avatars] |
| 12 | + [frq.clock :as clock] | ||
| 12 | [frq.media :as media] | 13 | [frq.media :as media] |
| 13 | [frq.platform :as platform] | 14 | [frq.platform :as platform] |
| 14 | [frq.state :as s])) | 15 | [frq.state :as s])) |
| @@ -231,11 +232,13 @@ | |||
| 231 | (= (:from prev) (:from m)))] | 232 | (= (:from prev) (:from m)))] |
| 232 | [:vbox {:key i :spacing 2 :margin 0} | 233 | [:vbox {:key i :spacing 2 :margin 0} |
| 233 | ;; A run from one person reads as one block; repeating the nick on every | 234 | ;; A run from one person reads as one block; repeating the nick on every |
| 234 | - ;; line is what made a busy channel look like a list of headers. | 235 | + ;; line is what made a busy channel look like a list of headers. The time |
| 236 | + ;; rides on that same line, for the same reason: once per run, not once | ||
| 237 | + ;; per line. | ||
| 235 | [:vbox {:key :who} | 238 | [:vbox {:key :who} |
| 236 | (when-not (or same-sender? (:system? m)) | 239 | (when-not (or same-sender? (:system? m)) |
| 237 | - ;; The picture sits beside the name rather than beside the message: a | 240 | + ;; Everything that is about the person rather than the line: their |
| 238 | - ;; run of lines from one person has one header, and this belongs to it. | 241 | + ;; picture, their name, and when they started saying this. |
| 239 | [:hbox {:spacing 6} | 242 | [:hbox {:spacing 6} |
| 240 | ;; Always an avatar, picture or not: the initial stands in until the | 243 | ;; Always an avatar, picture or not: the initial stands in until the |
| 241 | ;; fetch lands, and for the guests who have no profile at all, which | 244 | ;; fetch lands, and for the guests who have no profile at all, which |
| @@ -245,7 +248,9 @@ | |||
| 245 | [:avatar {:label (:from m) | 248 | [:avatar {:label (:from m) |
| 246 | :src (or (avatars/path-when-ready (:actor m)) "") | 249 | :src (or (avatars/path-when-ready (:actor m)) "") |
| 247 | :size 22}]) | 250 | :size 22}]) |
| 248 | - [:dim-label {:label (:from m)}]])] | 251 | + [:dim-label {:label (:from m)}] |
| 252 | + (when-let [at (:at m)] | ||
| 253 | + [:dim-label {:label (clock/clock-time at)}])])] | ||
| 249 | [:vbox {:key :text :spacing 2} | 254 | [:vbox {:key :text :spacing 2} |
| 250 | (map-indexed (fn [j run] (run-node j run (:system? m))) | 255 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 251 | (text-runs (:text m)))] | 256 | (text-runs (:text m)))] |
| @@ -262,9 +267,28 @@ | |||
| 262 | :max-height 260 | 267 | :max-height 260 |
| 263 | :on-click #(reset! s/lightbox {:path path :url url})}]))))]])) | 268 | :on-click #(reset! s/lightbox {:path path :url url})}]))))]])) |
| 264 | 269 | ||
| 265 | -(defn- message-rows [messages] | 270 | +(defn- day-separator [day-key label] |
| 266 | - (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m]) | 271 | + [:vbox {:key day-key :spacing 4 :margin 0} |
| 267 | - messages)) | 272 | + [:separator {}] |
| 273 | + [:dim-label {:label label}]]) | ||
| 274 | + | ||
| 275 | +(defn- message-rows | ||
| 276 | + "The messages, with a heading wherever the day changes. | ||
| 277 | + | ||
| 278 | + A backlog can reach back weeks, and `11:04 AM` says nothing about which day | ||
| 279 | + it was. The heading is what makes the time above it mean something." | ||
| 280 | + [messages] | ||
| 281 | + (mapcat (fn [i m] | ||
| 282 | + (let [prev (when (pos? i) (nth messages (dec i))) | ||
| 283 | + day (some-> (:at m) clock/day) | ||
| 284 | + new-day? (and day (not= day (some-> (:at prev) clock/day)))] | ||
| 285 | + (cond-> [] | ||
| 286 | + new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m)))) | ||
| 287 | + ;; A new day breaks the run: the first line under a heading | ||
| 288 | + ;; names its sender and its time, whoever spoke last night. | ||
| 289 | + true (conj [message-row i (when-not new-day? prev) m])))) | ||
| 290 | + (range (count messages)) | ||
| 291 | + messages)) | ||
| 268 | 292 | ||
| 269 | (defn lightbox-screen | 293 | (defn lightbox-screen |
| 270 | "One picture, as big as the window allows. | 294 | "One picture, as big as the window allows. |
| @@ -377,7 +401,15 @@ | |||
| 377 | [chats-screen]))) | 401 | [chats-screen]))) |
| 378 | 402 | ||
| 379 | (defn -main [& _] | 403 | (defn -main [& _] |
| 380 | - ;; Before the window: a saved sign-in decides which mode the connect screen | 404 | + ;; Before the window: the rooms this client has been in, and a saved sign-in |
| 381 | - ;; opens in, and what it says. | 405 | + ;; deciding which mode the connect screen opens in and what it says. |
| 382 | - (s/restore-session!) | 406 | + (s/restore-channels!) |
| 407 | + (when (s/restore-session!) | ||
| 408 | + ;; And then it connects on its own. A remembered account has already said | ||
| 409 | + ;; what it wants; making it say so again at every launch is a click that | ||
| 410 | + ;; carries no information. It is a timer rather than a call here so the | ||
| 411 | + ;; window is up first — the connect screen with its status is what the | ||
| 412 | + ;; user should be looking at while this happens, and if it fails, the | ||
| 413 | + ;; error lands somewhere visible. | ||
| 414 | + (vidya/after! 150 s/connect!)) | ||
| 383 | (ui/run app :title "frq" :width 520 :height 860)) | 415 | (ui/run app :title "frq" :width 520 :height 860)) |
added
src/frq/clock.jolt +74 -0 | new file mode 100644 | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +(ns frq.clock | |
| 2 | + "Wall-clock time, in the reader's own zone. | |
| 3 | + | |
| 4 | + Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time. | |
| 5 | + jolt's time library works in epoch days and nanoseconds-of-day, and the | |
| 6 | + offset needs a zone name, which no single call hands over — so the zone is | |
| 7 | + found once here and everything else is arithmetic." | |
| 8 | + (:require [clojure.string :as str] | |
| 9 | + [jolt.host :as host] | |
| 10 | + [jolt.time.local :as local])) | |
| 11 | + | |
| 12 | +(def ^:private zone | |
| 13 | + ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise | |
| 14 | + ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing. | |
| 15 | + (delay | |
| 16 | + (or (let [tz (host/getenv "TZ")] (when (seq tz) tz)) | |
| 17 | + (try | |
| 18 | + (second (re-find #"/zoneinfo/(.+)$" | |
| 19 | + (str/trim (str (host/sh-out "readlink -f /etc/localtime"))))) | |
| 20 | + (catch Exception _ nil)) | |
| 21 | + "UTC"))) | |
| 22 | + | |
| 23 | +(defn now-ms [] (quot (host/wall-nanos) 1000000)) | |
| 24 | + | |
| 25 | +(defn parse-time-tag | |
| 26 | + "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil. | |
| 27 | + | |
| 28 | + Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather | |
| 29 | + than through a parser, since this runs once per message of a hundred-message | |
| 30 | + backlog." | |
| 31 | + [tags] | |
| 32 | + (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" | |
| 33 | + (or tags ""))] | |
| 34 | + (let [[y mo d h mi s] (map parse-long [y mo d h mi s]) | |
| 35 | + ;; days from the civil date, by Howard Hinnant's算 — the same one | |
| 36 | + ;; jolt's own local-date uses in reverse. | |
| 37 | + y (if (<= mo 2) (dec y) y) | |
| 38 | + era (quot (if (>= y 0) y (- y 399)) 400) | |
| 39 | + yoe (- y (* era 400)) | |
| 40 | + doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d)) | |
| 41 | + doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy) | |
| 42 | + days (+ (* era 146097) doe -719468)] | |
| 43 | + (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s))))) | |
| 44 | + | |
| 45 | +(defn- local-parts | |
| 46 | + "`[date hour minute]` in the reader's zone, the hour on a 24-clock." | |
| 47 | + [ms] | |
| 48 | + (let [secs (quot ms 1000) | |
| 49 | + secs (+ secs (host/tz-offset-seconds @zone secs)) | |
| 50 | + text (str (local/local-dt (Math/floorDiv secs 86400) | |
| 51 | + (* (Math/floorMod secs 86400) 1000000000)))] | |
| 52 | + [(subs text 0 10) (parse-long (subs text 11 13)) (subs text 14 16)])) | |
| 53 | + | |
| 54 | +(defn clock-time | |
| 55 | + "A twelve-hour time: `9:05 AM`, `12:30 PM`." | |
| 56 | + [ms] | |
| 57 | + (let [[_ hour minute] (local-parts ms) | |
| 58 | + display (cond (zero? hour) 12 | |
| 59 | + (> hour 12) (- hour 12) | |
| 60 | + :else hour)] | |
| 61 | + (str display ":" minute " " (if (< hour 12) "AM" "PM")))) | |
| 62 | + | |
| 63 | +(defn day [ms] (first (local-parts ms))) | |
| 64 | + | |
| 65 | +(defn day-label | |
| 66 | + "The heading for a day's messages: today and yesterday by name, anything | |
| 67 | + older by date." | |
| 68 | + [ms] | |
| 69 | + (let [d (day ms) | |
| 70 | + today (day (now-ms)) | |
| 71 | + yesterday (day (- (now-ms) 86400000))] | |
| 72 | + (cond (= d today) "Today" | |
| 73 | + (= d yesterday) "Yesterday" | |
| 74 | + :else d))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | +(ns frq.clock | ||
| 2 | + "Wall-clock time, in the reader's own zone. | ||
| 3 | + | ||
| 4 | + Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time. | ||
| 5 | + jolt's time library works in epoch days and nanoseconds-of-day, and the | ||
| 6 | + offset needs a zone name, which no single call hands over — so the zone is | ||
| 7 | + found once here and everything else is arithmetic." | ||
| 8 | + (:require [clojure.string :as str] | ||
| 9 | + [jolt.host :as host] | ||
| 10 | + [jolt.time.local :as local])) | ||
| 11 | + | ||
| 12 | +(def ^:private zone | ||
| 13 | + ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise | ||
| 14 | + ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing. | ||
| 15 | + (delay | ||
| 16 | + (or (let [tz (host/getenv "TZ")] (when (seq tz) tz)) | ||
| 17 | + (try | ||
| 18 | + (second (re-find #"/zoneinfo/(.+)$" | ||
| 19 | + (str/trim (str (host/sh-out "readlink -f /etc/localtime"))))) | ||
| 20 | + (catch Exception _ nil)) | ||
| 21 | + "UTC"))) | ||
| 22 | + | ||
| 23 | +(defn now-ms [] (quot (host/wall-nanos) 1000000)) | ||
| 24 | + | ||
| 25 | +(defn parse-time-tag | ||
| 26 | + "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil. | ||
| 27 | + | ||
| 28 | + Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather | ||
| 29 | + than through a parser, since this runs once per message of a hundred-message | ||
| 30 | + backlog." | ||
| 31 | + [tags] | ||
| 32 | + (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})" | ||
| 33 | + (or tags ""))] | ||
| 34 | + (let [[y mo d h mi s] (map parse-long [y mo d h mi s]) | ||
| 35 | + ;; days from the civil date, by Howard Hinnant's算 — the same one | ||
| 36 | + ;; jolt's own local-date uses in reverse. | ||
| 37 | + y (if (<= mo 2) (dec y) y) | ||
| 38 | + era (quot (if (>= y 0) y (- y 399)) 400) | ||
| 39 | + yoe (- y (* era 400)) | ||
| 40 | + doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d)) | ||
| 41 | + doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy) | ||
| 42 | + days (+ (* era 146097) doe -719468)] | ||
| 43 | + (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s))))) | ||
| 44 | + | ||
| 45 | +(defn- local-parts | ||
| 46 | + "`[date hour minute]` in the reader's zone, the hour on a 24-clock." | ||
| 47 | + [ms] | ||
| 48 | + (let [secs (quot ms 1000) | ||
| 49 | + secs (+ secs (host/tz-offset-seconds @zone secs)) | ||
| 50 | + text (str (local/local-dt (Math/floorDiv secs 86400) | ||
| 51 | + (* (Math/floorMod secs 86400) 1000000000)))] | ||
| 52 | + [(subs text 0 10) (parse-long (subs text 11 13)) (subs text 14 16)])) | ||
| 53 | + | ||
| 54 | +(defn clock-time | ||
| 55 | + "A twelve-hour time: `9:05 AM`, `12:30 PM`." | ||
| 56 | + [ms] | ||
| 57 | + (let [[_ hour minute] (local-parts ms) | ||
| 58 | + display (cond (zero? hour) 12 | ||
| 59 | + (> hour 12) (- hour 12) | ||
| 60 | + :else hour)] | ||
| 61 | + (str display ":" minute " " (if (< hour 12) "AM" "PM")))) | ||
| 62 | + | ||
| 63 | +(defn day [ms] (first (local-parts ms))) | ||
| 64 | + | ||
| 65 | +(defn day-label | ||
| 66 | + "The heading for a day's messages: today and yesterday by name, anything | ||
| 67 | + older by date." | ||
| 68 | + [ms] | ||
| 69 | + (let [d (day ms) | ||
| 70 | + today (day (now-ms)) | ||
| 71 | + yesterday (day (- (now-ms) 86400000))] | ||
| 72 | + (cond (= d today) "Today" | ||
| 73 | + (= d yesterday) "Yesterday" | ||
| 74 | + :else d))) | ||
modified
src/frq/irc.jolt +19 -16 | @@ -223,32 +223,35 @@ | ||
| 223 | 223 | (send-line! conn "AUTHENTICATE +")))))) |
| 224 | 224 | |
| 225 | 225 | (def ^:private wanted-caps |
| 226 | - "What this client can actually use. | |
| 226 | + "What this client can use, and why a guest connection negotiates at all. | |
| 227 | 227 | |
| 228 | - `account-tag` is why a guest connection negotiates at all: it puts the | |
| 229 | - sender's DID on every message, which is the only identity a client is given — | |
| 230 | - a nick may be anything, and the hostmask carries eight characters of a DID, | |
| 231 | - too few to resolve. It needs `message-tags` beside it: IRCv3 sends tags only | |
| 232 | - to clients that asked for tags at all, so `account-tag` alone is ACKed and | |
| 233 | - then nothing arrives." | |
| 234 | - ["message-tags" "account-tag"]) | |
| 228 | + `server-time`: without it a replayed backlog arrives untimed and every old | |
| 229 | + line reads as having just been said. `account-tag`: it puts the sender's DID | |
| 230 | + on the message, which is the only identity a client is given — a nick is | |
| 231 | + whatever someone chose today, and the hostmask carries eight characters of a | |
| 232 | + DID, too few to resolve. Both need `message-tags` beside them, since IRCv3 | |
| 233 | + sends tags only to clients that asked for tags at all; either one alone is | |
| 234 | + ACKed and then nothing arrives." | |
| 235 | + ["message-tags" "server-time" "account-tag"]) | |
| 235 | 236 | |
| 236 | 237 | (defn- cap-step! |
| 237 | - "Drive capability negotiation, and the SASL exchange when there is a session. | |
| 238 | - Returns the message unchanged, so the caller can go on handling it." | |
| 238 | + "Drive capability negotiation, and the SASL exchange inside it when there is | |
| 239 | + a session to authenticate with. Returns the message unchanged, so the caller | |
| 240 | + can go on handling it." | |
| 239 | 241 | [conn session msg] |
| 240 | 242 | (let [{:keys [command params]} msg] |
| 241 | 243 | (case command |
| 242 | 244 | "CAP" (let [[_ sub caps] params |
| 243 | 245 | offered (set (str/split (or caps "") #"\s+")) |
| 244 | - sasl? (and session (contains? offered "sasl")) | |
| 245 | - wanted (cond-> (filterv offered wanted-caps) sasl? (conj "sasl"))] | |
| 246 | + wanted (cond-> (filterv offered wanted-caps) | |
| 247 | + (and session (offered "sasl")) (conj "sasl"))] | |
| 246 | 248 | (case sub |
| 247 | 249 | "LS" (if (seq wanted) |
| 248 | 250 | (send-line! conn (str "CAP REQ :" (str/join " " wanted))) |
| 249 | 251 | (send-line! conn "CAP END")) |
| 250 | - ;; SASL, when acknowledged, is what CAP END now waits for. | |
| 251 | - "ACK" (if (contains? offered "sasl") | |
| 252 | + ;; SASL, when acked, ends negotiation itself — CAP END waits | |
| 253 | + ;; for the exchange to finish either way. | |
| 254 | + "ACK" (if (str/includes? (or caps "") "sasl") | |
| 252 | 255 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") |
| 253 | 256 | (send-line! conn "CAP END")) |
| 254 | 257 | "NAK" (send-line! conn "CAP END") |
| @@ -281,8 +284,8 @@ | ||
| 281 | 284 | (try (reader-loop! conn on-msg) |
| 282 | 285 | (catch Exception e |
| 283 | 286 | (on-msg {:command "*ERROR*" :params [(str e)]})))) |
| 284 | - ;; CAP first: registration waits for the CAP END that negotiation sends | |
| 285 | - ;; once it has an answer, either way. | |
| 287 | + ;; CAP first: registration waits for CAP END, which negotiation sends once | |
| 288 | + ;; it has an answer — after the SASL exchange, when there is one. | |
| 286 | 289 | (send-line! conn "CAP LS 302") |
| 287 | 290 | (send-line! conn (str "NICK " nick)) |
| 288 | 291 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
| @@ -223,32 +223,35 @@ | |||
| 223 | (send-line! conn "AUTHENTICATE +")))))) | 223 | (send-line! conn "AUTHENTICATE +")))))) |
| 224 | 224 | ||
| 225 | (def ^:private wanted-caps | 225 | (def ^:private wanted-caps |
| 226 | - "What this client can actually use. | 226 | + "What this client can use, and why a guest connection negotiates at all. |
| 227 | 227 | ||
| 228 | - `account-tag` is why a guest connection negotiates at all: it puts the | 228 | + `server-time`: without it a replayed backlog arrives untimed and every old |
| 229 | - sender's DID on every message, which is the only identity a client is given — | 229 | + line reads as having just been said. `account-tag`: it puts the sender's DID |
| 230 | - a nick may be anything, and the hostmask carries eight characters of a DID, | 230 | + on the message, which is the only identity a client is given — a nick is |
| 231 | - too few to resolve. It needs `message-tags` beside it: IRCv3 sends tags only | 231 | + whatever someone chose today, and the hostmask carries eight characters of a |
| 232 | - to clients that asked for tags at all, so `account-tag` alone is ACKed and | 232 | + DID, too few to resolve. Both need `message-tags` beside them, since IRCv3 |
| 233 | - then nothing arrives." | 233 | + sends tags only to clients that asked for tags at all; either one alone is |
| 234 | - ["message-tags" "account-tag"]) | 234 | + ACKed and then nothing arrives." |
| 235 | + ["message-tags" "server-time" "account-tag"]) | ||
| 235 | 236 | ||
| 236 | (defn- cap-step! | 237 | (defn- cap-step! |
| 237 | - "Drive capability negotiation, and the SASL exchange when there is a session. | 238 | + "Drive capability negotiation, and the SASL exchange inside it when there is |
| 238 | - Returns the message unchanged, so the caller can go on handling it." | 239 | + a session to authenticate with. Returns the message unchanged, so the caller |
| 240 | + can go on handling it." | ||
| 239 | [conn session msg] | 241 | [conn session msg] |
| 240 | (let [{:keys [command params]} msg] | 242 | (let [{:keys [command params]} msg] |
| 241 | (case command | 243 | (case command |
| 242 | "CAP" (let [[_ sub caps] params | 244 | "CAP" (let [[_ sub caps] params |
| 243 | offered (set (str/split (or caps "") #"\s+")) | 245 | offered (set (str/split (or caps "") #"\s+")) |
| 244 | - sasl? (and session (contains? offered "sasl")) | 246 | + wanted (cond-> (filterv offered wanted-caps) |
| 245 | - wanted (cond-> (filterv offered wanted-caps) sasl? (conj "sasl"))] | 247 | + (and session (offered "sasl")) (conj "sasl"))] |
| 246 | (case sub | 248 | (case sub |
| 247 | "LS" (if (seq wanted) | 249 | "LS" (if (seq wanted) |
| 248 | (send-line! conn (str "CAP REQ :" (str/join " " wanted))) | 250 | (send-line! conn (str "CAP REQ :" (str/join " " wanted))) |
| 249 | (send-line! conn "CAP END")) | 251 | (send-line! conn "CAP END")) |
| 250 | - ;; SASL, when acknowledged, is what CAP END now waits for. | 252 | + ;; SASL, when acked, ends negotiation itself — CAP END waits |
| 251 | - "ACK" (if (contains? offered "sasl") | 253 | + ;; for the exchange to finish either way. |
| 254 | + "ACK" (if (str/includes? (or caps "") "sasl") | ||
| 252 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") | 255 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") |
| 253 | (send-line! conn "CAP END")) | 256 | (send-line! conn "CAP END")) |
| 254 | "NAK" (send-line! conn "CAP END") | 257 | "NAK" (send-line! conn "CAP END") |
| @@ -281,8 +284,8 @@ | |||
| 281 | (try (reader-loop! conn on-msg) | 284 | (try (reader-loop! conn on-msg) |
| 282 | (catch Exception e | 285 | (catch Exception e |
| 283 | (on-msg {:command "*ERROR*" :params [(str e)]})))) | 286 | (on-msg {:command "*ERROR*" :params [(str e)]})))) |
| 284 | - ;; CAP first: registration waits for the CAP END that negotiation sends | 287 | + ;; CAP first: registration waits for CAP END, which negotiation sends once |
| 285 | - ;; once it has an answer, either way. | 288 | + ;; it has an answer — after the SASL exchange, when there is one. |
| 286 | (send-line! conn "CAP LS 302") | 289 | (send-line! conn "CAP LS 302") |
| 287 | (send-line! conn (str "NICK " nick)) | 290 | (send-line! conn (str "NICK " nick)) |
| 288 | (send-line! conn (str "USER " nick " 0 * :" nick)) | 291 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
modified
src/frq/state.jolt +59 -9 | @@ -7,6 +7,7 @@ | ||
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [frq.atproto :as atproto] |
| 10 | + [frq.clock :as clock] | |
| 10 | 11 | [frq.irc :as irc] |
| 11 | 12 | [frq.avatars :as avatars] |
| 12 | 13 | [frq.media :as media] |
| @@ -72,6 +73,15 @@ | ||
| 72 | 73 | |
| 73 | 74 | (defn connected? [] (some? @conn)) |
| 74 | 75 | |
| 76 | +(declare channel-order) | |
| 77 | + | |
| 78 | +(defn- remember-channels! | |
| 79 | + "Write the order out. Off the caller's thread: opening a channel should not | |
| 80 | + wait on a file, and the order it records is read from the same atom either | |
| 81 | + way." | |
| 82 | + [] | |
| 83 | + (future (store/save-channels! (channel-order)))) | |
| 84 | + | |
| 75 | 85 | (defn normalize-channel [s] |
| 76 | 86 | (let [s (str/trim (or s ""))] |
| 77 | 87 | (cond (str/blank? s) "" |
| @@ -93,13 +103,16 @@ | ||
| 93 | 103 | unless that buffer is the one on screen. Any image it links to is fetched in |
| 94 | 104 | the background, as is the sender's avatar. |
| 95 | 105 | |
| 96 | - `did` is the sender's identity from the message's `account` tag, when the | |
| 97 | - server sent one — it outlasts whatever nick they are using today." | |
| 98 | - ([channel from text] (push-message! channel from text nil)) | |
| 99 | - ([channel from text did] | |
| 100 | - (doseq [url (media/image-urls text)] | |
| 101 | - (media/fetch! url #(swap! media-tick inc))) | |
| 102 | - (let [who (avatars/actor did from)] | |
| 106 | + The extras are what the message carried beyond its text: `:at` when it was | |
| 107 | + said, from the server's own `time` tag where there is one, and `:did` who | |
| 108 | + said it, from the `account` tag — an identity that outlasts whatever nick | |
| 109 | + they are using today." | |
| 110 | + ([channel from text] (push-message! channel from text {})) | |
| 111 | + ([channel from text {:keys [at did]}] | |
| 112 | + (let [at (or at (clock/now-ms)) | |
| 113 | + who (avatars/actor did from)] | |
| 114 | + (doseq [url (media/image-urls text)] | |
| 115 | + (media/fetch! url #(swap! media-tick inc))) | |
| 103 | 116 | ;; The same tick: an avatar arriving is a picture arriving, and the chat |
| 104 | 117 | ;; view already repaints on it. |
| 105 | 118 | (when who (avatars/fetch! who #(swap! media-tick inc))) |
| @@ -111,7 +124,8 @@ | ||
| 111 | 124 | (update-in [channel :messages] conj |
| 112 | 125 | {:from from :text text :system? (= "*" from) |
| 113 | 126 | :actor who |
| 114 | - :images (media/image-urls text)}) | |
| 127 | + :images (media/image-urls text) | |
| 128 | + :at at}) | |
| 115 | 129 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) |
| 116 | 130 | |
| 117 | 131 | (defn open-channel! |
| @@ -124,6 +138,7 @@ | ||
| 124 | 138 | (swap! channels #(-> (ensure-channel % name) |
| 125 | 139 | (assoc-in [name :unread] 0) |
| 126 | 140 | (assoc-in [name :accessed] (swap! access-tick inc)))) |
| 141 | + (remember-channels!) | |
| 127 | 142 | ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a |
| 128 | 143 | ;; second JOIN sent in the meantime is what makes the server replay nothing. |
| 129 | 144 | (let [buffer (get @channels name)] |
| @@ -149,10 +164,14 @@ | ||
| 149 | 164 | (reset! screen :chats) |
| 150 | 165 | (join! auto-join)) |
| 151 | 166 | "PRIVMSG" (let [[target text] params |
| 167 | + ;; The server's clock when it offers one: a replayed | |
| 168 | + ;; backlog is hours or weeks old, and stamping it with | |
| 169 | + ;; the moment it arrived would say it all happened now. | |
| 170 | + at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) | |
| 152 | 171 | ;; a DM addressed to us belongs in a buffer named for the |
| 153 | 172 | ;; sender, not for our own nick |
| 154 | 173 | buffer (if (str/starts-with? (or target "") "#") target from)] |
| 155 | - (push-message! buffer from text (:account msg))) | |
| 174 | + (push-message! buffer from text {:at at :did (:account msg)})) | |
| 156 | 175 | "JOIN" (let [ch (first params)] |
| 157 | 176 | (if (= from @form-nick) |
| 158 | 177 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| @@ -411,6 +430,37 @@ | ||
| 411 | 430 | (sort-by (juxt #(- (:accessed % 0)) :name)) |
| 412 | 431 | vec))) |
| 413 | 432 | |
| 433 | +(defn channel-order | |
| 434 | + "Just the channel names, most recently opened first — what gets written to | |
| 435 | + disk. Buffers never opened are left out: a DM that arrived once is not a | |
| 436 | + place this client has been." | |
| 437 | + [] | |
| 438 | + (->> (vals @channels) | |
| 439 | + (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0)))) | |
| 440 | + (sort-by #(- (:accessed % 0))) | |
| 441 | + (mapv :name))) | |
| 442 | + | |
| 443 | +(defn restore-channels! | |
| 444 | + "Bring back the channels of earlier runs, in the order they were last used. | |
| 445 | + | |
| 446 | + Empty buffers, not memberships: opening one is what joins it, and a list of | |
| 447 | + rooms is the part worth keeping — the messages in them come from the server. | |
| 448 | + The tick is seeded so this run's first open still sorts above all of them." | |
| 449 | + [] | |
| 450 | + (when-let [saved (seq (store/load-channels))] | |
| 451 | + (let [ordered (reverse saved)] ; oldest first, so ticks ascend | |
| 452 | + (swap! channels | |
| 453 | + (fn [m] | |
| 454 | + (reduce (fn [acc name] | |
| 455 | + (if (contains? acc name) | |
| 456 | + acc | |
| 457 | + (assoc acc name {:name name :messages [] :unread 0 | |
| 458 | + :joined? false :joining? false | |
| 459 | + :accessed (swap! access-tick inc)}))) | |
| 460 | + m | |
| 461 | + ordered)))) | |
| 462 | + (count saved))) | |
| 463 | + | |
| 414 | 464 | (defn last-preview [buffer] |
| 415 | 465 | (if-let [m (last (:messages buffer))] |
| 416 | 466 | (str (:from m) ": " (:text m)) |
| @@ -7,6 +7,7 @@ | |||
| 7 | (:require [clojure.string :as str] | 7 | (:require [clojure.string :as str] |
| 8 | [glimmer.ratom :as r :refer [atom]] | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | [frq.atproto :as atproto] | 9 | [frq.atproto :as atproto] |
| 10 | + [frq.clock :as clock] | ||
| 10 | [frq.irc :as irc] | 11 | [frq.irc :as irc] |
| 11 | [frq.avatars :as avatars] | 12 | [frq.avatars :as avatars] |
| 12 | [frq.media :as media] | 13 | [frq.media :as media] |
| @@ -72,6 +73,15 @@ | |||
| 72 | 73 | ||
| 73 | (defn connected? [] (some? @conn)) | 74 | (defn connected? [] (some? @conn)) |
| 74 | 75 | ||
| 76 | +(declare channel-order) | ||
| 77 | + | ||
| 78 | +(defn- remember-channels! | ||
| 79 | + "Write the order out. Off the caller's thread: opening a channel should not | ||
| 80 | + wait on a file, and the order it records is read from the same atom either | ||
| 81 | + way." | ||
| 82 | + [] | ||
| 83 | + (future (store/save-channels! (channel-order)))) | ||
| 84 | + | ||
| 75 | (defn normalize-channel [s] | 85 | (defn normalize-channel [s] |
| 76 | (let [s (str/trim (or s ""))] | 86 | (let [s (str/trim (or s ""))] |
| 77 | (cond (str/blank? s) "" | 87 | (cond (str/blank? s) "" |
| @@ -93,13 +103,16 @@ | |||
| 93 | unless that buffer is the one on screen. Any image it links to is fetched in | 103 | unless that buffer is the one on screen. Any image it links to is fetched in |
| 94 | the background, as is the sender's avatar. | 104 | the background, as is the sender's avatar. |
| 95 | 105 | ||
| 96 | - `did` is the sender's identity from the message's `account` tag, when the | 106 | + The extras are what the message carried beyond its text: `:at` when it was |
| 97 | - server sent one — it outlasts whatever nick they are using today." | 107 | + said, from the server's own `time` tag where there is one, and `:did` who |
| 98 | - ([channel from text] (push-message! channel from text nil)) | 108 | + said it, from the `account` tag — an identity that outlasts whatever nick |
| 99 | - ([channel from text did] | 109 | + they are using today." |
| 100 | - (doseq [url (media/image-urls text)] | 110 | + ([channel from text] (push-message! channel from text {})) |
| 101 | - (media/fetch! url #(swap! media-tick inc))) | 111 | + ([channel from text {:keys [at did]}] |
| 102 | - (let [who (avatars/actor did from)] | 112 | + (let [at (or at (clock/now-ms)) |
| 113 | + who (avatars/actor did from)] | ||
| 114 | + (doseq [url (media/image-urls text)] | ||
| 115 | + (media/fetch! url #(swap! media-tick inc))) | ||
| 103 | ;; The same tick: an avatar arriving is a picture arriving, and the chat | 116 | ;; The same tick: an avatar arriving is a picture arriving, and the chat |
| 104 | ;; view already repaints on it. | 117 | ;; view already repaints on it. |
| 105 | (when who (avatars/fetch! who #(swap! media-tick inc))) | 118 | (when who (avatars/fetch! who #(swap! media-tick inc))) |
| @@ -111,7 +124,8 @@ | |||
| 111 | (update-in [channel :messages] conj | 124 | (update-in [channel :messages] conj |
| 112 | {:from from :text text :system? (= "*" from) | 125 | {:from from :text text :system? (= "*" from) |
| 113 | :actor who | 126 | :actor who |
| 114 | - :images (media/image-urls text)}) | 127 | + :images (media/image-urls text) |
| 128 | + :at at}) | ||
| 115 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) | 129 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) |
| 116 | 130 | ||
| 117 | (defn open-channel! | 131 | (defn open-channel! |
| @@ -124,6 +138,7 @@ | |||
| 124 | (swap! channels #(-> (ensure-channel % name) | 138 | (swap! channels #(-> (ensure-channel % name) |
| 125 | (assoc-in [name :unread] 0) | 139 | (assoc-in [name :unread] 0) |
| 126 | (assoc-in [name :accessed] (swap! access-tick inc)))) | 140 | (assoc-in [name :accessed] (swap! access-tick inc)))) |
| 141 | + (remember-channels!) | ||
| 127 | ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a | 142 | ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a |
| 128 | ;; second JOIN sent in the meantime is what makes the server replay nothing. | 143 | ;; second JOIN sent in the meantime is what makes the server replay nothing. |
| 129 | (let [buffer (get @channels name)] | 144 | (let [buffer (get @channels name)] |
| @@ -149,10 +164,14 @@ | |||
| 149 | (reset! screen :chats) | 164 | (reset! screen :chats) |
| 150 | (join! auto-join)) | 165 | (join! auto-join)) |
| 151 | "PRIVMSG" (let [[target text] params | 166 | "PRIVMSG" (let [[target text] params |
| 167 | + ;; The server's clock when it offers one: a replayed | ||
| 168 | + ;; backlog is hours or weeks old, and stamping it with | ||
| 169 | + ;; the moment it arrived would say it all happened now. | ||
| 170 | + at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) | ||
| 152 | ;; a DM addressed to us belongs in a buffer named for the | 171 | ;; a DM addressed to us belongs in a buffer named for the |
| 153 | ;; sender, not for our own nick | 172 | ;; sender, not for our own nick |
| 154 | buffer (if (str/starts-with? (or target "") "#") target from)] | 173 | buffer (if (str/starts-with? (or target "") "#") target from)] |
| 155 | - (push-message! buffer from text (:account msg))) | 174 | + (push-message! buffer from text {:at at :did (:account msg)})) |
| 156 | "JOIN" (let [ch (first params)] | 175 | "JOIN" (let [ch (first params)] |
| 157 | (if (= from @form-nick) | 176 | (if (= from @form-nick) |
| 158 | (let [fresh? (empty? (get-in @channels [ch :messages]))] | 177 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| @@ -411,6 +430,37 @@ | |||
| 411 | (sort-by (juxt #(- (:accessed % 0)) :name)) | 430 | (sort-by (juxt #(- (:accessed % 0)) :name)) |
| 412 | vec))) | 431 | vec))) |
| 413 | 432 | ||
| 433 | +(defn channel-order | ||
| 434 | + "Just the channel names, most recently opened first — what gets written to | ||
| 435 | + disk. Buffers never opened are left out: a DM that arrived once is not a | ||
| 436 | + place this client has been." | ||
| 437 | + [] | ||
| 438 | + (->> (vals @channels) | ||
| 439 | + (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0)))) | ||
| 440 | + (sort-by #(- (:accessed % 0))) | ||
| 441 | + (mapv :name))) | ||
| 442 | + | ||
| 443 | +(defn restore-channels! | ||
| 444 | + "Bring back the channels of earlier runs, in the order they were last used. | ||
| 445 | + | ||
| 446 | + Empty buffers, not memberships: opening one is what joins it, and a list of | ||
| 447 | + rooms is the part worth keeping — the messages in them come from the server. | ||
| 448 | + The tick is seeded so this run's first open still sorts above all of them." | ||
| 449 | + [] | ||
| 450 | + (when-let [saved (seq (store/load-channels))] | ||
| 451 | + (let [ordered (reverse saved)] ; oldest first, so ticks ascend | ||
| 452 | + (swap! channels | ||
| 453 | + (fn [m] | ||
| 454 | + (reduce (fn [acc name] | ||
| 455 | + (if (contains? acc name) | ||
| 456 | + acc | ||
| 457 | + (assoc acc name {:name name :messages [] :unread 0 | ||
| 458 | + :joined? false :joining? false | ||
| 459 | + :accessed (swap! access-tick inc)}))) | ||
| 460 | + m | ||
| 461 | + ordered)))) | ||
| 462 | + (count saved))) | ||
| 463 | + | ||
| 414 | (defn last-preview [buffer] | 464 | (defn last-preview [buffer] |
| 415 | (if-let [m (last (:messages buffer))] | 465 | (if-let [m (last (:messages buffer))] |
| 416 | (str (:from m) ": " (:text m)) | 466 | (str (:from m) ": " (:text m)) |
modified
src/frq/store.jolt +23 -0 | @@ -42,6 +42,29 @@ | ||
| 42 | 42 | true |
| 43 | 43 | (catch Exception _ false)))) |
| 44 | 44 | |
| 45 | +(defn channels-file [] (str (config-dir) "/channels.edn")) | |
| 46 | + | |
| 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." | |
| 51 | + [] | |
| 52 | + (let [path (channels-file)] | |
| 53 | + (when (host/file-exists? path) | |
| 54 | + (try | |
| 55 | + (let [v (edn/read-string (slurp path))] | |
| 56 | + (when (vector? v) (filterv string? v))) | |
| 57 | + (catch Exception _ nil))))) | |
| 58 | + | |
| 59 | +(defn save-channels! | |
| 60 | + "Write the channel names, most recently used first." | |
| 61 | + [names] | |
| 62 | + (try | |
| 63 | + (host/mkdirs! (config-dir)) | |
| 64 | + (spit (channels-file) (pr-str (vec names))) | |
| 65 | + true | |
| 66 | + (catch Exception _ false))) | |
| 67 | + | |
| 45 | 68 | (defn clear-session! [] |
| 46 | 69 | (try |
| 47 | 70 | (when (host/file-exists? (session-file)) |
| @@ -42,6 +42,29 @@ | |||
| 42 | true | 42 | true |
| 43 | (catch Exception _ false)))) | 43 | (catch Exception _ false)))) |
| 44 | 44 | ||
| 45 | +(defn channels-file [] (str (config-dir) "/channels.edn")) | ||
| 46 | + | ||
| 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." | ||
| 51 | + [] | ||
| 52 | + (let [path (channels-file)] | ||
| 53 | + (when (host/file-exists? path) | ||
| 54 | + (try | ||
| 55 | + (let [v (edn/read-string (slurp path))] | ||
| 56 | + (when (vector? v) (filterv string? v))) | ||
| 57 | + (catch Exception _ nil))))) | ||
| 58 | + | ||
| 59 | +(defn save-channels! | ||
| 60 | + "Write the channel names, most recently used first." | ||
| 61 | + [names] | ||
| 62 | + (try | ||
| 63 | + (host/mkdirs! (config-dir)) | ||
| 64 | + (spit (channels-file) (pr-str (vec names))) | ||
| 65 | + true | ||
| 66 | + (catch Exception _ false))) | ||
| 67 | + | ||
| 45 | (defn clear-session! [] | 68 | (defn clear-session! [] |
| 46 | (try | 69 | (try |
| 47 | (when (host/file-exists? (session-file)) | 70 | (when (host/file-exists? (session-file)) |