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>
9126058 parent: 5f32523 modified
common/frq/profile.cljc +21 -0 | @@ -73,11 +73,32 @@ | ||
| 73 | 73 | {:host directory-host |
| 74 | 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 | 96 | (defn parse |
| 77 | 97 | "The fields the screen paints, out of an `app.bsky.actor.getProfile` body." |
| 78 | 98 | [body] |
| 79 | 99 | {:status :ready |
| 80 | 100 | :did (atproto/json-str body "did") |
| 101 | + :avatar (avatar-url body) | |
| 81 | 102 | :handle (atproto/json-str body "handle") |
| 82 | 103 | :display-name (some-> (atproto/json-str body "displayName") |
| 83 | 104 | atproto/json-unescape |
| @@ -73,11 +73,32 @@ | |||
| 73 | {:host directory-host | 73 | {: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 parse | 96 | (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 :ready | 99 | {: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-unescape | 104 | 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 @@ | ||
| 629 | 629 | :avatar |
| 630 | 630 | (let [s (dbl (:size p) 32.0) |
| 631 | 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 | 641 | face (m/CircleAvatar |
| 633 | 642 | .radius (/ s 2.0) |
| 634 | 643 | .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) | |
| 638 | 653 | (txt ctx (let [l (str (:label p ""))] |
| 639 | 654 | (if (pos? (count l)) |
| 640 | 655 | (.toUpperCase (subs l 0 1)) |
| @@ -629,12 +629,27 @@ | |||
| 629 | :avatar | 629 | :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/CircleAvatar | 641 | face (m/CircleAvatar |
| 633 | .radius (/ s 2.0) | 642 | .radius (/ s 2.0) |
| 634 | .backgroundColor t/component | 643 | .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 @@ | ||
| 43 | 43 | [frq.reactions :as reactions] |
| 44 | 44 | [frq.edits :as edits] |
| 45 | 45 | [frq.profile :as profile] |
| 46 | + [frq.avatars.dart :as avatars] | |
| 46 | 47 | [frq.irc.mutate :as mutate] |
| 47 | 48 | [frq.oauth.core :as oauth] |
| 48 | 49 | [frq.oauth.dart :as oauth-dart] |
| @@ -54,6 +55,25 @@ | ||
| 54 | 55 | |
| 55 | 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 | 77 | (def ^:private history-limit |
| 58 | 78 | "How many lines of backlog to ask a room for, as `frq.state` asks for them." |
| 59 | 79 | 100) |
| @@ -133,6 +153,12 @@ | ||
| 133 | 153 | name (if (rooms/dm? target) who target) |
| 134 | 154 | edit-of (or (irc/tag-value tags "+draft/edit") |
| 135 | 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 | 162 | (if edit-of |
| 137 | 163 | ;; A revision is not a new line: it replaces the one it names, under |
| 138 | 164 | ;; that line's own id and never the revision's own wire msgid, which |
| @@ -220,13 +246,6 @@ | ||
| 220 | 246 | |
| 221 | 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 | 249 | (defn- watch-cells! |
| 231 | 250 | "Every cell in `frq.cells` ticking `repaint` when it changes. |
| 232 | 251 | |
| @@ -650,7 +669,12 @@ | ||
| 650 | 669 | |
| 651 | 670 | ;; Who someone is, behind the nick on a line. `frq.profile` holds the |
| 652 | 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 | 678 | :profile-close! profile/close! |
| 655 | 679 | :profile-dismiss! profile/close! |
| 656 | 680 | :profile-entry profile/entry |
| @@ -691,6 +715,13 @@ | ||
| 691 | 715 | (->> (get-in @cells/channels [room :messages]) |
| 692 | 716 | (filter #(= id (:id %))) |
| 693 | 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 | 725 | :member-count (fn [room] (members/member-count @cells/channels room)) |
| 695 | 726 | :member-list (fn [room] (members/member-list @cells/channels room)) |
| 696 | 727 | :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-limit | 77 | (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-of | 162 | (if edit-of |
| 137 | ;; A revision is not a new line: it replaces the one it names, under | 163 | ;; 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, which | 164 | ;; 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 the | 670 | ;; 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/entry | 680 | :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 @@ | ||
| 38 | 38 | (defn path-when-ready [handle] |
| 39 | 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 | 41 | (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 | 46 | [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))) | |
| 58 | 51 | |
| 59 | 52 | (defn fetch! |
| 60 | 53 | "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-avatar | 41 | (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-host | 47 | + (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 without | 53 | "Ensure this person's avatar is on disk, in the background. Returns without |