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

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>
nandi committed 2026-08-30T11:24:39-07:00 Browse files
dbf3b86 parent: 1d062fd
modified README.md +3 -0
@@ -16,6 +16,7 @@ src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
1616 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/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
1920 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
2021 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
2122 src/frq/app.jolt the screens
@@ -104,6 +105,8 @@ surface — that surface does not work on Android either, while the syscalls do.
104105 * Auto-joins `#test` on `irc.freeq.at`
105106 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`
106107 * 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
107110 * Inline previews for PNG links, fetched once and cached under
108111 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
109112 * 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, /session16 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/media.jolt image links: spot them, fetch them once, cache on disk18 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, PRIVMSG20 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 screens22 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 instead107 * 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 under110 * Inline previews for PNG links, fetched once and cached under
108 `$XDG_CACHE_HOME/frq/media`; click one to see it full size111 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
109 * Join/part notices, DMs bucketed under the sender's nick112 * Join/part notices, DMs bucketed under the sender's nick
modified src/frq/app.jolt +30 -5
@@ -8,6 +8,7 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [glimmer.core :as ui]
1010 [glimmer-vidya.core :as vidya]
11+ [frq.clock :as clock]
1112 [frq.media :as media]
1213 [frq.platform :as platform]
1314 [frq.state :as s]))
@@ -230,10 +231,15 @@
230231 (= (:from prev) (:from m)))]
231232 [:vbox {:key i :spacing 2 :margin 0}
232233 ;; 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.
234237 [:vbox {:key :who}
235238 (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)}])])]
237243 [:vbox {:key :text :spacing 2}
238244 (map-indexed (fn [j run] (run-node j run (:system? m)))
239245 (text-runs (:text m)))]
@@ -250,9 +256,28 @@
250256 :max-height 260
251257 :on-click #(reset! s/lightbox {:path path :url url})}]))))]]))
252258
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))
256281
257282 (defn lightbox-screen
258283 "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 every233 ;; 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 260256 :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-screen282 (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 @@
220220 (when (= sasl-chunk (count rest))
221221 (send-line! conn "AUTHENTICATE +"))))))
222222
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."
226233 [conn session msg]
227234 (let [{:keys [command params]} msg]
228235 (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"))]
230240 (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)))
233243 (send-line! conn "CAP END"))
244+ ;; SASL, when acked, ends negotiation itself — CAP END waits
245+ ;; for the exchange to finish either way.
234246 "ACK" (if (str/includes? (or caps "") "sasl")
235247 (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE")
236248 (send-line! conn "CAP END"))
@@ -259,16 +271,14 @@
259271 ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil))
260272 ([host port nick on-msg tls? session]
261273 (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)))]
265275 (future
266276 (try (reader-loop! conn on-msg)
267277 (catch Exception e
268278 (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")
272282 (send-line! conn (str "NICK " nick))
273283 (send-line! conn (str "USER " nick " 0 * :" nick))
274284 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 command235 (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 sub240 (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 session274+ 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 (future275 (future
266 (try (reader-loop! conn on-msg)276 (try (reader-loop! conn on-msg)
267 (catch Exception e277 (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, which279+ ;; 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 @@
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.media :as media]
1213 [frq.oauth :as oauth]
@@ -100,18 +101,20 @@
100101 "Append a line to a buffer, creating it if needed, and bump the unread count
101102 unless that buffer is the one on screen. Any image it links to is fetched in
102103 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))))))))
115118
116119 (defn open-channel!
117120 "Show a buffer. A channel we are not in is joined on the way — a row can
@@ -149,10 +152,14 @@
149152 (reset! screen :chats)
150153 (join! auto-join))
151154 "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))
152159 ;; a DM addressed to us belongs in a buffer named for the
153160 ;; sender, not for our own nick
154161 buffer (if (str/starts-with? (or target "") "#") target from)]
155- (push-message! buffer from text))
162+ (push-message! buffer from text at))
156163 "JOIN" (let [ch (first params)]
157164 (if (= from @form-nick)
158165 (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 count101 "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 in102 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! channels107+ (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- (-> m111+ viewing? (and (= :chat @screen) (= channel @current))]
111- (update-in [channel :messages] conj112+ (-> 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 can120 "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] params154 "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 the159 ;; a DM addressed to us belongs in a buffer named for the
153 ;; sender, not for our own nick160 ;; 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]))]