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>
fd9ad7e parent: ac59bf2 modified
src/frq/app.jolt +57 -4 | @@ -372,6 +372,48 @@ | ||
| 372 | 372 | (when (pos? over) |
| 373 | 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 | 417 | (defn message-row |
| 376 | 418 | "One message. `prev` is the message above it, which decides whether this one |
| 377 | 419 | repeats the sender. |
| @@ -419,11 +461,22 @@ | ||
| 419 | 461 | ;; The face is also the way to the person behind it: Vidya's plain |
| 420 | 462 | ;; label does not answer the pointer, so the tap sleek puts on the |
| 421 | 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 | 467 | (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)])]) | |
| 427 | 480 | ;; The name carries the row, so it is set at body size in the plain |
| 428 | 481 | ;; text colour: dimmed caption made the one thing you scan a column |
| 429 | 482 | ;; 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-row | 417 | (defn message-row |
| 376 | "One message. `prev` is the message above it, which decides whether this one | 418 | "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 plain | 461 | ;; 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 the | 462 | ;; 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 32 | 470 | + :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 plain | 480 | ;; 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 column | 481 | ;; 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 @@ | ||
| 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 |
modified
src/frq/profile.jolt +21 -0 | @@ -82,6 +82,27 @@ | ||
| 82 | 82 | |
| 83 | 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 | 106 | (defn web-url |
| 86 | 107 | "Their profile on the web, by handle where there is one and DID otherwise." |
| 87 | 108 | [{: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-url | 106 | (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]}] |