Say when each thing was said
Every connection negotiates capabilities now, not only an authenticated one. `server-time` is the reason: a backlog reaches back weeks, and without it every replayed line carries the moment it arrived, which is to say a lie. The tag is read where the server sends one and the local clock only fills in for what has no tag. Times are the reader's own, in twelve hours. Finding the zone takes a name that no single call hands over, so it is TZ, or what /etc/localtime points at, or UTC — resolved once and the rest is arithmetic on epoch days. The time rides on the line that names the sender, once per run rather than once per message, and a heading marks each change of day: Today, Yesterday, or the date. A new day breaks the run too, so the first line under a heading says who said it and when, however recently they last spoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dbf3b86 parent: 1d062fd modified
README.md +3 -0 | @@ -16,6 +16,7 @@ src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads | ||
| 16 | 16 | 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/media.jolt image links: spot them, fetch them once, cache on disk |
| 19 | +src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings | |
| 19 | 20 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 20 | 21 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 21 | 22 | src/frq/app.jolt the screens |
| @@ -104,6 +105,8 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 104 | 105 | * Auto-joins `#test` on `irc.freeq.at` |
| 105 | 106 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` |
| 106 | 107 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 108 | +* Twelve-hour timestamps from the server's own clock, with a heading wherever | |
| 109 | + the day changes | |
| 107 | 110 | * Inline previews for PNG links, fetched once and cached under |
| 108 | 111 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 109 | 112 | * Join/part notices, DMs bucketed under the sender's nick |
| @@ -16,6 +16,7 @@ src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads | |||
| 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | 16 | 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/media.jolt image links: spot them, fetch them once, cache on disk | 18 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 19 | +src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings | ||
| 19 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 20 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 20 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | 21 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 21 | src/frq/app.jolt the screens | 22 | src/frq/app.jolt the screens |
| @@ -104,6 +105,8 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 104 | * Auto-joins `#test` on `irc.freeq.at` | 105 | * Auto-joins `#test` on `irc.freeq.at` |
| 105 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` | 106 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` |
| 106 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead | 107 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 108 | +* Twelve-hour timestamps from the server's own clock, with a heading wherever | ||
| 109 | + the day changes | ||
| 107 | * Inline previews for PNG links, fetched once and cached under | 110 | * Inline previews for PNG links, fetched once and cached under |
| 108 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size | 111 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 109 | * Join/part notices, DMs bucketed under the sender's nick | 112 | * Join/part notices, DMs bucketed under the sender's nick |
modified
src/frq/app.jolt +30 -5 | @@ -8,6 +8,7 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [glimmer.core :as ui] |
| 10 | 10 | [glimmer-vidya.core :as vidya] |
| 11 | + [frq.clock :as clock] | |
| 11 | 12 | [frq.media :as media] |
| 12 | 13 | [frq.platform :as platform] |
| 13 | 14 | [frq.state :as s])) |
| @@ -230,10 +231,15 @@ | ||
| 230 | 231 | (= (:from prev) (:from m)))] |
| 231 | 232 | [:vbox {:key i :spacing 2 :margin 0} |
| 232 | 233 | ;; A run from one person reads as one block; repeating the nick on every |
| 233 | - ;; line is what made a busy channel look like a list of headers. | |
| 234 | + ;; line is what made a busy channel look like a list of headers. The time | |
| 235 | + ;; rides on that same line, for the same reason: once per run, not once | |
| 236 | + ;; per line. | |
| 234 | 237 | [:vbox {:key :who} |
| 235 | 238 | (when-not (or same-sender? (:system? m)) |
| 236 | - [:dim-label {:label (:from m)}])] | |
| 239 | + [:hbox {:spacing 6} | |
| 240 | + [:dim-label {:label (:from m)}] | |
| 241 | + (when-let [at (:at m)] | |
| 242 | + [:dim-label {:label (clock/clock-time at)}])])] | |
| 237 | 243 | [:vbox {:key :text :spacing 2} |
| 238 | 244 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 239 | 245 | (text-runs (:text m)))] |
| @@ -250,9 +256,28 @@ | ||
| 250 | 256 | :max-height 260 |
| 251 | 257 | :on-click #(reset! s/lightbox {:path path :url url})}]))))]])) |
| 252 | 258 | |
| 253 | -(defn- message-rows [messages] | |
| 254 | - (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m]) | |
| 255 | - messages)) | |
| 259 | +(defn- day-separator [day-key label] | |
| 260 | + [:vbox {:key day-key :spacing 4 :margin 0} | |
| 261 | + [:separator {}] | |
| 262 | + [:dim-label {:label label}]]) | |
| 263 | + | |
| 264 | +(defn- message-rows | |
| 265 | + "The messages, with a heading wherever the day changes. | |
| 266 | + | |
| 267 | + A backlog can reach back weeks, and `11:04 AM` says nothing about which day | |
| 268 | + it was. The heading is what makes the time above it mean something." | |
| 269 | + [messages] | |
| 270 | + (mapcat (fn [i m] | |
| 271 | + (let [prev (when (pos? i) (nth messages (dec i))) | |
| 272 | + day (some-> (:at m) clock/day) | |
| 273 | + new-day? (and day (not= day (some-> (:at prev) clock/day)))] | |
| 274 | + (cond-> [] | |
| 275 | + new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m)))) | |
| 276 | + ;; A new day breaks the run: the first line under a heading | |
| 277 | + ;; names its sender and its time, whoever spoke last night. | |
| 278 | + true (conj [message-row i (when-not new-day? prev) m])))) | |
| 279 | + (range (count messages)) | |
| 280 | + messages)) | |
| 256 | 281 | |
| 257 | 282 | (defn lightbox-screen |
| 258 | 283 | "One picture, as big as the window allows. |
| @@ -8,6 +8,7 @@ | |||
| 8 | [glimmer.ratom :as r :refer [atom]] | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | [glimmer.core :as ui] | 9 | [glimmer.core :as ui] |
| 10 | [glimmer-vidya.core :as vidya] | 10 | [glimmer-vidya.core :as vidya] |
| 11 | + [frq.clock :as clock] | ||
| 11 | [frq.media :as media] | 12 | [frq.media :as media] |
| 12 | [frq.platform :as platform] | 13 | [frq.platform :as platform] |
| 13 | [frq.state :as s])) | 14 | [frq.state :as s])) |
| @@ -230,10 +231,15 @@ | |||
| 230 | (= (:from prev) (:from m)))] | 231 | (= (:from prev) (:from m)))] |
| 231 | [:vbox {:key i :spacing 2 :margin 0} | 232 | [:vbox {:key i :spacing 2 :margin 0} |
| 232 | ;; A run from one person reads as one block; repeating the nick on every | 233 | ;; A run from one person reads as one block; repeating the nick on every |
| 233 | - ;; line is what made a busy channel look like a list of headers. | 234 | + ;; line is what made a busy channel look like a list of headers. The time |
| 235 | + ;; rides on that same line, for the same reason: once per run, not once | ||
| 236 | + ;; per line. | ||
| 234 | [:vbox {:key :who} | 237 | [:vbox {:key :who} |
| 235 | (when-not (or same-sender? (:system? m)) | 238 | (when-not (or same-sender? (:system? m)) |
| 236 | - [:dim-label {:label (:from m)}])] | 239 | + [:hbox {:spacing 6} |
| 240 | + [:dim-label {:label (:from m)}] | ||
| 241 | + (when-let [at (:at m)] | ||
| 242 | + [:dim-label {:label (clock/clock-time at)}])])] | ||
| 237 | [:vbox {:key :text :spacing 2} | 243 | [:vbox {:key :text :spacing 2} |
| 238 | (map-indexed (fn [j run] (run-node j run (:system? m))) | 244 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 239 | (text-runs (:text m)))] | 245 | (text-runs (:text m)))] |
| @@ -250,9 +256,28 @@ | |||
| 250 | :max-height 260 | 256 | :max-height 260 |
| 251 | :on-click #(reset! s/lightbox {:path path :url url})}]))))]])) | 257 | :on-click #(reset! s/lightbox {:path path :url url})}]))))]])) |
| 252 | 258 | ||
| 253 | -(defn- message-rows [messages] | 259 | +(defn- day-separator [day-key label] |
| 254 | - (map-indexed (fn [i m] [message-row i (when (pos? i) (nth messages (dec i))) m]) | 260 | + [:vbox {:key day-key :spacing 4 :margin 0} |
| 255 | - messages)) | 261 | + [:separator {}] |
| 262 | + [:dim-label {:label label}]]) | ||
| 263 | + | ||
| 264 | +(defn- message-rows | ||
| 265 | + "The messages, with a heading wherever the day changes. | ||
| 266 | + | ||
| 267 | + A backlog can reach back weeks, and `11:04 AM` says nothing about which day | ||
| 268 | + it was. The heading is what makes the time above it mean something." | ||
| 269 | + [messages] | ||
| 270 | + (mapcat (fn [i m] | ||
| 271 | + (let [prev (when (pos? i) (nth messages (dec i))) | ||
| 272 | + day (some-> (:at m) clock/day) | ||
| 273 | + new-day? (and day (not= day (some-> (:at prev) clock/day)))] | ||
| 274 | + (cond-> [] | ||
| 275 | + new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m)))) | ||
| 276 | + ;; A new day breaks the run: the first line under a heading | ||
| 277 | + ;; names its sender and its time, whoever spoke last night. | ||
| 278 | + true (conj [message-row i (when-not new-day? prev) m])))) | ||
| 279 | + (range (count messages)) | ||
| 280 | + messages)) | ||
| 256 | 281 | ||
| 257 | (defn lightbox-screen | 282 | (defn lightbox-screen |
| 258 | "One picture, as big as the window allows. | 283 | "One picture, as big as the window allows. |
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 +22 -12 | @@ -220,17 +220,29 @@ | ||
| 220 | 220 | (when (= sasl-chunk (count rest)) |
| 221 | 221 | (send-line! conn "AUTHENTICATE +")))))) |
| 222 | 222 | |
| 223 | -(defn- sasl-step! | |
| 224 | - "Drive the SASL exchange from the lines the server sends during registration. | |
| 225 | - Returns the message unchanged, so the caller can go on handling it." | |
| 223 | +(def ^:private wanted-caps | |
| 224 | + "What this client can use. `server-time` is why a guest negotiates at all: | |
| 225 | + without it the replayed backlog arrives untimed and every old line reads as | |
| 226 | + having just been said." | |
| 227 | + ["message-tags" "server-time"]) | |
| 228 | + | |
| 229 | +(defn- cap-step! | |
| 230 | + "Drive capability negotiation, and the SASL exchange inside it when there is | |
| 231 | + a session to authenticate with. Returns the message unchanged, so the caller | |
| 232 | + can go on handling it." | |
| 226 | 233 | [conn session msg] |
| 227 | 234 | (let [{:keys [command params]} msg] |
| 228 | 235 | (case command |
| 229 | - "CAP" (let [[_ sub caps] params] | |
| 236 | + "CAP" (let [[_ sub caps] params | |
| 237 | + offered (set (str/split (or caps "") #"\s+")) | |
| 238 | + wanted (cond-> (filterv offered wanted-caps) | |
| 239 | + (and session (offered "sasl")) (conj "sasl"))] | |
| 230 | 240 | (case sub |
| 231 | - "LS" (if (str/includes? (or caps "") "sasl") | |
| 232 | - (send-line! conn "CAP REQ :sasl") | |
| 241 | + "LS" (if (seq wanted) | |
| 242 | + (send-line! conn (str "CAP REQ :" (str/join " " wanted))) | |
| 233 | 243 | (send-line! conn "CAP END")) |
| 244 | + ;; SASL, when acked, ends negotiation itself — CAP END waits | |
| 245 | + ;; for the exchange to finish either way. | |
| 234 | 246 | "ACK" (if (str/includes? (or caps "") "sasl") |
| 235 | 247 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") |
| 236 | 248 | (send-line! conn "CAP END")) |
| @@ -259,16 +271,14 @@ | ||
| 259 | 271 | ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil)) |
| 260 | 272 | ([host port nick on-msg tls? session] |
| 261 | 273 | (let [conn (open host port tls? nick) |
| 262 | - on-msg (if session | |
| 263 | - (fn [msg] (on-msg (sasl-step! conn session msg))) | |
| 264 | - on-msg)] | |
| 274 | + on-msg (fn [msg] (on-msg (cap-step! conn session msg)))] | |
| 265 | 275 | (future |
| 266 | 276 | (try (reader-loop! conn on-msg) |
| 267 | 277 | (catch Exception e |
| 268 | 278 | (on-msg {:command "*ERROR*" :params [(str e)]})))) |
| 269 | - ;; CAP first when authenticating: registration waits for CAP END, which | |
| 270 | - ;; the SASL exchange sends once it has an answer either way. | |
| 271 | - (when session (send-line! conn "CAP LS 302")) | |
| 279 | + ;; CAP first: registration waits for CAP END, which negotiation sends once | |
| 280 | + ;; it has an answer — after the SASL exchange, when there is one. | |
| 281 | + (send-line! conn "CAP LS 302") | |
| 272 | 282 | (send-line! conn (str "NICK " nick)) |
| 273 | 283 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
| 274 | 284 | conn))) |
| @@ -220,17 +220,29 @@ | |||
| 220 | (when (= sasl-chunk (count rest)) | 220 | (when (= sasl-chunk (count rest)) |
| 221 | (send-line! conn "AUTHENTICATE +")))))) | 221 | (send-line! conn "AUTHENTICATE +")))))) |
| 222 | 222 | ||
| 223 | -(defn- sasl-step! | 223 | +(def ^:private wanted-caps |
| 224 | - "Drive the SASL exchange from the lines the server sends during registration. | 224 | + "What this client can use. `server-time` is why a guest negotiates at all: |
| 225 | - Returns the message unchanged, so the caller can go on handling it." | 225 | + without it the replayed backlog arrives untimed and every old line reads as |
| 226 | + having just been said." | ||
| 227 | + ["message-tags" "server-time"]) | ||
| 228 | + | ||
| 229 | +(defn- cap-step! | ||
| 230 | + "Drive capability negotiation, and the SASL exchange inside it when there is | ||
| 231 | + a session to authenticate with. Returns the message unchanged, so the caller | ||
| 232 | + can go on handling it." | ||
| 226 | [conn session msg] | 233 | [conn session msg] |
| 227 | (let [{:keys [command params]} msg] | 234 | (let [{:keys [command params]} msg] |
| 228 | (case command | 235 | (case command |
| 229 | - "CAP" (let [[_ sub caps] params] | 236 | + "CAP" (let [[_ sub caps] params |
| 237 | + offered (set (str/split (or caps "") #"\s+")) | ||
| 238 | + wanted (cond-> (filterv offered wanted-caps) | ||
| 239 | + (and session (offered "sasl")) (conj "sasl"))] | ||
| 230 | (case sub | 240 | (case sub |
| 231 | - "LS" (if (str/includes? (or caps "") "sasl") | 241 | + "LS" (if (seq wanted) |
| 232 | - (send-line! conn "CAP REQ :sasl") | 242 | + (send-line! conn (str "CAP REQ :" (str/join " " wanted))) |
| 233 | (send-line! conn "CAP END")) | 243 | (send-line! conn "CAP END")) |
| 244 | + ;; SASL, when acked, ends negotiation itself — CAP END waits | ||
| 245 | + ;; for the exchange to finish either way. | ||
| 234 | "ACK" (if (str/includes? (or caps "") "sasl") | 246 | "ACK" (if (str/includes? (or caps "") "sasl") |
| 235 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") | 247 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") |
| 236 | (send-line! conn "CAP END")) | 248 | (send-line! conn "CAP END")) |
| @@ -259,16 +271,14 @@ | |||
| 259 | ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil)) | 271 | ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil)) |
| 260 | ([host port nick on-msg tls? session] | 272 | ([host port nick on-msg tls? session] |
| 261 | (let [conn (open host port tls? nick) | 273 | (let [conn (open host port tls? nick) |
| 262 | - on-msg (if session | 274 | + on-msg (fn [msg] (on-msg (cap-step! conn session msg)))] |
| 263 | - (fn [msg] (on-msg (sasl-step! conn session msg))) | ||
| 264 | - on-msg)] | ||
| 265 | (future | 275 | (future |
| 266 | (try (reader-loop! conn on-msg) | 276 | (try (reader-loop! conn on-msg) |
| 267 | (catch Exception e | 277 | (catch Exception e |
| 268 | (on-msg {:command "*ERROR*" :params [(str e)]})))) | 278 | (on-msg {:command "*ERROR*" :params [(str e)]})))) |
| 269 | - ;; CAP first when authenticating: registration waits for CAP END, which | 279 | + ;; CAP first: registration waits for CAP END, which negotiation sends once |
| 270 | - ;; the SASL exchange sends once it has an answer either way. | 280 | + ;; it has an answer — after the SASL exchange, when there is one. |
| 271 | - (when session (send-line! conn "CAP LS 302")) | 281 | + (send-line! conn "CAP LS 302") |
| 272 | (send-line! conn (str "NICK " nick)) | 282 | (send-line! conn (str "NICK " nick)) |
| 273 | (send-line! conn (str "USER " nick " 0 * :" nick)) | 283 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
| 274 | conn))) | 284 | conn))) |
modified
src/frq/state.jolt +20 -13 | @@ -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.media :as media] |
| 12 | 13 | [frq.oauth :as oauth] |
| @@ -100,18 +101,20 @@ | ||
| 100 | 101 | "Append a line to a buffer, creating it if needed, and bump the unread count |
| 101 | 102 | unless that buffer is the one on screen. Any image it links to is fetched in |
| 102 | 103 | the background." |
| 103 | - [channel from text] | |
| 104 | - (doseq [url (media/image-urls text)] | |
| 105 | - (media/fetch! url #(swap! media-tick inc))) | |
| 106 | - (swap! channels | |
| 107 | - (fn [m] | |
| 108 | - (let [m (ensure-channel m channel) | |
| 109 | - viewing? (and (= :chat @screen) (= channel @current))] | |
| 110 | - (-> m | |
| 111 | - (update-in [channel :messages] conj | |
| 112 | - {:from from :text text :system? (= "*" from) | |
| 113 | - :images (media/image-urls text)}) | |
| 114 | - (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) | |
| 104 | + ([channel from text] (push-message! channel from text (clock/now-ms))) | |
| 105 | + ([channel from text at] | |
| 106 | + (doseq [url (media/image-urls text)] | |
| 107 | + (media/fetch! url #(swap! media-tick inc))) | |
| 108 | + (swap! channels | |
| 109 | + (fn [m] | |
| 110 | + (let [m (ensure-channel m channel) | |
| 111 | + viewing? (and (= :chat @screen) (= channel @current))] | |
| 112 | + (-> m | |
| 113 | + (update-in [channel :messages] conj | |
| 114 | + {:from from :text text :system? (= "*" from) | |
| 115 | + :images (media/image-urls text) | |
| 116 | + :at at}) | |
| 117 | + (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))) | |
| 115 | 118 | |
| 116 | 119 | (defn open-channel! |
| 117 | 120 | "Show a buffer. A channel we are not in is joined on the way — a row can |
| @@ -149,10 +152,14 @@ | ||
| 149 | 152 | (reset! screen :chats) |
| 150 | 153 | (join! auto-join)) |
| 151 | 154 | "PRIVMSG" (let [[target text] params |
| 155 | + ;; The server's clock when it offers one: a replayed | |
| 156 | + ;; backlog is hours or weeks old, and stamping it with | |
| 157 | + ;; the moment it arrived would say it all happened now. | |
| 158 | + at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) | |
| 152 | 159 | ;; a DM addressed to us belongs in a buffer named for the |
| 153 | 160 | ;; sender, not for our own nick |
| 154 | 161 | buffer (if (str/starts-with? (or target "") "#") target from)] |
| 155 | - (push-message! buffer from text)) | |
| 162 | + (push-message! buffer from text at)) | |
| 156 | 163 | "JOIN" (let [ch (first params)] |
| 157 | 164 | (if (= from @form-nick) |
| 158 | 165 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| @@ -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.media :as media] | 12 | [frq.media :as media] |
| 12 | [frq.oauth :as oauth] | 13 | [frq.oauth :as oauth] |
| @@ -100,18 +101,20 @@ | |||
| 100 | "Append a line to a buffer, creating it if needed, and bump the unread count | 101 | "Append a line to a buffer, creating it if needed, and bump the unread count |
| 101 | unless that buffer is the one on screen. Any image it links to is fetched in | 102 | unless that buffer is the one on screen. Any image it links to is fetched in |
| 102 | the background." | 103 | the background." |
| 103 | - [channel from text] | 104 | + ([channel from text] (push-message! channel from text (clock/now-ms))) |
| 104 | - (doseq [url (media/image-urls text)] | 105 | + ([channel from text at] |
| 105 | - (media/fetch! url #(swap! media-tick inc))) | 106 | + (doseq [url (media/image-urls text)] |
| 106 | - (swap! channels | 107 | + (media/fetch! url #(swap! media-tick inc))) |
| 107 | - (fn [m] | 108 | + (swap! channels |
| 108 | - (let [m (ensure-channel m channel) | 109 | + (fn [m] |
| 109 | - viewing? (and (= :chat @screen) (= channel @current))] | 110 | + (let [m (ensure-channel m channel) |
| 110 | - (-> m | 111 | + viewing? (and (= :chat @screen) (= channel @current))] |
| 111 | - (update-in [channel :messages] conj | 112 | + (-> m |
| 112 | - {:from from :text text :system? (= "*" from) | 113 | + (update-in [channel :messages] conj |
| 113 | - :images (media/image-urls text)}) | 114 | + {:from from :text text :system? (= "*" from) |
| 114 | - (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) | 115 | + :images (media/image-urls text) |
| 116 | + :at at}) | ||
| 117 | + (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))) | ||
| 115 | 118 | ||
| 116 | (defn open-channel! | 119 | (defn open-channel! |
| 117 | "Show a buffer. A channel we are not in is joined on the way — a row can | 120 | "Show a buffer. A channel we are not in is joined on the way — a row can |
| @@ -149,10 +152,14 @@ | |||
| 149 | (reset! screen :chats) | 152 | (reset! screen :chats) |
| 150 | (join! auto-join)) | 153 | (join! auto-join)) |
| 151 | "PRIVMSG" (let [[target text] params | 154 | "PRIVMSG" (let [[target text] params |
| 155 | + ;; The server's clock when it offers one: a replayed | ||
| 156 | + ;; backlog is hours or weeks old, and stamping it with | ||
| 157 | + ;; the moment it arrived would say it all happened now. | ||
| 158 | + at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms)) | ||
| 152 | ;; a DM addressed to us belongs in a buffer named for the | 159 | ;; a DM addressed to us belongs in a buffer named for the |
| 153 | ;; sender, not for our own nick | 160 | ;; sender, not for our own nick |
| 154 | buffer (if (str/starts-with? (or target "") "#") target from)] | 161 | buffer (if (str/starts-with? (or target "") "#") target from)] |
| 155 | - (push-message! buffer from text)) | 162 | + (push-message! buffer from text at)) |
| 156 | "JOIN" (let [ch (first params)] | 163 | "JOIN" (let [ch (first params)] |
| 157 | (if (= from @form-nick) | 164 | (if (= from @form-nick) |
| 158 | (let [fresh? (empty? (get-in @channels [ch :messages]))] | 165 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |