nandi/frqpublic Fork 0
78e7c29
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Merge remote-tracking branch 'origin/main' into worktree-clickable-links

# Conflicts:
#	src/frq/app.jolt
#	src/frq/irc.jolt
#	src/frq/state.jolt
nandi committed 2026-08-30T11:29:36-07:00 Browse files
78e7c29 parents: 58fb3f3 dbf3b86
modified README.md +7 -1
@@ -17,6 +17,7 @@ src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
1717 src/frq/store.jolt the saved sign-in, mode 600 in the config directory
1818 src/frq/avatars.jolt profile pictures, by DID or handle
1919 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
2021 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
2122 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
2223 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
5960 from the durable one at `/session` and skip the browser.
6061
6162 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.
6365 The web-token beside it is single-use and deliberately not saved. A token the
6466 broker no longer honours is dropped — from disk and memory — and the browser
6567 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.
104106 * Auto-joins `#test` on `irc.freeq.at`
105107 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`
106108 * 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
107111 * Inline previews for PNG links, fetched once and cached under
108112 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
109113 * Join/part notices, DMs bucketed under the sender's nick
110114 * 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`)
111117 * Conversations listed most recently opened first
112118 * Bluesky avatars beside the sender, resolved from the DID freeq tags each
113119 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 directory17 src/frq/store.jolt the saved sign-in, mode 600 in the config directory
18 src/frq/avatars.jolt profile pictures, by DID or handle18 src/frq/avatars.jolt profile pictures, by DID or handle
19 src/frq/media.jolt image links: spot them, fetch them once, cache on disk19 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, PRIVMSG21 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 screens23 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) so62 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 the65 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 browser66 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 instead108 * 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 under111 * Inline previews for PNG links, fetched once and cached under
108 `$XDG_CACHE_HOME/frq/media`; click one to see it full size112 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
109 * Join/part notices, DMs bucketed under the sender's nick113 * Join/part notices, DMs bucketed under the sender's nick
110 * Discover list, search over buffers, disconnect114 * 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 first117 * Conversations listed most recently opened first
112 * Bluesky avatars beside the sender, resolved from the DID freeq tags each118 * Bluesky avatars beside the sender, resolved from the DID freeq tags each
113 message with119 message with
modified src/frq/app.jolt +42 -10
@@ -9,6 +9,7 @@
99 [glimmer.core :as ui]
1010 [glimmer-vidya.core :as vidya]
1111 [frq.avatars :as avatars]
12+ [frq.clock :as clock]
1213 [frq.media :as media]
1314 [frq.platform :as platform]
1415 [frq.state :as s]))
@@ -231,11 +232,13 @@
231232 (= (:from prev) (:from m)))]
232233 [:vbox {:key i :spacing 2 :margin 0}
233234 ;; 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.
235238 [:vbox {:key :who}
236239 (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.
239242 [:hbox {:spacing 6}
240243 ;; Always an avatar, picture or not: the initial stands in until the
241244 ;; fetch lands, and for the guests who have no profile at all, which
@@ -245,7 +248,9 @@
245248 [:avatar {:label (:from m)
246249 :src (or (avatars/path-when-ready (:actor m)) "")
247250 :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)}])])]
249254 [:vbox {:key :text :spacing 2}
250255 (map-indexed (fn [j run] (run-node j run (:system? m)))
251256 (text-runs (:text m)))]
@@ -262,9 +267,28 @@
262267 :max-height 260
263268 :on-click #(reset! s/lightbox {:path path :url url})}]))))]]))
264269
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))
268292
269293 (defn lightbox-screen
270294 "One picture, as big as the window allows.
@@ -377,7 +401,15 @@
377401 [chats-screen])))
378402
379403 (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!))
383415 (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 every234 ;; 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: a240+ ;; 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 the243 ;; 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, which244 ;; 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 260267 :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-screen293 (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 screen404+ ;; 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 @@
223223 (send-line! conn "AUTHENTICATE +"))))))
224224
225225 (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.
227227
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"])
235236
236237 (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."
239241 [conn session msg]
240242 (let [{:keys [command params]} msg]
241243 (case command
242244 "CAP" (let [[_ sub caps] params
243245 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"))]
246248 (case sub
247249 "LS" (if (seq wanted)
248250 (send-line! conn (str "CAP REQ :" (str/join " " wanted)))
249251 (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")
252255 (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE")
253256 (send-line! conn "CAP END"))
254257 "NAK" (send-line! conn "CAP END")
@@ -281,8 +284,8 @@
281284 (try (reader-loop! conn on-msg)
282285 (catch Exception e
283286 (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.
286289 (send-line! conn "CAP LS 302")
287290 (send-line! conn (str "NICK " nick))
288291 (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-caps225 (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 the228+ `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 only231+ 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 and232+ 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 command243 (case command
242 "CAP" (let [[_ sub caps] params244 "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 sub248 (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 e285 (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 sends287+ ;; 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 @@
77 (:require [clojure.string :as str]
88 [glimmer.ratom :as r :refer [atom]]
99 [frq.atproto :as atproto]
10+ [frq.clock :as clock]
1011 [frq.irc :as irc]
1112 [frq.avatars :as avatars]
1213 [frq.media :as media]
@@ -72,6 +73,15 @@
7273
7374 (defn connected? [] (some? @conn))
7475
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+
7585 (defn normalize-channel [s]
7686 (let [s (str/trim (or s ""))]
7787 (cond (str/blank? s) ""
@@ -93,13 +103,16 @@
93103 unless that buffer is the one on screen. Any image it links to is fetched in
94104 the background, as is the sender's avatar.
95105
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)))
103116 ;; The same tick: an avatar arriving is a picture arriving, and the chat
104117 ;; view already repaints on it.
105118 (when who (avatars/fetch! who #(swap! media-tick inc)))
@@ -111,7 +124,8 @@
111124 (update-in [channel :messages] conj
112125 {:from from :text text :system? (= "*" from)
113126 :actor who
114- :images (media/image-urls text)})
127+ :images (media/image-urls text)
128+ :at at})
115129 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
116130
117131 (defn open-channel!
@@ -124,6 +138,7 @@
124138 (swap! channels #(-> (ensure-channel % name)
125139 (assoc-in [name :unread] 0)
126140 (assoc-in [name :accessed] (swap! access-tick inc))))
141+ (remember-channels!)
127142 ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
128143 ;; second JOIN sent in the meantime is what makes the server replay nothing.
129144 (let [buffer (get @channels name)]
@@ -149,10 +164,14 @@
149164 (reset! screen :chats)
150165 (join! auto-join))
151166 "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))
152171 ;; a DM addressed to us belongs in a buffer named for the
153172 ;; sender, not for our own nick
154173 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)}))
156175 "JOIN" (let [ch (first params)]
157176 (if (= from @form-nick)
158177 (let [fresh? (empty? (get-in @channels [ch :messages]))]
@@ -411,6 +430,37 @@
411430 (sort-by (juxt #(- (:accessed % 0)) :name))
412431 vec)))
413432
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+
414464 (defn last-preview [buffer]
415465 (if-let [m (last (:messages buffer))]
416466 (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 in103 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 the106+ 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 chat116 ;; 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] conj124 (update-in [channel :messages] conj
112 {:from from :text text :system? (= "*" from)125 {:from from :text text :system? (= "*" from)
113 :actor who126 :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 a142 ;; `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] params166 "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 the171 ;; a DM addressed to us belongs in a buffer named for the
153 ;; sender, not for our own nick172 ;; 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 @@
4242 true
4343 (catch Exception _ false))))
4444
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+
4568 (defn clear-session! []
4669 (try
4770 (when (host/file-exists? (session-file))
@@ -42,6 +42,29 @@
42 true42 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 (try69 (try
47 (when (host/file-exists? (session-file))70 (when (host/file-exists? (session-file))