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

Merge remote-tracking branch 'origin/main' into claude/userlist-implementation-cd28b2

nandi committed 2026-08-30T19:11:16-07:00 Browse files
9cf11a6 parents: 83cf1c7 53ef1ea
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 +162 -10
@@ -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
@@ -162,7 +163,10 @@
162163 (defn chats-screen []
163164 (let [buffers (s/channel-list)]
164165 [: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")}]
166170 [error-note]
167171 ;; Join and search are the same shape a channel box with a button
168172 ;; beside it so they read as one control block rather than a titled
@@ -375,6 +379,48 @@
375379 (when (pos? over)
376380 [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
377381
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+
378424 (defn message-row
379425 "One message. `prev` is the message above it, which decides whether this one
380426 repeats the sender.
@@ -419,10 +465,25 @@
419465 ;; fetch lands, and for the guests who have no profile at all, which
420466 ;; is what keeps the column of faces straight down the left.
421467 ;; 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.
422474 (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)])])
426487 ;; The name carries the row, so it is set at body size in the plain
427488 ;; text colour: dimmed caption made the one thing you scan a column
428489 ;; for the faintest thing on it.
@@ -526,6 +587,74 @@
526587 (range (count messages))
527588 messages))
528589
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+
529658 (defn lightbox-screen
530659 "One picture, as big as the window will paint it.
531660
@@ -769,9 +898,9 @@
769898 ;; Not a :page — a page scrolls everything, which would carry the compose
770899 ;; bar off the bottom with the backlog. The message list is the only thing
771900 ;; 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}
775904 [:hbox {:spacing 8}
776905 ;; The way back to the list, on a window with room for one thing at a
777906 ;; time. Beside the list there is nothing to go back to, so the button
@@ -871,9 +1000,13 @@
8711000 ;; window wider than that a left-aligned row leaves it stranded in the
8721001 ;; corner of an otherwise empty bar. On a phone the row is as wide as the
8731002 ;; 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}
8771010 ;; narrow enough that Send keeps its place on a phone-width row
8781011 ;; A picture is pasted where everything else is typed: Ctrl+V. The field
8791012 ;; answers a paste of text with the text, and one of a picture reaches
@@ -987,6 +1120,7 @@
9871120 (defn app []
9881121 (cond
9891122 @s/lightbox [lightbox-screen]
1123+ @profile/viewing [profile-screen]
9901124 @s/image-picker [image-picker-screen]
9911125 ;; Wide enough for both, and on one of the two screens that are halves of
9921126 ;; the same thing: the list and the conversation it opens. Discover and
@@ -1030,4 +1164,22 @@
10301164 (vidya/every! 200 #(let [w (vidya/window-width)]
10311165 (when (not= w @s/window-width)
10321166 (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)))))
10331185 (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 ;; ---------------------------------------------------------------- pieces19 ;; ---------------------------------------------------------------- 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 button171 ;; 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 titled172 ;; 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-row424 (defn message-row
379 "One message. `prev` is the message above it, which decides whether this one425 "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, which465 ;; 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 plain487 ;; 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 column488 ;; 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-screen658 (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 compose898 ;; 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 thing899 ;; 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 the901+ ;; 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 a905 ;; 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 button906 ;; 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 the1000 ;; 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 the1001 ;; 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 flat1003+ ;; 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 row1010 ;; 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 field1011 ;; 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 reaches1012 ;; 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 (cond1121 (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 of1125 ;; 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 and1126 ;; 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 @@
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 "\\" "\\\\")
modified src/frq/platform.jolt +10 -0
@@ -9,6 +9,16 @@
99 []
1010 (host/file-exists? "/system/bin/am"))
1111
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+
1222 (defn open-url!
1323 "Hand a URL to whatever shows web pages here. The backend knows what that
1424 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 that23 "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 no24 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)) ""))))