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

Show who is talking, with their Bluesky picture

freeq tags every message with the sender's DID, so the identity is there for
the asking — and it has to be asked for twice: `account-tag` puts the DID on
the message, and `message-tags` is what makes the server send tags at all. One
without the other is ACKed and then nothing arrives, which is how this looked
like it was already working.

That is also why a guest connection negotiates capabilities now, not only an
authenticated one. The DID is the only identity a client is given: a nick is
whatever someone chose today, and the hostmask carries eight characters of a
DID — too few to resolve. Where there is no tag, a handle-shaped nick is tried
instead, since freeq gives an authenticated user their handle by default;
`sleek5209` is a guest with no profile to fetch.

The picture is the thumbnail preset as PNG rather than the CDN's default: the
tree backend decodes PNG, and 128 pixels is what a 20-point avatar needs — the
full-size portrait is 170KB to draw at the size of a full stop. Fetched once
per person however many lines they write, cached on disk between runs, and
painted beside the name at the head of their run of lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T11:20:48-07:00 Browse files
f5548bd parent: e84d24d
modified README.md +4 -1
@@ -15,6 +15,7 @@ of an `AppState` struct.
1515 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
18+src/frq/avatars.jolt profile pictures, by DID or handle
1819 src/frq/media.jolt image links: spot them, fetch them once, cache on disk
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!`
@@ -108,6 +109,8 @@ surface — that surface does not work on Android either, while the syscalls do.
108109 * Join/part notices, DMs bucketed under the sender's nick
109110 * Discover list, search over buffers, disconnect
110111 * Conversations listed most recently opened first
112+* Bluesky avatars beside the sender, resolved from the DID freeq tags each
113+ message with
111114
112115 ## Limits
113116
@@ -121,6 +124,6 @@ surface — that surface does not work on Android either, while the syscalls do.
121124 format, and a fetch needs TLS, so the phone shows links. The link is left in
122125 place either way.
123126 * **Nothing evicts the media cache.**
124-* **No scrollback trimming, avatars, reactions, threads, or calls.**
127+* **No scrollback trimming, reactions, threads, or calls.**
125128 * A sent line waits up to 200ms for the reader thread to flush it.
126129 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
@@ -15,6 +15,7 @@ of an `AppState` struct.
15 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads15 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/avatars.jolt profile pictures, by DID or handle
18 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
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!`
@@ -108,6 +109,8 @@ surface — that surface does not work on Android either, while the syscalls do.
108 * Join/part notices, DMs bucketed under the sender's nick109 * Join/part notices, DMs bucketed under the sender's nick
109 * Discover list, search over buffers, disconnect110 * Discover list, search over buffers, disconnect
110 * Conversations listed most recently opened first111 * Conversations listed most recently opened first
112+* Bluesky avatars beside the sender, resolved from the DID freeq tags each
113+ message with
111 114
112 ## Limits115 ## Limits
113 116
@@ -121,6 +124,6 @@ surface — that surface does not work on Android either, while the syscalls do.
121 format, and a fetch needs TLS, so the phone shows links. The link is left in124 format, and a fetch needs TLS, so the phone shows links. The link is left in
122 place either way.125 place either way.
123 * **Nothing evicts the media cache.**126 * **Nothing evicts the media cache.**
124-* **No scrollback trimming, avatars, reactions, threads, or calls.**127+* **No scrollback trimming, reactions, threads, or calls.**
125 * A sent line waits up to 200ms for the reader thread to flush it.128 * A sent line waits up to 200ms for the reader thread to flush it.
126 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.129 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
modified src/frq/app.jolt +10 -1
@@ -8,6 +8,7 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [glimmer.core :as ui]
1010 [glimmer-vidya.core :as vidya]
11+ [frq.avatars :as avatars]
1112 [frq.media :as media]
1213 [frq.platform :as platform]
1314 [frq.state :as s]))
@@ -233,7 +234,15 @@
233234 ;; line is what made a busy channel look like a list of headers.
234235 [:vbox {:key :who}
235236 (when-not (or same-sender? (:system? m))
236- [:dim-label {:label (:from 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.
239+ [:hbox {:spacing 6}
240+ [:vbox {:key :avatar}
241+ ;; Reading the tick subscribes this row to the fetch finishing.
242+ (let [_ @s/media-tick]
243+ (when-let [path (avatars/path-when-ready (:actor m))]
244+ [:image {:src path :max-height 20}]))]
245+ [:dim-label {:label (:from m)}]])]
237246 [:vbox {:key :text :spacing 2}
238247 (map-indexed (fn [j run] (run-node j run (:system? m)))
239248 (text-runs (:text m)))]
@@ -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.avatars :as avatars]
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]))
@@ -233,7 +234,15 @@
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.
234 [:vbox {:key :who}235 [:vbox {:key :who}
235 (when-not (or same-sender? (:system? m))236 (when-not (or same-sender? (:system? m))
236- [:dim-label {:label (:from 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.
239+ [:hbox {:spacing 6}
240+ [:vbox {:key :avatar}
241+ ;; Reading the tick subscribes this row to the fetch finishing.
242+ (let [_ @s/media-tick]
243+ (when-let [path (avatars/path-when-ready (:actor m))]
244+ [:image {:src path :max-height 20}]))]
245+ [:dim-label {:label (:from m)}]])]
237 [:vbox {:key :text :spacing 2}246 [:vbox {:key :text :spacing 2}
238 (map-indexed (fn [j run] (run-node j run (:system? m)))247 (map-indexed (fn [j run] (run-node j run (:system? m)))
239 (text-runs (:text m)))]248 (text-runs (:text m)))]
added src/frq/avatars.jolt +93 -0
new file mode 100644
@@ -0,0 +1,93 @@
1+(ns frq.avatars
2+ "Profile pictures for the people with an AT Protocol identity behind them.
3+
4+ freeq gives an authenticated user their handle as their nick `nandi.uk`
5+ rather than `sleek5209` so the nick is the lookup, and a nick that is not
6+ handle-shaped is a guest with no profile to fetch. One lookup per person
7+ however many lines they write, kept on disk between runs like any other
8+ picture.
9+
10+ The thumbnail preset, and `@png` rather than the CDN's default: the tree
11+ backend decodes PNG, and 128×128 is what a 24-point avatar needs."
12+ (:require [clojure.string :as str]
13+ [frq.atproto :as atproto]
14+ [jolt.host :as host]
15+ [jolt.mvn-http :as http]))
16+
17+(def ^:private directory-host "public.api.bsky.app")
18+
19+;; A handle is a domain: labels joined by dots, ending in something alphabetic.
20+;; An IRC nick cannot be one by accident `sleek5209` and `eve` are not.
21+(def ^:private handle-pattern #"(?i)^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*\.[a-z]{2,}$")
22+
23+(defn handle?
24+ "Whether this nick is an AT Protocol handle, and so worth a lookup."
25+ [nick]
26+ (boolean (and nick (re-matches handle-pattern nick))))
27+
28+(defn actor
29+ "The identity to look a profile up by, or nil when there is none.
30+
31+ A DID from the message's `account` tag when the server sent one — it is the
32+ identity itself, and holds whatever the nick happens to be today. Otherwise
33+ the nick, but only when it is handle-shaped: freeq gives an authenticated
34+ user their handle by default, while `sleek5209` is a guest with no profile."
35+ [did nick]
36+ (cond
37+ (and did (str/starts-with? did "did:")) did
38+ (handle? nick) nick
39+ :else nil))
40+
41+(defn cache-dir []
42+ (let [xdg (host/getenv "XDG_CACHE_HOME")
43+ home (host/getenv "HOME")]
44+ (str (if (seq xdg) xdg (str home "/.cache")) "/frq/avatars")))
45+
46+(defn cached-path [handle]
47+ (str (cache-dir) "/" (str/replace (str/lower-case handle) #"[^a-z0-9._-]" "_") ".png"))
48+
49+;; handle -> :fetching | :ready | :failed
50+(defonce state (atom {}))
51+
52+(defn path-when-ready [handle]
53+ (when (= :ready (get @state handle)) (cached-path handle)))
54+
55+(defn- thumbnail-url
56+ "The CDN's full-size avatar URL as a 128-pixel PNG. Asking for the size we
57+ paint keeps a 170KB portrait from being downloaded to draw at 24 points."
58+ [url]
59+ (when (seq url)
60+ (-> url
61+ (str/replace "/img/avatar/plain/" "/img/avatar_thumbnail/plain/")
62+ (str/replace #"@[a-z]+$" "")
63+ (str "@png"))))
64+
65+(defn- profile-avatar
66+ "The avatar URL on someone's profile, or nil if they have none."
67+ [handle]
68+ (let [body (atproto/request directory-host
69+ (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
70+ nil)]
71+ (thumbnail-url (atproto/json-str body "avatar"))))
72+
73+(defn fetch!
74+ "Ensure this person's avatar is on disk, in the background. Returns without
75+ waiting; `path-when-ready` answers for it afterwards, and `on-change` says
76+ when that answer has changed."
77+ [handle on-change]
78+ (when (and (seq handle) (not (contains? @state handle)))
79+ (let [path (cached-path handle)]
80+ (if (host/file-exists? path)
81+ (do (swap! state assoc handle :ready) (on-change))
82+ (do
83+ (swap! state assoc handle :fetching)
84+ (future
85+ (let [ok (try
86+ (host/mkdirs! (cache-dir))
87+ (http/ensure-native!)
88+ (when-let [url (profile-avatar handle)]
89+ (and (http/fetch url path)
90+ (host/file-exists? path)))
91+ (catch Exception _ false))]
92+ (swap! state assoc handle (if ok :ready :failed))
93+ (on-change))))))))
new file mode 100644
@@ -0,0 +1,93 @@
1+(ns frq.avatars
2+ "Profile pictures for the people with an AT Protocol identity behind them.
3+
4+ freeq gives an authenticated user their handle as their nick `nandi.uk`
5+ rather than `sleek5209` so the nick is the lookup, and a nick that is not
6+ handle-shaped is a guest with no profile to fetch. One lookup per person
7+ however many lines they write, kept on disk between runs like any other
8+ picture.
9+
10+ The thumbnail preset, and `@png` rather than the CDN's default: the tree
11+ backend decodes PNG, and 128×128 is what a 24-point avatar needs."
12+ (:require [clojure.string :as str]
13+ [frq.atproto :as atproto]
14+ [jolt.host :as host]
15+ [jolt.mvn-http :as http]))
16+
17+(def ^:private directory-host "public.api.bsky.app")
18+
19+;; A handle is a domain: labels joined by dots, ending in something alphabetic.
20+;; An IRC nick cannot be one by accident `sleek5209` and `eve` are not.
21+(def ^:private handle-pattern #"(?i)^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*\.[a-z]{2,}$")
22+
23+(defn handle?
24+ "Whether this nick is an AT Protocol handle, and so worth a lookup."
25+ [nick]
26+ (boolean (and nick (re-matches handle-pattern nick))))
27+
28+(defn actor
29+ "The identity to look a profile up by, or nil when there is none.
30+
31+ A DID from the message's `account` tag when the server sent one — it is the
32+ identity itself, and holds whatever the nick happens to be today. Otherwise
33+ the nick, but only when it is handle-shaped: freeq gives an authenticated
34+ user their handle by default, while `sleek5209` is a guest with no profile."
35+ [did nick]
36+ (cond
37+ (and did (str/starts-with? did "did:")) did
38+ (handle? nick) nick
39+ :else nil))
40+
41+(defn cache-dir []
42+ (let [xdg (host/getenv "XDG_CACHE_HOME")
43+ home (host/getenv "HOME")]
44+ (str (if (seq xdg) xdg (str home "/.cache")) "/frq/avatars")))
45+
46+(defn cached-path [handle]
47+ (str (cache-dir) "/" (str/replace (str/lower-case handle) #"[^a-z0-9._-]" "_") ".png"))
48+
49+;; handle -> :fetching | :ready | :failed
50+(defonce state (atom {}))
51+
52+(defn path-when-ready [handle]
53+ (when (= :ready (get @state handle)) (cached-path handle)))
54+
55+(defn- thumbnail-url
56+ "The CDN's full-size avatar URL as a 128-pixel PNG. Asking for the size we
57+ paint keeps a 170KB portrait from being downloaded to draw at 24 points."
58+ [url]
59+ (when (seq url)
60+ (-> url
61+ (str/replace "/img/avatar/plain/" "/img/avatar_thumbnail/plain/")
62+ (str/replace #"@[a-z]+$" "")
63+ (str "@png"))))
64+
65+(defn- profile-avatar
66+ "The avatar URL on someone's profile, or nil if they have none."
67+ [handle]
68+ (let [body (atproto/request directory-host
69+ (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
70+ nil)]
71+ (thumbnail-url (atproto/json-str body "avatar"))))
72+
73+(defn fetch!
74+ "Ensure this person's avatar is on disk, in the background. Returns without
75+ waiting; `path-when-ready` answers for it afterwards, and `on-change` says
76+ when that answer has changed."
77+ [handle on-change]
78+ (when (and (seq handle) (not (contains? @state handle)))
79+ (let [path (cached-path handle)]
80+ (if (host/file-exists? path)
81+ (do (swap! state assoc handle :ready) (on-change))
82+ (do
83+ (swap! state assoc handle :fetching)
84+ (future
85+ (let [ok (try
86+ (host/mkdirs! (cache-dir))
87+ (http/ensure-native!)
88+ (when-let [url (profile-avatar handle)]
89+ (and (http/fetch url path)
90+ (host/file-exists? path)))
91+ (catch Exception _ false))]
92+ (swap! state assoc handle (if ok :ready :failed))
93+ (on-change))))))))
modified src/frq/irc.jolt +27 -12
@@ -67,6 +67,8 @@
6767 trailing (when i (subs rest-line (+ i 2)))
6868 parts (remove str/blank? (str/split head #" "))]
6969 {:tags tags
70+ :account (when tags
71+ (second (re-find #"(?:^|;)account=([^;]*)" tags)))
7072 :prefix prefix
7173 :command (str/upper-case (or (first parts) ""))
7274 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
@@ -220,18 +222,33 @@
220222 (when (= sasl-chunk (count rest))
221223 (send-line! conn "AUTHENTICATE +"))))))
222224
223-(defn- sasl-step!
224- "Drive the SASL exchange from the lines the server sends during registration.
225+(def ^:private wanted-caps
226+ "What this client can actually use.
227+
228+ `account-tag` is why a guest connection negotiates at all: it puts the
229+ sender's DID on every message, which is the only identity a client is given —
230+ a nick may be anything, and the hostmask carries eight characters of a DID,
231+ too few to resolve. It needs `message-tags` beside it: IRCv3 sends tags only
232+ to clients that asked for tags at all, so `account-tag` alone is ACKed and
233+ then nothing arrives."
234+ ["message-tags" "account-tag"])
235+
236+(defn- cap-step!
237+ "Drive capability negotiation, and the SASL exchange when there is a session.
225238 Returns the message unchanged, so the caller can go on handling it."
226239 [conn session msg]
227240 (let [{:keys [command params]} msg]
228241 (case command
229- "CAP" (let [[_ sub caps] params]
242+ "CAP" (let [[_ sub caps] params
243+ offered (set (str/split (or caps "") #"\s+"))
244+ sasl? (and session (contains? offered "sasl"))
245+ wanted (cond-> (filterv offered wanted-caps) sasl? (conj "sasl"))]
230246 (case sub
231- "LS" (if (str/includes? (or caps "") "sasl")
232- (send-line! conn "CAP REQ :sasl")
247+ "LS" (if (seq wanted)
248+ (send-line! conn (str "CAP REQ :" (str/join " " wanted)))
233249 (send-line! conn "CAP END"))
234- "ACK" (if (str/includes? (or caps "") "sasl")
250+ ;; SASL, when acknowledged, is what CAP END now waits for.
251+ "ACK" (if (contains? offered "sasl")
235252 (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE")
236253 (send-line! conn "CAP END"))
237254 "NAK" (send-line! conn "CAP END")
@@ -259,16 +276,14 @@
259276 ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil))
260277 ([host port nick on-msg tls? session]
261278 (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)]
279+ on-msg (fn [msg] (on-msg (cap-step! conn session msg)))]
265280 (future
266281 (try (reader-loop! conn on-msg)
267282 (catch Exception e
268283 (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"))
284+ ;; CAP first: registration waits for the CAP END that negotiation sends
285+ ;; once it has an answer, either way.
286+ (send-line! conn "CAP LS 302")
272287 (send-line! conn (str "NICK " nick))
273288 (send-line! conn (str "USER " nick " 0 * :" nick))
274289 conn)))
@@ -67,6 +67,8 @@
67 trailing (when i (subs rest-line (+ i 2)))67 trailing (when i (subs rest-line (+ i 2)))
68 parts (remove str/blank? (str/split head #" "))]68 parts (remove str/blank? (str/split head #" "))]
69 {:tags tags69 {:tags tags
70+ :account (when tags
71+ (second (re-find #"(?:^|;)account=([^;]*)" tags)))
70 :prefix prefix72 :prefix prefix
71 :command (str/upper-case (or (first parts) ""))73 :command (str/upper-case (or (first parts) ""))
72 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))74 :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
@@ -220,18 +222,33 @@
220 (when (= sasl-chunk (count rest))222 (when (= sasl-chunk (count rest))
221 (send-line! conn "AUTHENTICATE +"))))))223 (send-line! conn "AUTHENTICATE +"))))))
222 224
223-(defn- sasl-step!225+(def ^:private wanted-caps
224- "Drive the SASL exchange from the lines the server sends during registration.226+ "What this client can actually use.
227+
228+ `account-tag` is why a guest connection negotiates at all: it puts the
229+ sender's DID on every message, which is the only identity a client is given —
230+ a nick may be anything, and the hostmask carries eight characters of a DID,
231+ too few to resolve. It needs `message-tags` beside it: IRCv3 sends tags only
232+ to clients that asked for tags at all, so `account-tag` alone is ACKed and
233+ then nothing arrives."
234+ ["message-tags" "account-tag"])
235+
236+(defn- cap-step!
237+ "Drive capability negotiation, and the SASL exchange when there is a session.
225 Returns the message unchanged, so the caller can go on handling it."238 Returns the message unchanged, so the caller can go on handling it."
226 [conn session msg]239 [conn session msg]
227 (let [{:keys [command params]} msg]240 (let [{:keys [command params]} msg]
228 (case command241 (case command
229- "CAP" (let [[_ sub caps] params]242+ "CAP" (let [[_ sub caps] params
243+ offered (set (str/split (or caps "") #"\s+"))
244+ sasl? (and session (contains? offered "sasl"))
245+ wanted (cond-> (filterv offered wanted-caps) sasl? (conj "sasl"))]
230 (case sub246 (case sub
231- "LS" (if (str/includes? (or caps "") "sasl")247+ "LS" (if (seq wanted)
232- (send-line! conn "CAP REQ :sasl")248+ (send-line! conn (str "CAP REQ :" (str/join " " wanted)))
233 (send-line! conn "CAP END"))249 (send-line! conn "CAP END"))
234- "ACK" (if (str/includes? (or caps "") "sasl")250+ ;; SASL, when acknowledged, is what CAP END now waits for.
251+ "ACK" (if (contains? offered "sasl")
235 (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE")252 (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE")
236 (send-line! conn "CAP END"))253 (send-line! conn "CAP END"))
237 "NAK" (send-line! conn "CAP END")254 "NAK" (send-line! conn "CAP END")
@@ -259,16 +276,14 @@
259 ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil))276 ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil))
260 ([host port nick on-msg tls? session]277 ([host port nick on-msg tls? session]
261 (let [conn (open host port tls? nick)278 (let [conn (open host port tls? nick)
262- on-msg (if session279+ 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 (future280 (future
266 (try (reader-loop! conn on-msg)281 (try (reader-loop! conn on-msg)
267 (catch Exception e282 (catch Exception e
268 (on-msg {:command "*ERROR*" :params [(str e)]}))))283 (on-msg {:command "*ERROR*" :params [(str e)]}))))
269- ;; CAP first when authenticating: registration waits for CAP END, which284+ ;; CAP first: registration waits for the CAP END that negotiation sends
270- ;; the SASL exchange sends once it has an answer either way.285+ ;; once it has an answer, either way.
271- (when session (send-line! conn "CAP LS 302"))286+ (send-line! conn "CAP LS 302")
272 (send-line! conn (str "NICK " nick))287 (send-line! conn (str "NICK " nick))
273 (send-line! conn (str "USER " nick " 0 * :" nick))288 (send-line! conn (str "USER " nick " 0 * :" nick))
274 conn)))289 conn)))
modified src/frq/state.jolt +24 -14
@@ -8,6 +8,7 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [frq.atproto :as atproto]
1010 [frq.irc :as irc]
11+ [frq.avatars :as avatars]
1112 [frq.media :as media]
1213 [frq.oauth :as oauth]
1314 [frq.store :as store]))
@@ -90,19 +91,28 @@
9091 (defn push-message!
9192 "Append a line to a buffer, creating it if needed, and bump the unread count
9293 unless that buffer is the one on screen. Any image it links to is fetched in
93- the background."
94- [channel from text]
95- (doseq [url (media/image-urls text)]
96- (media/fetch! url #(swap! media-tick inc)))
97- (swap! channels
98- (fn [m]
99- (let [m (ensure-channel m channel)
100- viewing? (and (= :chat @screen) (= channel @current))]
101- (-> m
102- (update-in [channel :messages] conj
103- {:from from :text text :system? (= "*" from)
104- :images (media/image-urls text)})
105- (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))
94+ the background, as is the sender's avatar.
95+
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)]
103+ ;; The same tick: an avatar arriving is a picture arriving, and the chat
104+ ;; view already repaints on it.
105+ (when who (avatars/fetch! who #(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+ :actor who
114+ :images (media/image-urls text)})
115+ (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
106116
107117 (defn open-channel!
108118 "Show a buffer. A channel we are not in is joined on the way — a row can
@@ -142,7 +152,7 @@
142152 ;; a DM addressed to us belongs in a buffer named for the
143153 ;; sender, not for our own nick
144154 buffer (if (str/starts-with? (or target "") "#") target from)]
145- (push-message! buffer from text))
155+ (push-message! buffer from text (:account msg)))
146156 "JOIN" (let [ch (first params)]
147157 (if (= from @form-nick)
148158 (let [fresh? (empty? (get-in @channels [ch :messages]))]
@@ -8,6 +8,7 @@
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.irc :as irc]10 [frq.irc :as irc]
11+ [frq.avatars :as avatars]
11 [frq.media :as media]12 [frq.media :as media]
12 [frq.oauth :as oauth]13 [frq.oauth :as oauth]
13 [frq.store :as store]))14 [frq.store :as store]))
@@ -90,19 +91,28 @@
90 (defn push-message!91 (defn push-message!
91 "Append a line to a buffer, creating it if needed, and bump the unread count92 "Append a line to a buffer, creating it if needed, and bump the unread count
92 unless that buffer is the one on screen. Any image it links to is fetched in93 unless that buffer is the one on screen. Any image it links to is fetched in
93- the background."94+ the background, as is the sender's avatar.
94- [channel from text]95+
95- (doseq [url (media/image-urls text)]96+ `did` is the sender's identity from the message's `account` tag, when the
96- (media/fetch! url #(swap! media-tick inc)))97+ server sent one it outlasts whatever nick they are using today."
97- (swap! channels98+ ([channel from text] (push-message! channel from text nil))
98- (fn [m]99+ ([channel from text did]
99- (let [m (ensure-channel m channel)100+ (doseq [url (media/image-urls text)]
100- viewing? (and (= :chat @screen) (= channel @current))]101+ (media/fetch! url #(swap! media-tick inc)))
101- (-> m102+ (let [who (avatars/actor did from)]
102- (update-in [channel :messages] conj103+ ;; The same tick: an avatar arriving is a picture arriving, and the chat
103- {:from from :text text :system? (= "*" from)104+ ;; view already repaints on it.
104- :images (media/image-urls text)})105+ (when who (avatars/fetch! who #(swap! media-tick inc)))
105- (update-in [channel :unread] (if viewing? (constantly 0) 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+ :actor who
114+ :images (media/image-urls text)})
115+ (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
106 116
107 (defn open-channel!117 (defn open-channel!
108 "Show a buffer. A channel we are not in is joined on the way — a row can118 "Show a buffer. A channel we are not in is joined on the way — a row can
@@ -142,7 +152,7 @@
142 ;; a DM addressed to us belongs in a buffer named for the152 ;; a DM addressed to us belongs in a buffer named for the
143 ;; sender, not for our own nick153 ;; sender, not for our own nick
144 buffer (if (str/starts-with? (or target "") "#") target from)]154 buffer (if (str/starts-with? (or target "") "#") target from)]
145- (push-message! buffer from text))155+ (push-message! buffer from text (:account msg)))
146 "JOIN" (let [ch (first params)]156 "JOIN" (let [ch (first params)]
147 (if (= from @form-nick)157 (if (= from @form-nick)
148 (let [fresh? (empty? (get-in @channels [ch :messages]))]158 (let [fresh? (empty? (get-in @channels [ch :messages]))]