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>
b79ebc4 parent: 13e70a4 modified
README.md +1 -0 | @@ -16,6 +16,7 @@ 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 | 18 | 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 | 20 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 20 | 21 | src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart |
| 21 | 22 | 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, /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/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 disk | 20 | 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 multipart | 21 | 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 plane | 22 | 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 @@ | ||
| 13 | 13 | [frq.clock :as clock] |
| 14 | 14 | [frq.media :as media] |
| 15 | 15 | [frq.platform :as platform] |
| 16 | + [frq.profile :as profile] | |
| 16 | 17 | [frq.state :as s])) |
| 17 | 18 | |
| 18 | 19 | ;; ---------------------------------------------------------------- pieces |
| @@ -407,10 +408,14 @@ | ||
| 407 | 408 | ;; fetch lands, and for the guests who have no profile at all, which |
| 408 | 409 | ;; is what keeps the column of faces straight down the left. |
| 409 | 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 | 414 | (let [_ @s/media-tick] |
| 411 | 415 | [:avatar {:label (:from m) |
| 412 | 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 | 419 | ;; The name carries the row, so it is set at body size in the plain |
| 415 | 420 | ;; text colour: dimmed caption made the one thing you scan a column |
| 416 | 421 | ;; for the faintest thing on it. |
| @@ -514,6 +519,74 @@ | ||
| 514 | 519 | (range (count messages)) |
| 515 | 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 | 590 | (defn lightbox-screen |
| 518 | 591 | "One picture, as big as the window will paint it. |
| 519 | 592 | |
| @@ -907,6 +980,7 @@ | ||
| 907 | 980 | (defn app [] |
| 908 | 981 | (cond |
| 909 | 982 | @s/lightbox [lightbox-screen] |
| 983 | + @profile/viewing [profile-screen] | |
| 910 | 984 | @s/image-picker [image-picker-screen] |
| 911 | 985 | ;; Wide enough for both, and on one of the two screens that are halves of |
| 912 | 986 | ;; 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 | ;; ---------------------------------------------------------------- pieces | 19 | ;; ---------------------------------------------------------------- pieces |
| @@ -407,10 +408,14 @@ | |||
| 407 | ;; fetch lands, and for the guests who have no profile at all, which | 408 | ;; 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 plain | 419 | ;; 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 column | 420 | ;; 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-screen | 590 | (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 | (cond | 981 | (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 of | 985 | ;; 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 and | 986 | ;; the same thing: the list and the conversation it opens. Discover and |
modified
src/frq/atproto.jolt +30 -0 | @@ -59,6 +59,36 @@ | ||
| 59 | 59 | (let [m (re-find (re-pattern (str "\"" field "\"\\s*:\\s*\"([^\"]*)\"")) (or json ""))] |
| 60 | 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 | 92 | (defn- json-escape [s] |
| 63 | 93 | (-> (or s "") |
| 64 | 94 | (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)) "…")))) | ||