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

Show a profile on hovering a face

Where there is a pointer to ask with, resting on someone's face answers before
the click does: their picture, name, handle, the first of their bio and their
counts, beside the pointer. The same two fetches opening them would start, so
the card has something on it by the time it is read.

Only the hovered face carries a card — a channel's worth of unseen profiles is
a tree nobody looks at — and only on a desktop, where hovering means anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T19:04:36-07:00 Browse files
fd9ad7e parent: ac59bf2
modified src/frq/app.jolt +57 -4
@@ -372,6 +372,48 @@
372372 (when (pos? over)
373373 [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
374374
375+(defn hover-card
376+ "Who someone is, in the few lines that fit beside a pointer.
377+
378+ The profile screen's opening, without the ways onward: a picture, the name
379+ they go by, their handle, and the first of their bio. What it cannot say yet
380+ it says plainly — a fetch in flight, a guest with no identity to look up —
381+ because a card that is blank while it waits reads as a card with nothing on
382+ it."
383+ [nick actor]
384+ (let [_ @profile/tick
385+ _ @s/media-tick
386+ pr (profile/entry actor)
387+ ready? (= :ready (:status pr))
388+ display (or (:display-name pr) nick)]
389+ [:vbox {:spacing 6}
390+ [:hbox {:spacing 10}
391+ [:avatar {:label nick
392+ :src (or (avatars/path-when-ready actor) "")
393+ :size 48}]
394+ [:vbox {:spacing 2}
395+ [:title-2 {:label display}]
396+ [:vbox {:key :nick}
397+ (when (not= display nick) [:dim-label {:label nick}])]
398+ [:vbox {:key :handle}
399+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
400+ [:label {:label (str "@" h)}])]]]
401+ ;; Shorter than the screen's: this is a glance, not a read, and a bio
402+ ;; that fills the window beside a pointer is in the way of the chat.
403+ [:vbox {:key :bio :spacing 2}
404+ (when-let [bio (and ready? (:description pr))]
405+ (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 200)))]
406+ [:label {:key i :label line}]))]
407+ [:vbox {:key :stats}
408+ (when-let [line (and ready? (profile/stats-line pr))]
409+ [:dim-label {:label line}])]
410+ [:vbox {:key :status}
411+ (cond
412+ (nil? actor) [:dim-label {:label "Guest — no Bluesky identity"}]
413+ (= :loading (:status pr)) [:dim-label {:label "Loading…"}]
414+ (= :failed (:status pr)) [:dim-label {:label "No Bluesky profile found"}])]
415+ [:dim-label {:label "Click for the full profile"}]]))
416+
375417 (defn message-row
376418 "One message. `prev` is the message above it, which decides whether this one
377419 repeats the sender.
@@ -419,11 +461,22 @@
419461 ;; The face is also the way to the person behind it: Vidya's plain
420462 ;; label does not answer the pointer, so the tap sleek puts on the
421463 ;; nick lives here, on the one thing in the row that does.
464+ ;; And, where there is a pointer to ask with, resting on the face
465+ ;; answers before the click does: the card under it is the profile
466+ ;; screen's first few lines, painted beside the pointer.
422467 (let [_ @s/media-tick]
423- [:avatar {:label (:from m)
424- :src (or (avatars/path-when-ready (:actor m)) "")
425- :size 32
426- :on-click #(profile/open! (:from m) (:actor m))}])
468+ [:avatar (cond-> {:label (:from m)
469+ :src (or (avatars/path-when-ready (:actor m)) "")
470+ :size 32
471+ :on-click #(profile/open! (:from m) (:actor m))}
472+ (platform/desktop?)
473+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
474+ :on-unhover #(profile/unhover! (:from m))))
475+ ;; Only the hovered face carries one: a card is painted when its
476+ ;; node has children, and the pointer is on one face at a time.
477+ (when (and (platform/desktop?)
478+ (= (:from m) (:nick @profile/hovering)))
479+ [hover-card (:from m) (:actor m)])])
427480 ;; The name carries the row, so it is set at body size in the plain
428481 ;; text colour: dimmed caption made the one thing you scan a column
429482 ;; for the faintest thing on it.
@@ -372,6 +372,48 @@
372 (when (pos? over)372 (when (pos? over)
373 [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))373 [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
374 374
375+(defn hover-card
376+ "Who someone is, in the few lines that fit beside a pointer.
377+
378+ The profile screen's opening, without the ways onward: a picture, the name
379+ they go by, their handle, and the first of their bio. What it cannot say yet
380+ it says plainly — a fetch in flight, a guest with no identity to look up —
381+ because a card that is blank while it waits reads as a card with nothing on
382+ it."
383+ [nick actor]
384+ (let [_ @profile/tick
385+ _ @s/media-tick
386+ pr (profile/entry actor)
387+ ready? (= :ready (:status pr))
388+ display (or (:display-name pr) nick)]
389+ [:vbox {:spacing 6}
390+ [:hbox {:spacing 10}
391+ [:avatar {:label nick
392+ :src (or (avatars/path-when-ready actor) "")
393+ :size 48}]
394+ [:vbox {:spacing 2}
395+ [:title-2 {:label display}]
396+ [:vbox {:key :nick}
397+ (when (not= display nick) [:dim-label {:label nick}])]
398+ [:vbox {:key :handle}
399+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
400+ [:label {:label (str "@" h)}])]]]
401+ ;; Shorter than the screen's: this is a glance, not a read, and a bio
402+ ;; that fills the window beside a pointer is in the way of the chat.
403+ [:vbox {:key :bio :spacing 2}
404+ (when-let [bio (and ready? (:description pr))]
405+ (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 200)))]
406+ [:label {:key i :label line}]))]
407+ [:vbox {:key :stats}
408+ (when-let [line (and ready? (profile/stats-line pr))]
409+ [:dim-label {:label line}])]
410+ [:vbox {:key :status}
411+ (cond
412+ (nil? actor) [:dim-label {:label "Guest — no Bluesky identity"}]
413+ (= :loading (:status pr)) [:dim-label {:label "Loading…"}]
414+ (= :failed (:status pr)) [:dim-label {:label "No Bluesky profile found"}])]
415+ [:dim-label {:label "Click for the full profile"}]]))
416+
375 (defn message-row417 (defn message-row
376 "One message. `prev` is the message above it, which decides whether this one418 "One message. `prev` is the message above it, which decides whether this one
377 repeats the sender.419 repeats the sender.
@@ -419,11 +461,22 @@
419 ;; The face is also the way to the person behind it: Vidya's plain461 ;; The face is also the way to the person behind it: Vidya's plain
420 ;; label does not answer the pointer, so the tap sleek puts on the462 ;; label does not answer the pointer, so the tap sleek puts on the
421 ;; nick lives here, on the one thing in the row that does.463 ;; nick lives here, on the one thing in the row that does.
464+ ;; And, where there is a pointer to ask with, resting on the face
465+ ;; answers before the click does: the card under it is the profile
466+ ;; screen's first few lines, painted beside the pointer.
422 (let [_ @s/media-tick]467 (let [_ @s/media-tick]
423- [:avatar {:label (:from m)468+ [:avatar (cond-> {:label (:from m)
424- :src (or (avatars/path-when-ready (:actor m)) "")469+ :src (or (avatars/path-when-ready (:actor m)) "")
425- :size 32470+ :size 32
426- :on-click #(profile/open! (:from m) (:actor m))}])471+ :on-click #(profile/open! (:from m) (:actor m))}
472+ (platform/desktop?)
473+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
474+ :on-unhover #(profile/unhover! (:from m))))
475+ ;; Only the hovered face carries one: a card is painted when its
476+ ;; node has children, and the pointer is on one face at a time.
477+ (when (and (platform/desktop?)
478+ (= (:from m) (:nick @profile/hovering)))
479+ [hover-card (:from m) (:actor m)])])
427 ;; The name carries the row, so it is set at body size in the plain480 ;; The name carries the row, so it is set at body size in the plain
428 ;; text colour: dimmed caption made the one thing you scan a column481 ;; text colour: dimmed caption made the one thing you scan a column
429 ;; for the faintest thing on it.482 ;; for the faintest thing on it.
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
modified src/frq/profile.jolt +21 -0
@@ -82,6 +82,27 @@
8282
8383 (defn close! [] (reset! viewing nil))
8484
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+
85106 (defn web-url
86107 "Their profile on the web, by handle where there is one and DID otherwise."
87108 [{:keys [handle did]}]
@@ -82,6 +82,27 @@
82 82
83 (defn close! [] (reset! viewing nil))83 (defn close! [] (reset! viewing nil))
84 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+
85 (defn web-url106 (defn web-url
86 "Their profile on the web, by handle where there is one and DID otherwise."107 "Their profile on the web, by handle where there is one and DID otherwise."
87 [{:keys [handle did]}]108 [{:keys [handle did]}]