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

Copy sleek's profiles

Tapping a face in the chat opens who is behind it: the picture at a size
worth looking at, the display name and handle, the DID, the Bluesky bio
and counts, and a way through to their profile on the web.

A screen rather than sleek's modal — the tree backend paints in one
layer, the same reason the lightbox is a screen — and the tap is on the
avatar rather than the nick, because Vidya's plain label does not answer
the pointer and a link would recolour every sender name.

Without sleek's WHOIS block or its Message button: there is no WHOIS
plumbing here and no DMs for the button to open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T18:54:12-07:00 Browse files
b79ebc4 parent: 13e70a4
modified README.md +1 -0
@@ -16,6 +16,7 @@ src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
1616 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
1717 src/frq/store.jolt the saved sign-in, mode 600 in the config directory
1818 src/frq/avatars.jolt profile pictures, by DID or handle
19+src/frq/profile.jolt who someone is: the Bluesky profile behind a nick
1920 src/frq/media.jolt image links: spot them, fetch them once, cache on disk
2021 src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart
2122 src/frq/av.jolt calls: the signaling, and a handle on the media plane
@@ -16,6 +16,7 @@ src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
16 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session16 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
17 src/frq/store.jolt the saved sign-in, mode 600 in the config directory17 src/frq/store.jolt the saved sign-in, mode 600 in the config directory
18 src/frq/avatars.jolt profile pictures, by DID or handle18 src/frq/avatars.jolt profile pictures, by DID or handle
19+src/frq/profile.jolt who someone is: the Bluesky profile behind a nick
19 src/frq/media.jolt image links: spot them, fetch them once, cache on disk20 src/frq/media.jolt image links: spot them, fetch them once, cache on disk
20 src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart21 src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart
21 src/frq/av.jolt calls: the signaling, and a handle on the media plane22 src/frq/av.jolt calls: the signaling, and a handle on the media plane
modified src/frq/app.jolt +75 -1
@@ -13,6 +13,7 @@
1313 [frq.clock :as clock]
1414 [frq.media :as media]
1515 [frq.platform :as platform]
16+ [frq.profile :as profile]
1617 [frq.state :as s]))
1718
1819 ;; ---------------------------------------------------------------- pieces
@@ -407,10 +408,14 @@
407408 ;; fetch lands, and for the guests who have no profile at all, which
408409 ;; is what keeps the column of faces straight down the left.
409410 ;; Reading the tick subscribes this row to a fetch finishing.
411+ ;; The face is also the way to the person behind it: Vidya's plain
412+ ;; label does not answer the pointer, so the tap sleek puts on the
413+ ;; nick lives here, on the one thing in the row that does.
410414 (let [_ @s/media-tick]
411415 [:avatar {:label (:from m)
412416 :src (or (avatars/path-when-ready (:actor m)) "")
413- :size 32}])
417+ :size 32
418+ :on-click #(profile/open! (:from m) (:actor m))}])
414419 ;; The name carries the row, so it is set at body size in the plain
415420 ;; text colour: dimmed caption made the one thing you scan a column
416421 ;; for the faintest thing on it.
@@ -514,6 +519,74 @@
514519 (range (count messages))
515520 messages))
516521
522+(defn profile-screen
523+ "Who someone is: sleek's peer profile modal, as a screen.
524+
525+ A screen rather than a layer for the same reason the lightbox is one the
526+ tree backend paints in a single layer, with no z-order to hang a modal from.
527+ So the back row does what sleek's ✕ and backdrop did.
528+
529+ The picture is the same cached file the chat column draws, at a size worth
530+ looking at; the bio is split into a label a line, so a bio that was written
531+ as several lines is still several lines here."
532+ []
533+ (let [{:keys [nick actor]} @profile/viewing
534+ ;; Reading both ticks subscribes this screen to the two fetches it is
535+ ;; waiting on: the profile itself, and the picture on it.
536+ _ @profile/tick
537+ _ @s/media-tick
538+ pr (profile/entry actor)
539+ ready? (= :ready (:status pr))
540+ display (or (:display-name pr) nick)
541+ url (when ready? (profile/web-url pr))]
542+ [:page {:max-width 520}
543+ [:hbox {:spacing 8}
544+ [:button {:label "← Back" :on-click profile/close!}]]
545+ [:card {}
546+ [:hbox {:spacing 12}
547+ [:avatar {:label nick
548+ :src (or (avatars/path-when-ready actor) "")
549+ :size 72}]
550+ [:vbox {:spacing 2}
551+ [:title-2 {:label display}]
552+ ;; The nick under the display name only when they differ repeating
553+ ;; it is a line that says nothing.
554+ [:vbox {:key :nick}
555+ (when (not= display nick) [:dim-label {:label nick}])]
556+ [:vbox {:key :handle}
557+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
558+ [:label {:label (str "@" h)}])]]]
559+ ;; The DID is the identity itself, and outlasts both the nick and the
560+ ;; handle so it is on the screen, in full, rather than implied.
561+ [:vbox {:key :did :spacing 2}
562+ (when-let [did (:did pr)] [:dim-label {:label did}])]
563+ [:vbox {:key :bio :spacing 2}
564+ (when-let [bio (and ready? (:description pr))]
565+ (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))]
566+ [:label {:key i :label line}]))]
567+ [:vbox {:key :stats}
568+ (when-let [line (and ready? (profile/stats-line pr))]
569+ [:dim-label {:label line}])]
570+ ;; What is happening, or why nothing is: a guest has no identity to look
571+ ;; up, and saying so is a better answer than a spinner that never lands.
572+ [:vbox {:key :status :spacing 4}
573+ (cond
574+ (nil? actor)
575+ [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
576+
577+ (= :loading (:status pr))
578+ [:hbox {:spacing 8}
579+ [:spinner {}]
580+ [:dim-label {:label "Loading Bluesky profile…"}]]
581+
582+ (= :failed (:status pr))
583+ [:dim-label {:label "No Bluesky profile found"}])]
584+ [:vbox {:key :actions}
585+ (when url
586+ [:button {:label "Bluesky ↗"
587+ :kind :primary
588+ :on-click #(platform/open-url! url)}])]]]))
589+
517590 (defn lightbox-screen
518591 "One picture, as big as the window will paint it.
519592
@@ -907,6 +980,7 @@
907980 (defn app []
908981 (cond
909982 @s/lightbox [lightbox-screen]
983+ @profile/viewing [profile-screen]
910984 @s/image-picker [image-picker-screen]
911985 ;; Wide enough for both, and on one of the two screens that are halves of
912986 ;; the same thing: the list and the conversation it opens. Discover and
@@ -13,6 +13,7 @@
13 [frq.clock :as clock]13 [frq.clock :as clock]
14 [frq.media :as media]14 [frq.media :as media]
15 [frq.platform :as platform]15 [frq.platform :as platform]
16+ [frq.profile :as profile]
16 [frq.state :as s]))17 [frq.state :as s]))
17 18
18 ;; ---------------------------------------------------------------- pieces19 ;; ---------------------------------------------------------------- pieces
@@ -407,10 +408,14 @@
407 ;; fetch lands, and for the guests who have no profile at all, which408 ;; fetch lands, and for the guests who have no profile at all, which
408 ;; is what keeps the column of faces straight down the left.409 ;; is what keeps the column of faces straight down the left.
409 ;; Reading the tick subscribes this row to a fetch finishing.410 ;; Reading the tick subscribes this row to a fetch finishing.
411+ ;; The face is also the way to the person behind it: Vidya's plain
412+ ;; label does not answer the pointer, so the tap sleek puts on the
413+ ;; nick lives here, on the one thing in the row that does.
410 (let [_ @s/media-tick]414 (let [_ @s/media-tick]
411 [:avatar {:label (:from m)415 [:avatar {:label (:from m)
412 :src (or (avatars/path-when-ready (:actor m)) "")416 :src (or (avatars/path-when-ready (:actor m)) "")
413- :size 32}])417+ :size 32
418+ :on-click #(profile/open! (:from m) (:actor m))}])
414 ;; The name carries the row, so it is set at body size in the plain419 ;; The name carries the row, so it is set at body size in the plain
415 ;; text colour: dimmed caption made the one thing you scan a column420 ;; text colour: dimmed caption made the one thing you scan a column
416 ;; for the faintest thing on it.421 ;; for the faintest thing on it.
@@ -514,6 +519,74 @@
514 (range (count messages))519 (range (count messages))
515 messages))520 messages))
516 521
522+(defn profile-screen
523+ "Who someone is: sleek's peer profile modal, as a screen.
524+
525+ A screen rather than a layer for the same reason the lightbox is one the
526+ tree backend paints in a single layer, with no z-order to hang a modal from.
527+ So the back row does what sleek's ✕ and backdrop did.
528+
529+ The picture is the same cached file the chat column draws, at a size worth
530+ looking at; the bio is split into a label a line, so a bio that was written
531+ as several lines is still several lines here."
532+ []
533+ (let [{:keys [nick actor]} @profile/viewing
534+ ;; Reading both ticks subscribes this screen to the two fetches it is
535+ ;; waiting on: the profile itself, and the picture on it.
536+ _ @profile/tick
537+ _ @s/media-tick
538+ pr (profile/entry actor)
539+ ready? (= :ready (:status pr))
540+ display (or (:display-name pr) nick)
541+ url (when ready? (profile/web-url pr))]
542+ [:page {:max-width 520}
543+ [:hbox {:spacing 8}
544+ [:button {:label "← Back" :on-click profile/close!}]]
545+ [:card {}
546+ [:hbox {:spacing 12}
547+ [:avatar {:label nick
548+ :src (or (avatars/path-when-ready actor) "")
549+ :size 72}]
550+ [:vbox {:spacing 2}
551+ [:title-2 {:label display}]
552+ ;; The nick under the display name only when they differ repeating
553+ ;; it is a line that says nothing.
554+ [:vbox {:key :nick}
555+ (when (not= display nick) [:dim-label {:label nick}])]
556+ [:vbox {:key :handle}
557+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
558+ [:label {:label (str "@" h)}])]]]
559+ ;; The DID is the identity itself, and outlasts both the nick and the
560+ ;; handle so it is on the screen, in full, rather than implied.
561+ [:vbox {:key :did :spacing 2}
562+ (when-let [did (:did pr)] [:dim-label {:label did}])]
563+ [:vbox {:key :bio :spacing 2}
564+ (when-let [bio (and ready? (:description pr))]
565+ (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))]
566+ [:label {:key i :label line}]))]
567+ [:vbox {:key :stats}
568+ (when-let [line (and ready? (profile/stats-line pr))]
569+ [:dim-label {:label line}])]
570+ ;; What is happening, or why nothing is: a guest has no identity to look
571+ ;; up, and saying so is a better answer than a spinner that never lands.
572+ [:vbox {:key :status :spacing 4}
573+ (cond
574+ (nil? actor)
575+ [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
576+
577+ (= :loading (:status pr))
578+ [:hbox {:spacing 8}
579+ [:spinner {}]
580+ [:dim-label {:label "Loading Bluesky profile…"}]]
581+
582+ (= :failed (:status pr))
583+ [:dim-label {:label "No Bluesky profile found"}])]
584+ [:vbox {:key :actions}
585+ (when url
586+ [:button {:label "Bluesky ↗"
587+ :kind :primary
588+ :on-click #(platform/open-url! url)}])]]]))
589+
517 (defn lightbox-screen590 (defn lightbox-screen
518 "One picture, as big as the window will paint it.591 "One picture, as big as the window will paint it.
519 592
@@ -907,6 +980,7 @@
907 (defn app []980 (defn app []
908 (cond981 (cond
909 @s/lightbox [lightbox-screen]982 @s/lightbox [lightbox-screen]
983+ @profile/viewing [profile-screen]
910 @s/image-picker [image-picker-screen]984 @s/image-picker [image-picker-screen]
911 ;; Wide enough for both, and on one of the two screens that are halves of985 ;; Wide enough for both, and on one of the two screens that are halves of
912 ;; the same thing: the list and the conversation it opens. Discover and986 ;; the same thing: the list and the conversation it opens. Discover and
modified src/frq/atproto.jolt +30 -0
@@ -59,6 +59,36 @@
5959 (let [m (re-find (re-pattern (str "\"" field "\"\\s*:\\s*\"([^\"]*)\"")) (or json ""))]
6060 (second m)))
6161
62+(defn json-num
63+ "The numeric value of a top-level JSON field, or nil.
64+
65+ Written out as a string rather than parsed into a number: the counts on a
66+ profile are only ever printed, and \"1204\" is what printing them wants."
67+ [json field]
68+ (second (re-find (re-pattern (str "\"" field "\"\\s*:\\s*(-?[0-9]+)")) (or json ""))))
69+
70+(defn json-unescape
71+ "A JSON string body back to the text it stands for.
72+
73+ `json-str` hands back the escapes as they were written, which is right for a
74+ DID or a URL — none of them contains one — and wrong for a bio, where the
75+ line breaks someone typed arrive as backslash-n. Only the escapes a bio can
76+ carry are undone; a stray backslash is left alone rather than eaten."
77+ [s]
78+ (str/replace (or s "") #"\\(u[0-9a-fA-F]{4}|.)"
79+ (fn [[whole esc]]
80+ (case (first esc)
81+ \n "\n"
82+ \t "\t"
83+ \r "\r"
84+ \b "\b"
85+ \f "\f"
86+ \" "\""
87+ \\ "\\"
88+ \/ "/"
89+ \u (str (char (Integer/parseInt (subs esc 1) 16)))
90+ whole))))
91+
6292 (defn- json-escape [s]
6393 (-> (or s "")
6494 (str/replace "\\" "\\\\")
@@ -59,6 +59,36 @@
59 (let [m (re-find (re-pattern (str "\"" field "\"\\s*:\\s*\"([^\"]*)\"")) (or json ""))]59 (let [m (re-find (re-pattern (str "\"" field "\"\\s*:\\s*\"([^\"]*)\"")) (or json ""))]
60 (second m)))60 (second m)))
61 61
62+(defn json-num
63+ "The numeric value of a top-level JSON field, or nil.
64+
65+ Written out as a string rather than parsed into a number: the counts on a
66+ profile are only ever printed, and \"1204\" is what printing them wants."
67+ [json field]
68+ (second (re-find (re-pattern (str "\"" field "\"\\s*:\\s*(-?[0-9]+)")) (or json ""))))
69+
70+(defn json-unescape
71+ "A JSON string body back to the text it stands for.
72+
73+ `json-str` hands back the escapes as they were written, which is right for a
74+ DID or a URL — none of them contains one — and wrong for a bio, where the
75+ line breaks someone typed arrive as backslash-n. Only the escapes a bio can
76+ carry are undone; a stray backslash is left alone rather than eaten."
77+ [s]
78+ (str/replace (or s "") #"\\(u[0-9a-fA-F]{4}|.)"
79+ (fn [[whole esc]]
80+ (case (first esc)
81+ \n "\n"
82+ \t "\t"
83+ \r "\r"
84+ \b "\b"
85+ \f "\f"
86+ \" "\""
87+ \\ "\\"
88+ \/ "/"
89+ \u (str (char (Integer/parseInt (subs esc 1) 16)))
90+ whole))))
91+
62 (defn- json-escape [s]92 (defn- json-escape [s]
63 (-> (or s "")93 (-> (or s "")
64 (str/replace "\\" "\\\\")94 (str/replace "\\" "\\\\")
added src/frq/profile.jolt +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+(ns frq.profile
2+ "Who someone is, behind the nick on a line.
3+
4+ sleek's peer profile modal, as a screen. Tapping a name or a face in the
5+ chat opens it: the picture at a size worth looking at, the display name and
6+ handle, the DID, whatever bio and counts Bluesky holds, and a way through to
7+ their profile on the web.
8+
9+ One fetch per person, kept for the run a profile is looked at repeatedly
10+ and changes on nobody's timescale. Guests have no identity to fetch, so for
11+ them the screen is the nick and a line saying so, which is the honest answer
12+ rather than a spinner that never lands."
13+ (:require [clojure.string :as str]
14+ [glimmer.ratom :as r :refer [atom]]
15+ [frq.atproto :as atproto]
16+ [frq.avatars :as avatars]))
17+
18+(def ^:private directory-host "public.api.bsky.app")
19+
20+;; Who is being looked at, or nil `{:nick :actor}`, where `actor` is the DID
21+;; or handle `frq.avatars/actor` worked out, and nil for a guest.
22+(defonce viewing (atom nil))
23+
24+;; actor -> {:status :loading | :ready | :failed, and the fields when ready}
25+(defonce ^:private cache (atom {}))
26+
27+;; Bumped when a fetch lands, so the screen re-renders without watching the
28+;; cache map itself the same trick the chat view uses for pictures.
29+(defonce tick (atom 0))
30+
31+(defn- parse
32+ "The fields the screen paints, out of an `app.bsky.actor.getProfile` body."
33+ [body]
34+ {:status :ready
35+ :did (atproto/json-str body "did")
36+ :handle (atproto/json-str body "handle")
37+ :display-name (some-> (atproto/json-str body "displayName")
38+ atproto/json-unescape
39+ str/trim
40+ not-empty)
41+ :description (some-> (atproto/json-str body "description")
42+ atproto/json-unescape
43+ str/trim
44+ not-empty)
45+ :followers (atproto/json-num body "followersCount")
46+ :follows (atproto/json-num body "followsCount")
47+ :posts (atproto/json-num body "postsCount")})
48+
49+(defn entry
50+ "What is known about this person right now, or nil before anything is."
51+ [actor]
52+ (get @cache actor))
53+
54+(defn fetch!
55+ "Ensure this person's profile is on its way, in the background. Returns
56+ without waiting; `entry` answers for it afterwards and `tick` says when that
57+ answer has changed."
58+ [actor]
59+ (when (and (seq actor) (not (contains? @cache actor)))
60+ (swap! cache assoc actor {:status :loading})
61+ (future
62+ (let [got (try
63+ (let [body (atproto/request
64+ directory-host
65+ (str "/xrpc/app.bsky.actor.getProfile?actor=" actor)
66+ nil)]
67+ (when (atproto/json-str body "did")
68+ (parse body)))
69+ (catch Exception _ nil))]
70+ (swap! cache assoc actor (or got {:status :failed}))
71+ (swap! tick inc)))))
72+
73+(defn open!
74+ "Look at someone. `actor` is their DID or handle, or nil for a guest."
75+ [nick actor]
76+ (reset! viewing {:nick nick :actor actor})
77+ ;; The picture too: the chat asks for one when a line arrives, but this
78+ ;; screen can be opened on someone whose avatar never landed, and it paints
79+ ;; a bigger one than the column it was fetched for.
80+ (when (seq actor) (avatars/fetch! actor #(swap! tick inc)))
81+ (fetch! actor))
82+
83+(defn close! [] (reset! viewing nil))
84+
85+(defn web-url
86+ "Their profile on the web, by handle where there is one and DID otherwise."
87+ [{:keys [handle did]}]
88+ (let [who (or (not-empty (str/trim (or handle ""))) did)]
89+ (when (seq who)
90+ (str "https://bsky.app/profile/" (str/replace who #"^@" "")))))
91+
92+(defn stats-line
93+ "\"12 followers · 34 following · 56 posts\", or nil when none are known."
94+ [{:keys [followers follows posts]}]
95+ (let [parts (cond-> []
96+ followers (conj (str followers " followers"))
97+ follows (conj (str follows " following"))
98+ posts (conj (str posts " posts")))]
99+ (when (seq parts) (str/join " · " parts))))
100+
101+(defn truncate
102+ "A bio cut to `max` characters, keeping its line breaks — the height of a
103+ multi-line bio is part of what it says."
104+ [s max]
105+ (let [t (str/trim (or s ""))]
106+ (if (<= (count t) max)
107+ t
108+ (str (subs t 0 (dec max)) ""))))
new file mode 100644
@@ -0,0 +1,108 @@
1+(ns frq.profile
2+ "Who someone is, behind the nick on a line.
3+
4+ sleek's peer profile modal, as a screen. Tapping a name or a face in the
5+ chat opens it: the picture at a size worth looking at, the display name and
6+ handle, the DID, whatever bio and counts Bluesky holds, and a way through to
7+ their profile on the web.
8+
9+ One fetch per person, kept for the run a profile is looked at repeatedly
10+ and changes on nobody's timescale. Guests have no identity to fetch, so for
11+ them the screen is the nick and a line saying so, which is the honest answer
12+ rather than a spinner that never lands."
13+ (:require [clojure.string :as str]
14+ [glimmer.ratom :as r :refer [atom]]
15+ [frq.atproto :as atproto]
16+ [frq.avatars :as avatars]))
17+
18+(def ^:private directory-host "public.api.bsky.app")
19+
20+;; Who is being looked at, or nil `{:nick :actor}`, where `actor` is the DID
21+;; or handle `frq.avatars/actor` worked out, and nil for a guest.
22+(defonce viewing (atom nil))
23+
24+;; actor -> {:status :loading | :ready | :failed, and the fields when ready}
25+(defonce ^:private cache (atom {}))
26+
27+;; Bumped when a fetch lands, so the screen re-renders without watching the
28+;; cache map itself the same trick the chat view uses for pictures.
29+(defonce tick (atom 0))
30+
31+(defn- parse
32+ "The fields the screen paints, out of an `app.bsky.actor.getProfile` body."
33+ [body]
34+ {:status :ready
35+ :did (atproto/json-str body "did")
36+ :handle (atproto/json-str body "handle")
37+ :display-name (some-> (atproto/json-str body "displayName")
38+ atproto/json-unescape
39+ str/trim
40+ not-empty)
41+ :description (some-> (atproto/json-str body "description")
42+ atproto/json-unescape
43+ str/trim
44+ not-empty)
45+ :followers (atproto/json-num body "followersCount")
46+ :follows (atproto/json-num body "followsCount")
47+ :posts (atproto/json-num body "postsCount")})
48+
49+(defn entry
50+ "What is known about this person right now, or nil before anything is."
51+ [actor]
52+ (get @cache actor))
53+
54+(defn fetch!
55+ "Ensure this person's profile is on its way, in the background. Returns
56+ without waiting; `entry` answers for it afterwards and `tick` says when that
57+ answer has changed."
58+ [actor]
59+ (when (and (seq actor) (not (contains? @cache actor)))
60+ (swap! cache assoc actor {:status :loading})
61+ (future
62+ (let [got (try
63+ (let [body (atproto/request
64+ directory-host
65+ (str "/xrpc/app.bsky.actor.getProfile?actor=" actor)
66+ nil)]
67+ (when (atproto/json-str body "did")
68+ (parse body)))
69+ (catch Exception _ nil))]
70+ (swap! cache assoc actor (or got {:status :failed}))
71+ (swap! tick inc)))))
72+
73+(defn open!
74+ "Look at someone. `actor` is their DID or handle, or nil for a guest."
75+ [nick actor]
76+ (reset! viewing {:nick nick :actor actor})
77+ ;; The picture too: the chat asks for one when a line arrives, but this
78+ ;; screen can be opened on someone whose avatar never landed, and it paints
79+ ;; a bigger one than the column it was fetched for.
80+ (when (seq actor) (avatars/fetch! actor #(swap! tick inc)))
81+ (fetch! actor))
82+
83+(defn close! [] (reset! viewing nil))
84+
85+(defn web-url
86+ "Their profile on the web, by handle where there is one and DID otherwise."
87+ [{:keys [handle did]}]
88+ (let [who (or (not-empty (str/trim (or handle ""))) did)]
89+ (when (seq who)
90+ (str "https://bsky.app/profile/" (str/replace who #"^@" "")))))
91+
92+(defn stats-line
93+ "\"12 followers · 34 following · 56 posts\", or nil when none are known."
94+ [{:keys [followers follows posts]}]
95+ (let [parts (cond-> []
96+ followers (conj (str followers " followers"))
97+ follows (conj (str follows " following"))
98+ posts (conj (str posts " posts")))]
99+ (when (seq parts) (str/join " · " parts))))
100+
101+(defn truncate
102+ "A bio cut to `max` characters, keeping its line breaks — the height of a
103+ multi-line bio is part of what it says."
104+ [s max]
105+ (let [t (str/trim (or s ""))]
106+ (if (<= (count t) max)
107+ t
108+ (str (subs t 0 (dec max)) ""))))