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>
f5548bd parent: e84d24d modified
README.md +4 -1 | @@ -15,6 +15,7 @@ of an `AppState` struct. | ||
| 15 | 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session |
| 17 | 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory |
| 18 | +src/frq/avatars.jolt profile pictures, by DID or handle | |
| 18 | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 19 | 20 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 20 | 21 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| @@ -108,6 +109,8 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 108 | 109 | * Join/part notices, DMs bucketed under the sender's nick |
| 109 | 110 | * Discover list, search over buffers, disconnect |
| 110 | 111 | * 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 | 115 | ## Limits |
| 113 | 116 | |
| @@ -121,6 +124,6 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 121 | 124 | format, and a fetch needs TLS, so the phone shows links. The link is left in |
| 122 | 125 | place either way. |
| 123 | 126 | * **Nothing evicts the media cache.** |
| 124 | -* **No scrollback trimming, avatars, reactions, threads, or calls.** | |
| 127 | +* **No scrollback trimming, reactions, threads, or calls.** | |
| 125 | 128 | * A sent line waits up to 200ms for the reader thread to flush it. |
| 126 | 129 | * 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 payloads | 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session |
| 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory | 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory |
| 18 | +src/frq/avatars.jolt profile pictures, by DID or handle | ||
| 18 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 19 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 20 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 20 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | 21 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| @@ -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 nick | 109 | * Join/part notices, DMs bucketed under the sender's nick |
| 109 | * Discover list, search over buffers, disconnect | 110 | * Discover list, search over buffers, disconnect |
| 110 | * Conversations listed most recently opened first | 111 | * 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 | ## Limits | 115 | ## 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 in | 124 | 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 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [glimmer.core :as ui] |
| 10 | 10 | [glimmer-vidya.core :as vidya] |
| 11 | + [frq.avatars :as avatars] | |
| 11 | 12 | [frq.media :as media] |
| 12 | 13 | [frq.platform :as platform] |
| 13 | 14 | [frq.state :as s])) |
| @@ -233,7 +234,15 @@ | ||
| 233 | 234 | ;; line is what made a busy channel look like a list of headers. |
| 234 | 235 | [:vbox {:key :who} |
| 235 | 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 | 246 | [:vbox {:key :text :spacing 2} |
| 238 | 247 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 239 | 248 | (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 @@ | ||
| 67 | 67 | trailing (when i (subs rest-line (+ i 2))) |
| 68 | 68 | parts (remove str/blank? (str/split head #" "))] |
| 69 | 69 | {:tags tags |
| 70 | + :account (when tags | |
| 71 | + (second (re-find #"(?:^|;)account=([^;]*)" tags))) | |
| 70 | 72 | :prefix prefix |
| 71 | 73 | :command (str/upper-case (or (first parts) "")) |
| 72 | 74 | :params (cond-> (vec (rest parts)) trailing (conj trailing))})) |
| @@ -220,18 +222,33 @@ | ||
| 220 | 222 | (when (= sasl-chunk (count rest)) |
| 221 | 223 | (send-line! conn "AUTHENTICATE +")))))) |
| 222 | 224 | |
| 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. | |
| 225 | 238 | Returns the message unchanged, so the caller can go on handling it." |
| 226 | 239 | [conn session msg] |
| 227 | 240 | (let [{:keys [command params]} msg] |
| 228 | 241 | (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 | 246 | (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))) | |
| 233 | 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 | 252 | (send-line! conn "AUTHENTICATE ATPROTO-CHALLENGE") |
| 236 | 253 | (send-line! conn "CAP END")) |
| 237 | 254 | "NAK" (send-line! conn "CAP END") |
| @@ -259,16 +276,14 @@ | ||
| 259 | 276 | ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil)) |
| 260 | 277 | ([host port nick on-msg tls? session] |
| 261 | 278 | (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)))] | |
| 265 | 280 | (future |
| 266 | 281 | (try (reader-loop! conn on-msg) |
| 267 | 282 | (catch Exception e |
| 268 | 283 | (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") | |
| 272 | 287 | (send-line! conn (str "NICK " nick)) |
| 273 | 288 | (send-line! conn (str "USER " nick " 0 * :" nick)) |
| 274 | 289 | 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 tags | 69 | {:tags tags |
| 70 | + :account (when tags | ||
| 71 | + (second (re-find #"(?:^|;)account=([^;]*)" tags))) | ||
| 70 | :prefix prefix | 72 | :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 command | 241 | (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 sub | 246 | (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 session | 279 | + on-msg (fn [msg] (on-msg (cap-step! conn session msg)))] |
| 263 | - (fn [msg] (on-msg (sasl-step! conn session msg))) | ||
| 264 | - on-msg)] | ||
| 265 | (future | 280 | (future |
| 266 | (try (reader-loop! conn on-msg) | 281 | (try (reader-loop! conn on-msg) |
| 267 | (catch Exception e | 282 | (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, which | 284 | + ;; 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 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [frq.atproto :as atproto] |
| 10 | 10 | [frq.irc :as irc] |
| 11 | + [frq.avatars :as avatars] | |
| 11 | 12 | [frq.media :as media] |
| 12 | 13 | [frq.oauth :as oauth] |
| 13 | 14 | [frq.store :as store])) |
| @@ -90,19 +91,28 @@ | ||
| 90 | 91 | (defn push-message! |
| 91 | 92 | "Append a line to a buffer, creating it if needed, and bump the unread count |
| 92 | 93 | 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))))))))) | |
| 106 | 116 | |
| 107 | 117 | (defn open-channel! |
| 108 | 118 | "Show a buffer. A channel we are not in is joined on the way — a row can |
| @@ -142,7 +152,7 @@ | ||
| 142 | 152 | ;; a DM addressed to us belongs in a buffer named for the |
| 143 | 153 | ;; sender, not for our own nick |
| 144 | 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 | 156 | "JOIN" (let [ch (first params)] |
| 147 | 157 | (if (= from @form-nick) |
| 148 | 158 | (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 count | 92 | "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 in | 93 | 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! channels | 98 | + ([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 | - (-> m | 102 | + (let [who (avatars/actor did from)] |
| 102 | - (update-in [channel :messages] conj | 103 | + ;; 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 can | 118 | "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 the | 152 | ;; a DM addressed to us belongs in a buffer named for the |
| 143 | ;; sender, not for our own nick | 153 | ;; 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]))] |