Merge remote-tracking branch 'origin/main' into claude/userlist-implementation-cd28b2
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 +162 -10 | @@ -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 |
| @@ -162,7 +163,10 @@ | ||
| 162 | 163 | (defn chats-screen [] |
| 163 | 164 | (let [buffers (s/channel-list)] |
| 164 | 165 | [:page {:max-width 620} |
| 165 | - [:title {:label "Chats"}] | |
| 166 | + ;; Your nick in the title, where "Chats" alone said nothing you did not | |
| 167 | + ;; already know. It is what every channel calls you and what your own | |
| 168 | + ;; lines are signed with, and the only other place it showed was Settings. | |
| 169 | + [:title {:label (if (s/connected?) (str "Chats as " @s/form-nick) "Chats")}] | |
| 166 | 170 | [error-note] |
| 167 | 171 | ;; Join and search are the same shape — a channel box with a button |
| 168 | 172 | ;; beside it — so they read as one control block rather than a titled |
| @@ -375,6 +379,48 @@ | ||
| 375 | 379 | (when (pos? over) |
| 376 | 380 | [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]])) |
| 377 | 381 | |
| 382 | +(defn hover-card | |
| 383 | + "Who someone is, in the few lines that fit beside a pointer. | |
| 384 | + | |
| 385 | + The profile screen's opening, without the ways onward: a picture, the name | |
| 386 | + they go by, their handle, and the first of their bio. What it cannot say yet | |
| 387 | + it says plainly — a fetch in flight, a guest with no identity to look up — | |
| 388 | + because a card that is blank while it waits reads as a card with nothing on | |
| 389 | + it." | |
| 390 | + [nick actor] | |
| 391 | + (let [_ @profile/tick | |
| 392 | + _ @s/media-tick | |
| 393 | + pr (profile/entry actor) | |
| 394 | + ready? (= :ready (:status pr)) | |
| 395 | + display (or (:display-name pr) nick)] | |
| 396 | + [:vbox {:spacing 6} | |
| 397 | + [:hbox {:spacing 10} | |
| 398 | + [:avatar {:label nick | |
| 399 | + :src (or (avatars/path-when-ready actor) "") | |
| 400 | + :size 48}] | |
| 401 | + [:vbox {:spacing 2} | |
| 402 | + [:title-2 {:label display}] | |
| 403 | + [:vbox {:key :nick} | |
| 404 | + (when (not= display nick) [:dim-label {:label nick}])] | |
| 405 | + [:vbox {:key :handle} | |
| 406 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 407 | + [:label {:label (str "@" h)}])]]] | |
| 408 | + ;; Shorter than the screen's: this is a glance, not a read, and a bio | |
| 409 | + ;; that fills the window beside a pointer is in the way of the chat. | |
| 410 | + [:vbox {:key :bio :spacing 2} | |
| 411 | + (when-let [bio (and ready? (:description pr))] | |
| 412 | + (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 200)))] | |
| 413 | + [:label {:key i :label line}]))] | |
| 414 | + [:vbox {:key :stats} | |
| 415 | + (when-let [line (and ready? (profile/stats-line pr))] | |
| 416 | + [:dim-label {:label line}])] | |
| 417 | + [:vbox {:key :status} | |
| 418 | + (cond | |
| 419 | + (nil? actor) [:dim-label {:label "Guest — no Bluesky identity"}] | |
| 420 | + (= :loading (:status pr)) [:dim-label {:label "Loading…"}] | |
| 421 | + (= :failed (:status pr)) [:dim-label {:label "No Bluesky profile found"}])] | |
| 422 | + [:dim-label {:label "Click for the full profile"}]])) | |
| 423 | + | |
| 378 | 424 | (defn message-row |
| 379 | 425 | "One message. `prev` is the message above it, which decides whether this one |
| 380 | 426 | repeats the sender. |
| @@ -419,10 +465,25 @@ | ||
| 419 | 465 | ;; fetch lands, and for the guests who have no profile at all, which |
| 420 | 466 | ;; is what keeps the column of faces straight down the left. |
| 421 | 467 | ;; Reading the tick subscribes this row to a fetch finishing. |
| 468 | + ;; The face is also the way to the person behind it: Vidya's plain | |
| 469 | + ;; label does not answer the pointer, so the tap sleek puts on the | |
| 470 | + ;; nick lives here, on the one thing in the row that does. | |
| 471 | + ;; And, where there is a pointer to ask with, resting on the face | |
| 472 | + ;; answers before the click does: the card under it is the profile | |
| 473 | + ;; screen's first few lines, painted beside the pointer. | |
| 422 | 474 | (let [_ @s/media-tick] |
| 423 | - [:avatar {:label (:from m) | |
| 424 | - :src (or (avatars/path-when-ready (:actor m)) "") | |
| 425 | - :size 32}]) | |
| 475 | + [:avatar (cond-> {:label (:from m) | |
| 476 | + :src (or (avatars/path-when-ready (:actor m)) "") | |
| 477 | + :size 32 | |
| 478 | + :on-click #(profile/open! (:from m) (:actor m))} | |
| 479 | + (platform/desktop?) | |
| 480 | + (assoc :on-hover #(profile/hover! (:from m) (:actor m)) | |
| 481 | + :on-unhover #(profile/unhover! (:from m)))) | |
| 482 | + ;; Only the hovered face carries one: a card is painted when its | |
| 483 | + ;; node has children, and the pointer is on one face at a time. | |
| 484 | + (when (and (platform/desktop?) | |
| 485 | + (= (:from m) (:nick @profile/hovering))) | |
| 486 | + [hover-card (:from m) (:actor m)])]) | |
| 426 | 487 | ;; The name carries the row, so it is set at body size in the plain |
| 427 | 488 | ;; text colour: dimmed caption made the one thing you scan a column |
| 428 | 489 | ;; for the faintest thing on it. |
| @@ -526,6 +587,74 @@ | ||
| 526 | 587 | (range (count messages)) |
| 527 | 588 | messages)) |
| 528 | 589 | |
| 590 | +(defn profile-screen | |
| 591 | + "Who someone is: sleek's peer profile modal, as a screen. | |
| 592 | + | |
| 593 | + A screen rather than a layer for the same reason the lightbox is one — the | |
| 594 | + tree backend paints in a single layer, with no z-order to hang a modal from. | |
| 595 | + So the back row does what sleek's ✕ and backdrop did. | |
| 596 | + | |
| 597 | + The picture is the same cached file the chat column draws, at a size worth | |
| 598 | + looking at; the bio is split into a label a line, so a bio that was written | |
| 599 | + as several lines is still several lines here." | |
| 600 | + [] | |
| 601 | + (let [{:keys [nick actor]} @profile/viewing | |
| 602 | + ;; Reading both ticks subscribes this screen to the two fetches it is | |
| 603 | + ;; waiting on: the profile itself, and the picture on it. | |
| 604 | + _ @profile/tick | |
| 605 | + _ @s/media-tick | |
| 606 | + pr (profile/entry actor) | |
| 607 | + ready? (= :ready (:status pr)) | |
| 608 | + display (or (:display-name pr) nick) | |
| 609 | + url (when ready? (profile/web-url pr))] | |
| 610 | + [:page {:max-width 520} | |
| 611 | + [:hbox {:spacing 8} | |
| 612 | + [:button {:label "← Back" :on-click profile/close!}]] | |
| 613 | + [:card {} | |
| 614 | + [:hbox {:spacing 12} | |
| 615 | + [:avatar {:label nick | |
| 616 | + :src (or (avatars/path-when-ready actor) "") | |
| 617 | + :size 72}] | |
| 618 | + [:vbox {:spacing 2} | |
| 619 | + [:title-2 {:label display}] | |
| 620 | + ;; The nick under the display name only when they differ — repeating | |
| 621 | + ;; it is a line that says nothing. | |
| 622 | + [:vbox {:key :nick} | |
| 623 | + (when (not= display nick) [:dim-label {:label nick}])] | |
| 624 | + [:vbox {:key :handle} | |
| 625 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 626 | + [:label {:label (str "@" h)}])]]] | |
| 627 | + ;; The DID is the identity itself, and outlasts both the nick and the | |
| 628 | + ;; handle — so it is on the screen, in full, rather than implied. | |
| 629 | + [:vbox {:key :did :spacing 2} | |
| 630 | + (when-let [did (:did pr)] [:dim-label {:label did}])] | |
| 631 | + [:vbox {:key :bio :spacing 2} | |
| 632 | + (when-let [bio (and ready? (:description pr))] | |
| 633 | + (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | |
| 634 | + [:label {:key i :label line}]))] | |
| 635 | + [:vbox {:key :stats} | |
| 636 | + (when-let [line (and ready? (profile/stats-line pr))] | |
| 637 | + [:dim-label {:label line}])] | |
| 638 | + ;; What is happening, or why nothing is: a guest has no identity to look | |
| 639 | + ;; up, and saying so is a better answer than a spinner that never lands. | |
| 640 | + [:vbox {:key :status :spacing 4} | |
| 641 | + (cond | |
| 642 | + (nil? actor) | |
| 643 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | |
| 644 | + | |
| 645 | + (= :loading (:status pr)) | |
| 646 | + [:hbox {:spacing 8} | |
| 647 | + [:spinner {}] | |
| 648 | + [:dim-label {:label "Loading Bluesky profile…"}]] | |
| 649 | + | |
| 650 | + (= :failed (:status pr)) | |
| 651 | + [:dim-label {:label "No Bluesky profile found"}])] | |
| 652 | + [:vbox {:key :actions} | |
| 653 | + (when url | |
| 654 | + [:button {:label "Bluesky ↗" | |
| 655 | + :kind :primary | |
| 656 | + :on-click #(platform/open-url! url)}])]]])) | |
| 657 | + | |
| 529 | 658 | (defn lightbox-screen |
| 530 | 659 | "One picture, as big as the window will paint it. |
| 531 | 660 | |
| @@ -769,9 +898,9 @@ | ||
| 769 | 898 | ;; Not a :page — a page scrolls everything, which would carry the compose |
| 770 | 899 | ;; bar off the bottom with the backlog. The message list is the only thing |
| 771 | 900 | ;; that scrolls, bounded so what follows it keeps its room. |
| 772 | - ;; Less room under the compose row than around it: the space a row at the | |
| 773 | - ;; bottom of a screen wants is above it, not beneath it. | |
| 774 | - [:vbox {:spacing 8 :margin 12 :margin-bottom 4} | |
| 901 | + ;; Same margin all round: the compose row's own air is what centres it in | |
| 902 | + ;; the strip below the separator, and it is measured from this edge. | |
| 903 | + [:vbox {:spacing 8 :margin 12} | |
| 775 | 904 | [:hbox {:spacing 8} |
| 776 | 905 | ;; The way back to the list, on a window with room for one thing at a |
| 777 | 906 | ;; time. Beside the list there is nothing to go back to, so the button |
| @@ -871,9 +1000,13 @@ | ||
| 871 | 1000 | ;; window wider than that a left-aligned row leaves it stranded in the |
| 872 | 1001 | ;; corner of an otherwise empty bar. On a phone the row is as wide as the |
| 873 | 1002 | ;; window and centring costs nothing. |
| 874 | - ;; A little air under the row, so the line being typed does not sit flat | |
| 875 | - ;; on the bottom edge of the window. | |
| 876 | - [:hbox {:spacing 8 :align :center :margin-bottom 8} | |
| 1003 | + ;; Equal air above and below, so the row sits on the middle of the strip | |
| 1004 | + ;; between the separator and the bottom edge rather than flat against it. | |
| 1005 | + ;; Above it is three of the column's gaps: one after the separator and one | |
| 1006 | + ;; for each of the empty wrappers — the reply banner and the attachment, | |
| 1007 | + ;; which cost a gap apiece whether or not they have anything in them. This | |
| 1008 | + ;; margin plus the window's own is what answers them underneath. | |
| 1009 | + [:hbox {:spacing 8 :align :center :margin-bottom 12} | |
| 877 | 1010 | ;; narrow enough that Send keeps its place on a phone-width row |
| 878 | 1011 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field |
| 879 | 1012 | ;; answers a paste of text with the text, and one of a picture reaches |
| @@ -987,6 +1120,7 @@ | ||
| 987 | 1120 | (defn app [] |
| 988 | 1121 | (cond |
| 989 | 1122 | @s/lightbox [lightbox-screen] |
| 1123 | + @profile/viewing [profile-screen] | |
| 990 | 1124 | @s/image-picker [image-picker-screen] |
| 991 | 1125 | ;; Wide enough for both, and on one of the two screens that are halves of |
| 992 | 1126 | ;; the same thing: the list and the conversation it opens. Discover and |
| @@ -1030,4 +1164,22 @@ | ||
| 1030 | 1164 | (vidya/every! 200 #(let [w (vidya/window-width)] |
| 1031 | 1165 | (when (not= w @s/window-width) |
| 1032 | 1166 | (reset! s/window-width w)))) |
| 1167 | + ;; The nick in the window title, so a second window of this client is told | |
| 1168 | + ;; apart from the first by the one thing that differs — and so the answer to | |
| 1169 | + ;; "who am I here?" is on screen without opening Settings. | |
| 1170 | + ;; | |
| 1171 | + ;; Polled, like the width above and for the same reason: the title belongs to | |
| 1172 | + ;; the window rather than to the tree, so no render puts it there, and the | |
| 1173 | + ;; nick is only settled once a connection has been made. Twice a second is | |
| 1174 | + ;; far more often than a nick changes, and a string compare is what a tick | |
| 1175 | + ;; costs when it has not. | |
| 1176 | + ;; Seeded with the title `run` opens the window under, so the first tick of | |
| 1177 | + ;; a launch that has nobody signed in yet sets nothing. | |
| 1178 | + (let [shown (atom "frq")] | |
| 1179 | + (vidya/every! 500 #(let [title (if (and (s/connected?) (seq @s/form-nick)) | |
| 1180 | + (str "frq — " @s/form-nick) | |
| 1181 | + "frq")] | |
| 1182 | + (when (not= title @shown) | |
| 1183 | + (reset! shown title) | |
| 1184 | + (vidya/set-title! title))))) | |
| 1033 | 1185 | (ui/run app :title "frq" :width 520 :height 860)) |
| @@ -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 |
| @@ -162,7 +163,10 @@ | |||
| 162 | (defn chats-screen [] | 163 | (defn chats-screen [] |
| 163 | (let [buffers (s/channel-list)] | 164 | (let [buffers (s/channel-list)] |
| 164 | [:page {:max-width 620} | 165 | [:page {:max-width 620} |
| 165 | - [:title {:label "Chats"}] | 166 | + ;; Your nick in the title, where "Chats" alone said nothing you did not |
| 167 | + ;; already know. It is what every channel calls you and what your own | ||
| 168 | + ;; lines are signed with, and the only other place it showed was Settings. | ||
| 169 | + [:title {:label (if (s/connected?) (str "Chats as " @s/form-nick) "Chats")}] | ||
| 166 | [error-note] | 170 | [error-note] |
| 167 | ;; Join and search are the same shape — a channel box with a button | 171 | ;; Join and search are the same shape — a channel box with a button |
| 168 | ;; beside it — so they read as one control block rather than a titled | 172 | ;; beside it — so they read as one control block rather than a titled |
| @@ -375,6 +379,48 @@ | |||
| 375 | (when (pos? over) | 379 | (when (pos? over) |
| 376 | [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]])) | 380 | [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]])) |
| 377 | 381 | ||
| 382 | +(defn hover-card | ||
| 383 | + "Who someone is, in the few lines that fit beside a pointer. | ||
| 384 | + | ||
| 385 | + The profile screen's opening, without the ways onward: a picture, the name | ||
| 386 | + they go by, their handle, and the first of their bio. What it cannot say yet | ||
| 387 | + it says plainly — a fetch in flight, a guest with no identity to look up — | ||
| 388 | + because a card that is blank while it waits reads as a card with nothing on | ||
| 389 | + it." | ||
| 390 | + [nick actor] | ||
| 391 | + (let [_ @profile/tick | ||
| 392 | + _ @s/media-tick | ||
| 393 | + pr (profile/entry actor) | ||
| 394 | + ready? (= :ready (:status pr)) | ||
| 395 | + display (or (:display-name pr) nick)] | ||
| 396 | + [:vbox {:spacing 6} | ||
| 397 | + [:hbox {:spacing 10} | ||
| 398 | + [:avatar {:label nick | ||
| 399 | + :src (or (avatars/path-when-ready actor) "") | ||
| 400 | + :size 48}] | ||
| 401 | + [:vbox {:spacing 2} | ||
| 402 | + [:title-2 {:label display}] | ||
| 403 | + [:vbox {:key :nick} | ||
| 404 | + (when (not= display nick) [:dim-label {:label nick}])] | ||
| 405 | + [:vbox {:key :handle} | ||
| 406 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 407 | + [:label {:label (str "@" h)}])]]] | ||
| 408 | + ;; Shorter than the screen's: this is a glance, not a read, and a bio | ||
| 409 | + ;; that fills the window beside a pointer is in the way of the chat. | ||
| 410 | + [:vbox {:key :bio :spacing 2} | ||
| 411 | + (when-let [bio (and ready? (:description pr))] | ||
| 412 | + (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 200)))] | ||
| 413 | + [:label {:key i :label line}]))] | ||
| 414 | + [:vbox {:key :stats} | ||
| 415 | + (when-let [line (and ready? (profile/stats-line pr))] | ||
| 416 | + [:dim-label {:label line}])] | ||
| 417 | + [:vbox {:key :status} | ||
| 418 | + (cond | ||
| 419 | + (nil? actor) [:dim-label {:label "Guest — no Bluesky identity"}] | ||
| 420 | + (= :loading (:status pr)) [:dim-label {:label "Loading…"}] | ||
| 421 | + (= :failed (:status pr)) [:dim-label {:label "No Bluesky profile found"}])] | ||
| 422 | + [:dim-label {:label "Click for the full profile"}]])) | ||
| 423 | + | ||
| 378 | (defn message-row | 424 | (defn message-row |
| 379 | "One message. `prev` is the message above it, which decides whether this one | 425 | "One message. `prev` is the message above it, which decides whether this one |
| 380 | repeats the sender. | 426 | repeats the sender. |
| @@ -419,10 +465,25 @@ | |||
| 419 | ;; fetch lands, and for the guests who have no profile at all, which | 465 | ;; fetch lands, and for the guests who have no profile at all, which |
| 420 | ;; is what keeps the column of faces straight down the left. | 466 | ;; is what keeps the column of faces straight down the left. |
| 421 | ;; Reading the tick subscribes this row to a fetch finishing. | 467 | ;; Reading the tick subscribes this row to a fetch finishing. |
| 468 | + ;; The face is also the way to the person behind it: Vidya's plain | ||
| 469 | + ;; label does not answer the pointer, so the tap sleek puts on the | ||
| 470 | + ;; nick lives here, on the one thing in the row that does. | ||
| 471 | + ;; And, where there is a pointer to ask with, resting on the face | ||
| 472 | + ;; answers before the click does: the card under it is the profile | ||
| 473 | + ;; screen's first few lines, painted beside the pointer. | ||
| 422 | (let [_ @s/media-tick] | 474 | (let [_ @s/media-tick] |
| 423 | - [:avatar {:label (:from m) | 475 | + [:avatar (cond-> {:label (:from m) |
| 424 | - :src (or (avatars/path-when-ready (:actor m)) "") | 476 | + :src (or (avatars/path-when-ready (:actor m)) "") |
| 425 | - :size 32}]) | 477 | + :size 32 |
| 478 | + :on-click #(profile/open! (:from m) (:actor m))} | ||
| 479 | + (platform/desktop?) | ||
| 480 | + (assoc :on-hover #(profile/hover! (:from m) (:actor m)) | ||
| 481 | + :on-unhover #(profile/unhover! (:from m)))) | ||
| 482 | + ;; Only the hovered face carries one: a card is painted when its | ||
| 483 | + ;; node has children, and the pointer is on one face at a time. | ||
| 484 | + (when (and (platform/desktop?) | ||
| 485 | + (= (:from m) (:nick @profile/hovering))) | ||
| 486 | + [hover-card (:from m) (:actor m)])]) | ||
| 426 | ;; The name carries the row, so it is set at body size in the plain | 487 | ;; The name carries the row, so it is set at body size in the plain |
| 427 | ;; text colour: dimmed caption made the one thing you scan a column | 488 | ;; text colour: dimmed caption made the one thing you scan a column |
| 428 | ;; for the faintest thing on it. | 489 | ;; for the faintest thing on it. |
| @@ -526,6 +587,74 @@ | |||
| 526 | (range (count messages)) | 587 | (range (count messages)) |
| 527 | messages)) | 588 | messages)) |
| 528 | 589 | ||
| 590 | +(defn profile-screen | ||
| 591 | + "Who someone is: sleek's peer profile modal, as a screen. | ||
| 592 | + | ||
| 593 | + A screen rather than a layer for the same reason the lightbox is one — the | ||
| 594 | + tree backend paints in a single layer, with no z-order to hang a modal from. | ||
| 595 | + So the back row does what sleek's ✕ and backdrop did. | ||
| 596 | + | ||
| 597 | + The picture is the same cached file the chat column draws, at a size worth | ||
| 598 | + looking at; the bio is split into a label a line, so a bio that was written | ||
| 599 | + as several lines is still several lines here." | ||
| 600 | + [] | ||
| 601 | + (let [{:keys [nick actor]} @profile/viewing | ||
| 602 | + ;; Reading both ticks subscribes this screen to the two fetches it is | ||
| 603 | + ;; waiting on: the profile itself, and the picture on it. | ||
| 604 | + _ @profile/tick | ||
| 605 | + _ @s/media-tick | ||
| 606 | + pr (profile/entry actor) | ||
| 607 | + ready? (= :ready (:status pr)) | ||
| 608 | + display (or (:display-name pr) nick) | ||
| 609 | + url (when ready? (profile/web-url pr))] | ||
| 610 | + [:page {:max-width 520} | ||
| 611 | + [:hbox {:spacing 8} | ||
| 612 | + [:button {:label "← Back" :on-click profile/close!}]] | ||
| 613 | + [:card {} | ||
| 614 | + [:hbox {:spacing 12} | ||
| 615 | + [:avatar {:label nick | ||
| 616 | + :src (or (avatars/path-when-ready actor) "") | ||
| 617 | + :size 72}] | ||
| 618 | + [:vbox {:spacing 2} | ||
| 619 | + [:title-2 {:label display}] | ||
| 620 | + ;; The nick under the display name only when they differ — repeating | ||
| 621 | + ;; it is a line that says nothing. | ||
| 622 | + [:vbox {:key :nick} | ||
| 623 | + (when (not= display nick) [:dim-label {:label nick}])] | ||
| 624 | + [:vbox {:key :handle} | ||
| 625 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 626 | + [:label {:label (str "@" h)}])]]] | ||
| 627 | + ;; The DID is the identity itself, and outlasts both the nick and the | ||
| 628 | + ;; handle — so it is on the screen, in full, rather than implied. | ||
| 629 | + [:vbox {:key :did :spacing 2} | ||
| 630 | + (when-let [did (:did pr)] [:dim-label {:label did}])] | ||
| 631 | + [:vbox {:key :bio :spacing 2} | ||
| 632 | + (when-let [bio (and ready? (:description pr))] | ||
| 633 | + (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | ||
| 634 | + [:label {:key i :label line}]))] | ||
| 635 | + [:vbox {:key :stats} | ||
| 636 | + (when-let [line (and ready? (profile/stats-line pr))] | ||
| 637 | + [:dim-label {:label line}])] | ||
| 638 | + ;; What is happening, or why nothing is: a guest has no identity to look | ||
| 639 | + ;; up, and saying so is a better answer than a spinner that never lands. | ||
| 640 | + [:vbox {:key :status :spacing 4} | ||
| 641 | + (cond | ||
| 642 | + (nil? actor) | ||
| 643 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | ||
| 644 | + | ||
| 645 | + (= :loading (:status pr)) | ||
| 646 | + [:hbox {:spacing 8} | ||
| 647 | + [:spinner {}] | ||
| 648 | + [:dim-label {:label "Loading Bluesky profile…"}]] | ||
| 649 | + | ||
| 650 | + (= :failed (:status pr)) | ||
| 651 | + [:dim-label {:label "No Bluesky profile found"}])] | ||
| 652 | + [:vbox {:key :actions} | ||
| 653 | + (when url | ||
| 654 | + [:button {:label "Bluesky ↗" | ||
| 655 | + :kind :primary | ||
| 656 | + :on-click #(platform/open-url! url)}])]]])) | ||
| 657 | + | ||
| 529 | (defn lightbox-screen | 658 | (defn lightbox-screen |
| 530 | "One picture, as big as the window will paint it. | 659 | "One picture, as big as the window will paint it. |
| 531 | 660 | ||
| @@ -769,9 +898,9 @@ | |||
| 769 | ;; Not a :page — a page scrolls everything, which would carry the compose | 898 | ;; Not a :page — a page scrolls everything, which would carry the compose |
| 770 | ;; bar off the bottom with the backlog. The message list is the only thing | 899 | ;; bar off the bottom with the backlog. The message list is the only thing |
| 771 | ;; that scrolls, bounded so what follows it keeps its room. | 900 | ;; that scrolls, bounded so what follows it keeps its room. |
| 772 | - ;; Less room under the compose row than around it: the space a row at the | 901 | + ;; Same margin all round: the compose row's own air is what centres it in |
| 773 | - ;; bottom of a screen wants is above it, not beneath it. | 902 | + ;; the strip below the separator, and it is measured from this edge. |
| 774 | - [:vbox {:spacing 8 :margin 12 :margin-bottom 4} | 903 | + [:vbox {:spacing 8 :margin 12} |
| 775 | [:hbox {:spacing 8} | 904 | [:hbox {:spacing 8} |
| 776 | ;; The way back to the list, on a window with room for one thing at a | 905 | ;; The way back to the list, on a window with room for one thing at a |
| 777 | ;; time. Beside the list there is nothing to go back to, so the button | 906 | ;; time. Beside the list there is nothing to go back to, so the button |
| @@ -871,9 +1000,13 @@ | |||
| 871 | ;; window wider than that a left-aligned row leaves it stranded in the | 1000 | ;; window wider than that a left-aligned row leaves it stranded in the |
| 872 | ;; corner of an otherwise empty bar. On a phone the row is as wide as the | 1001 | ;; corner of an otherwise empty bar. On a phone the row is as wide as the |
| 873 | ;; window and centring costs nothing. | 1002 | ;; window and centring costs nothing. |
| 874 | - ;; A little air under the row, so the line being typed does not sit flat | 1003 | + ;; Equal air above and below, so the row sits on the middle of the strip |
| 875 | - ;; on the bottom edge of the window. | 1004 | + ;; between the separator and the bottom edge rather than flat against it. |
| 876 | - [:hbox {:spacing 8 :align :center :margin-bottom 8} | 1005 | + ;; Above it is three of the column's gaps: one after the separator and one |
| 1006 | + ;; for each of the empty wrappers — the reply banner and the attachment, | ||
| 1007 | + ;; which cost a gap apiece whether or not they have anything in them. This | ||
| 1008 | + ;; margin plus the window's own is what answers them underneath. | ||
| 1009 | + [:hbox {:spacing 8 :align :center :margin-bottom 12} | ||
| 877 | ;; narrow enough that Send keeps its place on a phone-width row | 1010 | ;; narrow enough that Send keeps its place on a phone-width row |
| 878 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field | 1011 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field |
| 879 | ;; answers a paste of text with the text, and one of a picture reaches | 1012 | ;; answers a paste of text with the text, and one of a picture reaches |
| @@ -987,6 +1120,7 @@ | |||
| 987 | (defn app [] | 1120 | (defn app [] |
| 988 | (cond | 1121 | (cond |
| 989 | @s/lightbox [lightbox-screen] | 1122 | @s/lightbox [lightbox-screen] |
| 1123 | + @profile/viewing [profile-screen] | ||
| 990 | @s/image-picker [image-picker-screen] | 1124 | @s/image-picker [image-picker-screen] |
| 991 | ;; Wide enough for both, and on one of the two screens that are halves of | 1125 | ;; Wide enough for both, and on one of the two screens that are halves of |
| 992 | ;; the same thing: the list and the conversation it opens. Discover and | 1126 | ;; the same thing: the list and the conversation it opens. Discover and |
| @@ -1030,4 +1164,22 @@ | |||
| 1030 | (vidya/every! 200 #(let [w (vidya/window-width)] | 1164 | (vidya/every! 200 #(let [w (vidya/window-width)] |
| 1031 | (when (not= w @s/window-width) | 1165 | (when (not= w @s/window-width) |
| 1032 | (reset! s/window-width w)))) | 1166 | (reset! s/window-width w)))) |
| 1167 | + ;; The nick in the window title, so a second window of this client is told | ||
| 1168 | + ;; apart from the first by the one thing that differs — and so the answer to | ||
| 1169 | + ;; "who am I here?" is on screen without opening Settings. | ||
| 1170 | + ;; | ||
| 1171 | + ;; Polled, like the width above and for the same reason: the title belongs to | ||
| 1172 | + ;; the window rather than to the tree, so no render puts it there, and the | ||
| 1173 | + ;; nick is only settled once a connection has been made. Twice a second is | ||
| 1174 | + ;; far more often than a nick changes, and a string compare is what a tick | ||
| 1175 | + ;; costs when it has not. | ||
| 1176 | + ;; Seeded with the title `run` opens the window under, so the first tick of | ||
| 1177 | + ;; a launch that has nobody signed in yet sets nothing. | ||
| 1178 | + (let [shown (atom "frq")] | ||
| 1179 | + (vidya/every! 500 #(let [title (if (and (s/connected?) (seq @s/form-nick)) | ||
| 1180 | + (str "frq — " @s/form-nick) | ||
| 1181 | + "frq")] | ||
| 1182 | + (when (not= title @shown) | ||
| 1183 | + (reset! shown title) | ||
| 1184 | + (vidya/set-title! title))))) | ||
| 1033 | (ui/run app :title "frq" :width 520 :height 860)) | 1185 | (ui/run app :title "frq" :width 520 :height 860)) |
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 "\\" "\\\\") |
modified
src/frq/platform.jolt +10 -0 | @@ -9,6 +9,16 @@ | ||
| 9 | 9 | [] |
| 10 | 10 | (host/file-exists? "/system/bin/am")) |
| 11 | 11 | |
| 12 | +(defonce ^:private desktop (delay (not (android?)))) | |
| 13 | + | |
| 14 | +(defn desktop? | |
| 15 | + "True where there is a pointer to hover with, rather than a finger. Asked | |
| 16 | + once per render of every row that has a face on it, so the answer is worked | |
| 17 | + out once and kept — the question is a file that either exists or does not, | |
| 18 | + and nothing about it changes while the app runs." | |
| 19 | + [] | |
| 20 | + @desktop) | |
| 21 | + | |
| 12 | 22 | (defn open-url! |
| 13 | 23 | "Hand a URL to whatever shows web pages here. The backend knows what that |
| 14 | 24 | means — xdg-open on a desktop, an ACTION_VIEW intent on Android, where no |
| @@ -9,6 +9,16 @@ | |||
| 9 | [] | 9 | [] |
| 10 | (host/file-exists? "/system/bin/am")) | 10 | (host/file-exists? "/system/bin/am")) |
| 11 | 11 | ||
| 12 | +(defonce ^:private desktop (delay (not (android?)))) | ||
| 13 | + | ||
| 14 | +(defn desktop? | ||
| 15 | + "True where there is a pointer to hover with, rather than a finger. Asked | ||
| 16 | + once per render of every row that has a face on it, so the answer is worked | ||
| 17 | + out once and kept — the question is a file that either exists or does not, | ||
| 18 | + and nothing about it changes while the app runs." | ||
| 19 | + [] | ||
| 20 | + @desktop) | ||
| 21 | + | ||
| 12 | (defn open-url! | 22 | (defn open-url! |
| 13 | "Hand a URL to whatever shows web pages here. The backend knows what that | 23 | "Hand a URL to whatever shows web pages here. The backend knows what that |
| 14 | means — xdg-open on a desktop, an ACTION_VIEW intent on Android, where no | 24 | means — xdg-open on a desktop, an ACTION_VIEW intent on Android, where no |
added
src/frq/profile.jolt +129 -0 | new file mode 100644 | ||
| @@ -0,0 +1,129 @@ | ||
| 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 | +;; The face the pointer is resting on, or nil — `{:nick :actor}`, the same | |
| 86 | +;; shape as `viewing`. A card is painted for this one alone rather than hung | |
| 87 | +;; under every avatar in the column: the tree keeps whatever it is given, and | |
| 88 | +;; a channel's worth of unseen profile cards is a tree nobody looks at. | |
| 89 | +(defonce hovering (atom nil)) | |
| 90 | + | |
| 91 | +(defn hover! | |
| 92 | + "The pointer has come to rest on someone's face. Starts the same two fetches | |
| 93 | + opening them would, so the card has something on it by the time it is read." | |
| 94 | + [nick actor] | |
| 95 | + (reset! hovering {:nick nick :actor actor}) | |
| 96 | + (when (seq actor) (avatars/fetch! actor #(swap! tick inc))) | |
| 97 | + (fetch! actor)) | |
| 98 | + | |
| 99 | +(defn unhover! | |
| 100 | + "The pointer has left `nick`'s face. Guarded by who is being left, so the | |
| 101 | + leaving of one face cannot take down the card of the next one — both edges | |
| 102 | + arrive in the same frame when the pointer crosses straight over." | |
| 103 | + [nick] | |
| 104 | + (swap! hovering #(when-not (= nick (:nick %)) %))) | |
| 105 | + | |
| 106 | +(defn web-url | |
| 107 | + "Their profile on the web, by handle where there is one and DID otherwise." | |
| 108 | + [{:keys [handle did]}] | |
| 109 | + (let [who (or (not-empty (str/trim (or handle ""))) did)] | |
| 110 | + (when (seq who) | |
| 111 | + (str "https://bsky.app/profile/" (str/replace who #"^@" ""))))) | |
| 112 | + | |
| 113 | +(defn stats-line | |
| 114 | + "\"12 followers · 34 following · 56 posts\", or nil when none are known." | |
| 115 | + [{:keys [followers follows posts]}] | |
| 116 | + (let [parts (cond-> [] | |
| 117 | + followers (conj (str followers " followers")) | |
| 118 | + follows (conj (str follows " following")) | |
| 119 | + posts (conj (str posts " posts")))] | |
| 120 | + (when (seq parts) (str/join " · " parts)))) | |
| 121 | + | |
| 122 | +(defn truncate | |
| 123 | + "A bio cut to `max` characters, keeping its line breaks — the height of a | |
| 124 | + multi-line bio is part of what it says." | |
| 125 | + [s max] | |
| 126 | + (let [t (str/trim (or s ""))] | |
| 127 | + (if (<= (count t) max) | |
| 128 | + t | |
| 129 | + (str (subs t 0 (dec max)) "…")))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,129 @@ | |||
| 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 | +;; The face the pointer is resting on, or nil — `{:nick :actor}`, the same | ||
| 86 | +;; shape as `viewing`. A card is painted for this one alone rather than hung | ||
| 87 | +;; under every avatar in the column: the tree keeps whatever it is given, and | ||
| 88 | +;; a channel's worth of unseen profile cards is a tree nobody looks at. | ||
| 89 | +(defonce hovering (atom nil)) | ||
| 90 | + | ||
| 91 | +(defn hover! | ||
| 92 | + "The pointer has come to rest on someone's face. Starts the same two fetches | ||
| 93 | + opening them would, so the card has something on it by the time it is read." | ||
| 94 | + [nick actor] | ||
| 95 | + (reset! hovering {:nick nick :actor actor}) | ||
| 96 | + (when (seq actor) (avatars/fetch! actor #(swap! tick inc))) | ||
| 97 | + (fetch! actor)) | ||
| 98 | + | ||
| 99 | +(defn unhover! | ||
| 100 | + "The pointer has left `nick`'s face. Guarded by who is being left, so the | ||
| 101 | + leaving of one face cannot take down the card of the next one — both edges | ||
| 102 | + arrive in the same frame when the pointer crosses straight over." | ||
| 103 | + [nick] | ||
| 104 | + (swap! hovering #(when-not (= nick (:nick %)) %))) | ||
| 105 | + | ||
| 106 | +(defn web-url | ||
| 107 | + "Their profile on the web, by handle where there is one and DID otherwise." | ||
| 108 | + [{:keys [handle did]}] | ||
| 109 | + (let [who (or (not-empty (str/trim (or handle ""))) did)] | ||
| 110 | + (when (seq who) | ||
| 111 | + (str "https://bsky.app/profile/" (str/replace who #"^@" ""))))) | ||
| 112 | + | ||
| 113 | +(defn stats-line | ||
| 114 | + "\"12 followers · 34 following · 56 posts\", or nil when none are known." | ||
| 115 | + [{:keys [followers follows posts]}] | ||
| 116 | + (let [parts (cond-> [] | ||
| 117 | + followers (conj (str followers " followers")) | ||
| 118 | + follows (conj (str follows " following")) | ||
| 119 | + posts (conj (str posts " posts")))] | ||
| 120 | + (when (seq parts) (str/join " · " parts)))) | ||
| 121 | + | ||
| 122 | +(defn truncate | ||
| 123 | + "A bio cut to `max` characters, keeping its line breaks — the height of a | ||
| 124 | + multi-line bio is part of what it says." | ||
| 125 | + [s max] | ||
| 126 | + (let [t (str/trim (or s ""))] | ||
| 127 | + (if (<= (count t) max) | ||
| 128 | + t | ||
| 129 | + (str (subs t 0 (dec max)) "…")))) | ||