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

Put faces on the Flutter conversation

The chat screen asked `avatar-path` for every sender and got nil, because
nothing on this side installed it: each row drew the initial that stands in
until a picture lands, for ever.

The desktop's `frq.avatars` downloads the PNG to ~/.cache and hands back a
path, because the tree backend paints from a decoded file and nothing under
jolt fetches an image for it. Flutter already has that layer — NetworkImage
fetches, decodes and caches by URL — so `frq.avatars.dart` keeps the URL and
nothing else, and the disk half would have been a download written so Flutter
could read it back and fetch nothing. What it costs is the between-runs cache:
one 128-pixel PNG per person per launch, against adding a binary download to a
seam that has so far needed only strings.

So `:avatar` in `frq.hiccup` now takes either kind of source, the way `:image`
already did, and swallows a load failure rather than sending one line per
frame to debugPrint for someone whose avatar has been deleted. The thumbnail
rewrite moves to `frq.profile`, where both halves can read it off the same
getProfile body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T07:14:54-07:00 Browse files
9126058 parent: 5f32523
modified common/frq/profile.cljc +21 -0
@@ -73,11 +73,32 @@
7373 {:host directory-host
7474 :path (str "/xrpc/app.bsky.actor.getProfile?actor=" actor)})
7575
76+(defn thumbnail-url
77+ "The CDN's full-size avatar URL as a 128-pixel PNG. Asking for the size we
78+ paint keeps a 170KB portrait from being downloaded to draw at 24 points.
79+
80+ Here rather than in `frq.avatars` because `frq.avatars` is jolt's: the phone
81+ needs the same rewrite off the same `getProfile` body, and the rule for a
82+ string transformation both halves need is that it lives in `common/`."
83+ [url]
84+ (when (seq (str (or url "")))
85+ (-> url
86+ (str/replace "/img/avatar/plain/" "/img/avatar_thumbnail/plain/")
87+ (str/replace #"@[a-z]+$" "")
88+ (str "@png"))))
89+
90+(defn avatar-url
91+ "The thumbnail URL on this `app.bsky.actor.getProfile` body, or nil when the
92+ person has no picture."
93+ [body]
94+ (thumbnail-url (atproto/json-str body "avatar")))
95+
7696 (defn parse
7797 "The fields the screen paints, out of an `app.bsky.actor.getProfile` body."
7898 [body]
7999 {:status :ready
80100 :did (atproto/json-str body "did")
101+ :avatar (avatar-url body)
81102 :handle (atproto/json-str body "handle")
82103 :display-name (some-> (atproto/json-str body "displayName")
83104 atproto/json-unescape
@@ -73,11 +73,32 @@
73 {:host directory-host73 {:host directory-host
74 :path (str "/xrpc/app.bsky.actor.getProfile?actor=" actor)})74 :path (str "/xrpc/app.bsky.actor.getProfile?actor=" actor)})
75 75
76+(defn thumbnail-url
77+ "The CDN's full-size avatar URL as a 128-pixel PNG. Asking for the size we
78+ paint keeps a 170KB portrait from being downloaded to draw at 24 points.
79+
80+ Here rather than in `frq.avatars` because `frq.avatars` is jolt's: the phone
81+ needs the same rewrite off the same `getProfile` body, and the rule for a
82+ string transformation both halves need is that it lives in `common/`."
83+ [url]
84+ (when (seq (str (or url "")))
85+ (-> url
86+ (str/replace "/img/avatar/plain/" "/img/avatar_thumbnail/plain/")
87+ (str/replace #"@[a-z]+$" "")
88+ (str "@png"))))
89+
90+(defn avatar-url
91+ "The thumbnail URL on this `app.bsky.actor.getProfile` body, or nil when the
92+ person has no picture."
93+ [body]
94+ (thumbnail-url (atproto/json-str body "avatar")))
95+
76 (defn parse96 (defn parse
77 "The fields the screen paints, out of an `app.bsky.actor.getProfile` body."97 "The fields the screen paints, out of an `app.bsky.actor.getProfile` body."
78 [body]98 [body]
79 {:status :ready99 {:status :ready
80 :did (atproto/json-str body "did")100 :did (atproto/json-str body "did")
101+ :avatar (avatar-url body)
81 :handle (atproto/json-str body "handle")102 :handle (atproto/json-str body "handle")
82 :display-name (some-> (atproto/json-str body "displayName")103 :display-name (some-> (atproto/json-str body "displayName")
83 atproto/json-unescape104 atproto/json-unescape
added flutter/src/frq/avatars/dart.cljd +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+(ns frq.avatars.dart
2+ "Profile pictures for the Flutter half, as URLs rather than as files.
3+
4+ `frq.avatars` on the desktop downloads the PNG to `~/.cache/frq/avatars` and
5+ hands the screens a path, because the tree backend paints from a decoded file
6+ and nothing under jolt fetches an image for it. Flutter already has that
7+ layer: `NetworkImage` fetches, decodes and keeps the result in the image
8+ cache, keyed by URL, for every row that names it. So the only thing missing
9+ here is the URL, and the whole disk half of the desktop's version would be a
10+ download written so Flutter could read it back and fetch nothing.
11+
12+ What that costs is the *between runs* half of the desktop's cache: the image
13+ cache is memory, so a relaunch asks the CDN again. One 128-pixel PNG per
14+ person in a conversation, against adding a binary download to `frq.io` a
15+ seam that so far has needed only strings.
16+
17+ One lookup per person however many lines they write, and none at all for a
18+ guest: `frq.profile/actor` answers nil for a nick that is not handle-shaped,
19+ and nil never reaches here."
20+ (:require ["dart:async" :as async]
21+ [frq.atproto.dart :as atproto]
22+ [frq.profile :as profile]))
23+
24+;; actor -> {:status :loading | :ready | :failed, :url when there is one}.
25+;;
26+;; Deliberately not `frq.profile`'s cache, though both are filled from the same
27+;; `getProfile` body: that one is filled when someone *opens* a profile, and
28+;; this one when a message arrives, which is every sender in the room. Sharing
29+;; it would make opening a profile a no-op that shows the stale bio a message
30+;; fetched an hour ago.
31+(defonce ^:private cache (atom {}))
32+
33+(defn url
34+ "This person's picture, or nil while it is on its way and after it failed.
35+ Nil is also the honest answer for someone with no picture at all the
36+ screens draw the initial for all three."
37+ [actor]
38+ (:url (get @cache (str (or actor "")))))
39+
40+(defn fetch!
41+ "Ensure this person's avatar URL is known, in the background. Returns without
42+ waiting; `url` answers for it afterwards, and `on-change` says when that
43+ answer has changed."
44+ [actor on-change]
45+ (let [a (str (or actor ""))]
46+ (when (and (seq a) (not (contains? @cache a)))
47+ (swap! cache assoc a {:status :loading})
48+ (.then ^async/Future (atproto/fetch (profile/profile-req a))
49+ (fn [body]
50+ (swap! cache assoc a {:status :ready :url (profile/avatar-url body)})
51+ (on-change)
52+ nil)
53+ ;; A directory that does not answer is a face that stays an
54+ ;; initial, not a retry loop: the entry stays in the map, so this
55+ ;; person is asked about once per run either way.
56+ .onError (fn [_ _]
57+ (swap! cache assoc a {:status :failed})
58+ (on-change)
59+ nil)))))
new file mode 100644
@@ -0,0 +1,59 @@
1+(ns frq.avatars.dart
2+ "Profile pictures for the Flutter half, as URLs rather than as files.
3+
4+ `frq.avatars` on the desktop downloads the PNG to `~/.cache/frq/avatars` and
5+ hands the screens a path, because the tree backend paints from a decoded file
6+ and nothing under jolt fetches an image for it. Flutter already has that
7+ layer: `NetworkImage` fetches, decodes and keeps the result in the image
8+ cache, keyed by URL, for every row that names it. So the only thing missing
9+ here is the URL, and the whole disk half of the desktop's version would be a
10+ download written so Flutter could read it back and fetch nothing.
11+
12+ What that costs is the *between runs* half of the desktop's cache: the image
13+ cache is memory, so a relaunch asks the CDN again. One 128-pixel PNG per
14+ person in a conversation, against adding a binary download to `frq.io` a
15+ seam that so far has needed only strings.
16+
17+ One lookup per person however many lines they write, and none at all for a
18+ guest: `frq.profile/actor` answers nil for a nick that is not handle-shaped,
19+ and nil never reaches here."
20+ (:require ["dart:async" :as async]
21+ [frq.atproto.dart :as atproto]
22+ [frq.profile :as profile]))
23+
24+;; actor -> {:status :loading | :ready | :failed, :url when there is one}.
25+;;
26+;; Deliberately not `frq.profile`'s cache, though both are filled from the same
27+;; `getProfile` body: that one is filled when someone *opens* a profile, and
28+;; this one when a message arrives, which is every sender in the room. Sharing
29+;; it would make opening a profile a no-op that shows the stale bio a message
30+;; fetched an hour ago.
31+(defonce ^:private cache (atom {}))
32+
33+(defn url
34+ "This person's picture, or nil while it is on its way and after it failed.
35+ Nil is also the honest answer for someone with no picture at all the
36+ screens draw the initial for all three."
37+ [actor]
38+ (:url (get @cache (str (or actor "")))))
39+
40+(defn fetch!
41+ "Ensure this person's avatar URL is known, in the background. Returns without
42+ waiting; `url` answers for it afterwards, and `on-change` says when that
43+ answer has changed."
44+ [actor on-change]
45+ (let [a (str (or actor ""))]
46+ (when (and (seq a) (not (contains? @cache a)))
47+ (swap! cache assoc a {:status :loading})
48+ (.then ^async/Future (atproto/fetch (profile/profile-req a))
49+ (fn [body]
50+ (swap! cache assoc a {:status :ready :url (profile/avatar-url body)})
51+ (on-change)
52+ nil)
53+ ;; A directory that does not answer is a face that stays an
54+ ;; initial, not a retry loop: the entry stays in the map, so this
55+ ;; person is asked about once per run either way.
56+ .onError (fn [_ _]
57+ (swap! cache assoc a {:status :failed})
58+ (on-change)
59+ nil)))))
modified flutter/src/frq/hiccup.cljd +18 -3
@@ -629,12 +629,27 @@
629629 :avatar
630630 (let [s (dbl (:size p) 32.0)
631631 src (:src p)
632+ ;; Either kind of source, as `:image` takes either kind: the
633+ ;; desktop's `avatar-path` is a file it downloaded and this
634+ ;; half's is the CDN URL itself, and the screen hands over
635+ ;; whichever its host answered with.
636+ bg (when (and src (not= "" src))
637+ (if (or (.startsWith (str src) "http://")
638+ (.startsWith (str src) "https://"))
639+ (m/NetworkImage (str src))
640+ (m/FileImage (io/File. (str src)))))
632641 face (m/CircleAvatar
633642 .radius (/ s 2.0)
634643 .backgroundColor t/component
635- .backgroundImage (when (and src (not= "" src))
636- (m/NetworkImage src))
637- .child (when (or (nil? src) (= "" src))
644+ .backgroundImage bg
645+ ;; A picture that will not load is a face that stays its
646+ ;; initial, and nothing else: without this the failure goes
647+ ;; to `FlutterError.onError`, which this app points at
648+ ;; debugPrint one deleted avatar, one line of log per
649+ ;; frame. CircleAvatar asserts if this is given without an
650+ ;; image, hence the `when`.
651+ .onBackgroundImageError (when bg (fn [_ _] nil))
652+ .child (when (nil? bg)
638653 (txt ctx (let [l (str (:label p ""))]
639654 (if (pos? (count l))
640655 (.toUpperCase (subs l 0 1))
@@ -629,12 +629,27 @@
629 :avatar629 :avatar
630 (let [s (dbl (:size p) 32.0)630 (let [s (dbl (:size p) 32.0)
631 src (:src p)631 src (:src p)
632+ ;; Either kind of source, as `:image` takes either kind: the
633+ ;; desktop's `avatar-path` is a file it downloaded and this
634+ ;; half's is the CDN URL itself, and the screen hands over
635+ ;; whichever its host answered with.
636+ bg (when (and src (not= "" src))
637+ (if (or (.startsWith (str src) "http://")
638+ (.startsWith (str src) "https://"))
639+ (m/NetworkImage (str src))
640+ (m/FileImage (io/File. (str src)))))
632 face (m/CircleAvatar641 face (m/CircleAvatar
633 .radius (/ s 2.0)642 .radius (/ s 2.0)
634 .backgroundColor t/component643 .backgroundColor t/component
635- .backgroundImage (when (and src (not= "" src))644+ .backgroundImage bg
636- (m/NetworkImage src))645+ ;; A picture that will not load is a face that stays its
637- .child (when (or (nil? src) (= "" src))646+ ;; initial, and nothing else: without this the failure goes
647+ ;; to `FlutterError.onError`, which this app points at
648+ ;; debugPrint one deleted avatar, one line of log per
649+ ;; frame. CircleAvatar asserts if this is given without an
650+ ;; image, hence the `when`.
651+ .onBackgroundImageError (when bg (fn [_ _] nil))
652+ .child (when (nil? bg)
638 (txt ctx (let [l (str (:label p ""))]653 (txt ctx (let [l (str (:label p ""))]
639 (if (pos? (count l))654 (if (pos? (count l))
640 (.toUpperCase (subs l 0 1))655 (.toUpperCase (subs l 0 1))
modified flutter/src/frq/main.cljd +39 -8
@@ -43,6 +43,7 @@
4343 [frq.reactions :as reactions]
4444 [frq.edits :as edits]
4545 [frq.profile :as profile]
46+ [frq.avatars.dart :as avatars]
4647 [frq.irc.mutate :as mutate]
4748 [frq.oauth.core :as oauth]
4849 [frq.oauth.dart :as oauth-dart]
@@ -54,6 +55,25 @@
5455
5556 (defonce ^:private last-line (atom nil))
5657
58+;; Ticked whenever any cell changes, so one `:watch` covers all of them.
59+;;
60+;; What this replaced was a list of cells named by hand in the widget below,
61+;; and everything not on it was a control that flipped its cell and repainted
62+;; nothing People, Overview and hide join/part all did.
63+;;
64+;; Up here rather than beside `watch-cells!` because it is no longer only
65+;; cells that tick it: an avatar landing is a change to nothing under
66+;; `frq.cells` the URL is in `frq.avatars.dart` and `room!`, which is the
67+;; one that asks for it, is defined below this and above that.
68+(defonce ^:private repaint (atom 0))
69+
70+(defn- bump!
71+ "Repaint whatever is on screen. What a background fetch calls when its answer
72+ has arrived and the rows that read it do not know."
73+ []
74+ (swap! repaint inc)
75+ nil)
76+
5777 (def ^:private history-limit
5878 "How many lines of backlog to ask a room for, as `frq.state` asks for them."
5979 100)
@@ -133,6 +153,12 @@
133153 name (if (rooms/dm? target) who target)
134154 edit-of (or (irc/tag-value tags "+draft/edit")
135155 (irc/tag-value tags "+edit"))]
156+ ;; The face, asked for as the line arrives rather than when the row is
157+ ;; built: a row is built during a Flutter frame, and a fetch started
158+ ;; there would be state changed mid-build. `frq.avatars.dart` asks the
159+ ;; directory once per person however many lines they write, and
160+ ;; `bump!` is what wakes the rows already on screen when it answers.
161+ (avatars/fetch! (profile/actor (:account m) who) bump!)
136162 (if edit-of
137163 ;; A revision is not a new line: it replaces the one it names, under
138164 ;; that line's own id and never the revision's own wire msgid, which
@@ -220,13 +246,6 @@
220246
221247 (defonce ^:private attempt (atom 0))
222248
223-;; Ticked whenever any cell changes, so one `:watch` covers all of them.
224-;;
225-;; What this replaced was a list of cells named by hand in the widget below,
226-;; and everything not on it was a control that flipped its cell and repainted
227-;; nothing People, Overview and hide join/part all did.
228-(defonce ^:private repaint (atom 0))
229-
230249 (defn- watch-cells!
231250 "Every cell in `frq.cells` ticking `repaint` when it changes.
232251
@@ -650,7 +669,12 @@
650669
651670 ;; Who someone is, behind the nick on a line. `frq.profile` holds the
652671 ;; cache and the fields; this is the tapping.
653- :profile-open! profile/open!
672+ ;; Opening a profile asks for the picture too: the dialog paints it at 72
673+ ;; points, and someone whose lines are all above the fold in another room
674+ ;; may never have had a face fetched for them.
675+ :profile-open! (fn [nick actor]
676+ (avatars/fetch! actor bump!)
677+ (profile/open! nick actor))
654678 :profile-close! profile/close!
655679 :profile-dismiss! profile/close!
656680 :profile-entry profile/entry
@@ -691,6 +715,13 @@
691715 (->> (get-in @cells/channels [room :messages])
692716 (filter #(= id (:id %)))
693717 first))
718+ ;; Where a face is. A URL and not a path, which is what the desktop
719+ ;; answers see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`,
720+ ;; which is the one place that has to know which it got. Both names
721+ ;; answer the same thing here: the desktop's `avatar-ready` is
722+ ;; "downloaded already", and nothing is downloaded here.
723+ :avatar-path (fn [actor] (avatars/url actor))
724+ :avatar-ready (fn [actor] (avatars/url actor))
694725 :member-count (fn [room] (members/member-count @cells/channels room))
695726 :member-list (fn [room] (members/member-list @cells/channels room))
696727 :toggle-users! (fn [] (swap! cells/show-users? not))
@@ -43,6 +43,7 @@
43 [frq.reactions :as reactions]43 [frq.reactions :as reactions]
44 [frq.edits :as edits]44 [frq.edits :as edits]
45 [frq.profile :as profile]45 [frq.profile :as profile]
46+ [frq.avatars.dart :as avatars]
46 [frq.irc.mutate :as mutate]47 [frq.irc.mutate :as mutate]
47 [frq.oauth.core :as oauth]48 [frq.oauth.core :as oauth]
48 [frq.oauth.dart :as oauth-dart]49 [frq.oauth.dart :as oauth-dart]
@@ -54,6 +55,25 @@
54 55
55 (defonce ^:private last-line (atom nil))56 (defonce ^:private last-line (atom nil))
56 57
58+;; Ticked whenever any cell changes, so one `:watch` covers all of them.
59+;;
60+;; What this replaced was a list of cells named by hand in the widget below,
61+;; and everything not on it was a control that flipped its cell and repainted
62+;; nothing People, Overview and hide join/part all did.
63+;;
64+;; Up here rather than beside `watch-cells!` because it is no longer only
65+;; cells that tick it: an avatar landing is a change to nothing under
66+;; `frq.cells` the URL is in `frq.avatars.dart` and `room!`, which is the
67+;; one that asks for it, is defined below this and above that.
68+(defonce ^:private repaint (atom 0))
69+
70+(defn- bump!
71+ "Repaint whatever is on screen. What a background fetch calls when its answer
72+ has arrived and the rows that read it do not know."
73+ []
74+ (swap! repaint inc)
75+ nil)
76+
57 (def ^:private history-limit77 (def ^:private history-limit
58 "How many lines of backlog to ask a room for, as `frq.state` asks for them."78 "How many lines of backlog to ask a room for, as `frq.state` asks for them."
59 100)79 100)
@@ -133,6 +153,12 @@
133 name (if (rooms/dm? target) who target)153 name (if (rooms/dm? target) who target)
134 edit-of (or (irc/tag-value tags "+draft/edit")154 edit-of (or (irc/tag-value tags "+draft/edit")
135 (irc/tag-value tags "+edit"))]155 (irc/tag-value tags "+edit"))]
156+ ;; The face, asked for as the line arrives rather than when the row is
157+ ;; built: a row is built during a Flutter frame, and a fetch started
158+ ;; there would be state changed mid-build. `frq.avatars.dart` asks the
159+ ;; directory once per person however many lines they write, and
160+ ;; `bump!` is what wakes the rows already on screen when it answers.
161+ (avatars/fetch! (profile/actor (:account m) who) bump!)
136 (if edit-of162 (if edit-of
137 ;; A revision is not a new line: it replaces the one it names, under163 ;; A revision is not a new line: it replaces the one it names, under
138 ;; that line's own id and never the revision's own wire msgid, which164 ;; that line's own id and never the revision's own wire msgid, which
@@ -220,13 +246,6 @@
220 246
221 (defonce ^:private attempt (atom 0))247 (defonce ^:private attempt (atom 0))
222 248
223-;; Ticked whenever any cell changes, so one `:watch` covers all of them.
224-;;
225-;; What this replaced was a list of cells named by hand in the widget below,
226-;; and everything not on it was a control that flipped its cell and repainted
227-;; nothing People, Overview and hide join/part all did.
228-(defonce ^:private repaint (atom 0))
229-
230 (defn- watch-cells!249 (defn- watch-cells!
231 "Every cell in `frq.cells` ticking `repaint` when it changes.250 "Every cell in `frq.cells` ticking `repaint` when it changes.
232 251
@@ -650,7 +669,12 @@
650 669
651 ;; Who someone is, behind the nick on a line. `frq.profile` holds the670 ;; Who someone is, behind the nick on a line. `frq.profile` holds the
652 ;; cache and the fields; this is the tapping.671 ;; cache and the fields; this is the tapping.
653- :profile-open! profile/open!672+ ;; Opening a profile asks for the picture too: the dialog paints it at 72
673+ ;; points, and someone whose lines are all above the fold in another room
674+ ;; may never have had a face fetched for them.
675+ :profile-open! (fn [nick actor]
676+ (avatars/fetch! actor bump!)
677+ (profile/open! nick actor))
654 :profile-close! profile/close!678 :profile-close! profile/close!
655 :profile-dismiss! profile/close!679 :profile-dismiss! profile/close!
656 :profile-entry profile/entry680 :profile-entry profile/entry
@@ -691,6 +715,13 @@
691 (->> (get-in @cells/channels [room :messages])715 (->> (get-in @cells/channels [room :messages])
692 (filter #(= id (:id %)))716 (filter #(= id (:id %)))
693 first))717 first))
718+ ;; Where a face is. A URL and not a path, which is what the desktop
719+ ;; answers see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`,
720+ ;; which is the one place that has to know which it got. Both names
721+ ;; answer the same thing here: the desktop's `avatar-ready` is
722+ ;; "downloaded already", and nothing is downloaded here.
723+ :avatar-path (fn [actor] (avatars/url actor))
724+ :avatar-ready (fn [actor] (avatars/url actor))
694 :member-count (fn [room] (members/member-count @cells/channels room))725 :member-count (fn [room] (members/member-count @cells/channels room))
695 :member-list (fn [room] (members/member-list @cells/channels room))726 :member-list (fn [room] (members/member-list @cells/channels room))
696 :toggle-users! (fn [] (swap! cells/show-users? not))727 :toggle-users! (fn [] (swap! cells/show-users? not))
modified src/frq/avatars.clj +8 -15
@@ -38,23 +38,16 @@
3838 (defn path-when-ready [handle]
3939 (when (= :ready (get @state handle)) (cached-path handle)))
4040
41-(defn- thumbnail-url
42- "The CDN's full-size avatar URL as a 128-pixel PNG. Asking for the size we
43- paint keeps a 170KB portrait from being downloaded to draw at 24 points."
44- [url]
45- (when (seq url)
46- (-> url
47- (str/replace "/img/avatar/plain/" "/img/avatar_thumbnail/plain/")
48- (str/replace #"@[a-z]+$" "")
49- (str "@png"))))
50-
5141 (defn- profile-avatar
52- "The avatar URL on someone's profile, or nil if they have none."
42+ "The avatar URL on someone's profile, or nil if they have none.
43+
44+ The rewrite to a thumbnail is `frq.profile`'s, not this file's: the Flutter
45+ half reads the same field off the same body and has no jolt under it."
5346 [handle]
54- (let [body (atproto/request directory-host
55- (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
56- nil)]
57- (thumbnail-url (atproto/json-str body "avatar"))))
47+ (profile/avatar-url
48+ (atproto/request directory-host
49+ (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
50+ nil)))
5851
5952 (defn fetch!
6053 "Ensure this person's avatar is on disk, in the background. Returns without
@@ -38,23 +38,16 @@
38 (defn path-when-ready [handle]38 (defn path-when-ready [handle]
39 (when (= :ready (get @state handle)) (cached-path handle)))39 (when (= :ready (get @state handle)) (cached-path handle)))
40 40
41-(defn- thumbnail-url
42- "The CDN's full-size avatar URL as a 128-pixel PNG. Asking for the size we
43- paint keeps a 170KB portrait from being downloaded to draw at 24 points."
44- [url]
45- (when (seq url)
46- (-> url
47- (str/replace "/img/avatar/plain/" "/img/avatar_thumbnail/plain/")
48- (str/replace #"@[a-z]+$" "")
49- (str "@png"))))
50-
51 (defn- profile-avatar41 (defn- profile-avatar
52- "The avatar URL on someone's profile, or nil if they have none."42+ "The avatar URL on someone's profile, or nil if they have none.
43+
44+ The rewrite to a thumbnail is `frq.profile`'s, not this file's: the Flutter
45+ half reads the same field off the same body and has no jolt under it."
53 [handle]46 [handle]
54- (let [body (atproto/request directory-host47+ (profile/avatar-url
55- (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)48+ (atproto/request directory-host
56- nil)]49+ (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
57- (thumbnail-url (atproto/json-str body "avatar"))))50+ nil)))
58 51
59 (defn fetch!52 (defn fetch!
60 "Ensure this person's avatar is on disk, in the background. Returns without53 "Ensure this person's avatar is on disk, in the background. Returns without