Fold the list away, overview every other room, and open profiles in a dialog
Four things the desktop window could not do, and one it was doing badly. The chats list folds. Past `wide-width` the list and the conversation are both on screen, and there was no way to say "not now" to the 320 points the list is holding — `☰` in the chat's heading is that, saved with the other settings, because a reader who wants the conversation whole wants it whole tomorrow. The empty pane carries the switch back, or hiding the list and then closing the last room would put the app away rather than the list. The overview is every other room's last few lines under the one being read, for the question you ask while reading something: is anything happening anywhere else. Three lines a room rather than a shared budget, since one busy channel taking the lot answers about one room — probably the one you are already in. Not the room being read, which is on the screen already and in full. It does not scroll: a fixed handful is what makes the space it takes a number `below-messages` can reserve, and a second scroll here would size itself against the window and fight the backlog for the same points. A profile opens in libcosmic's own dialog now, on a pointer or on a press. The pointer's is non-modal and closes itself; a press pins it. Neither the hover card nor the reaction card is a tooltip any more — one dialog, one slot at the root, and a pinned profile outranks both pointers because it is the only one that was asked for rather than rested on. Leaving a face starts a grace rather than closing: the dialog reports its own pointer, so it is something to move into and read rather than glance at. The Bluesky button on that dialog had never worked. libcosmic has no browser and `frq.cosmic` installed no `open-url!`, so every link in the window fell through to a no-op. It is xdg-open, on http and https alone — every link in a conversation comes from somebody else's message, and xdg-open takes far more than a web page. And the chats list reads as a list again: no filled lozenge on every card saying the same word, no green "joined" on every row saying the ordinary thing, one right edge for the pane instead of two, and the cards held clear of the scrollbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
39459ed parent: 80eb088 modified
src/frq/app.clj +334 -122 | @@ -100,12 +100,6 @@ | ||
| 100 | 100 | [url] |
| 101 | 101 | (derived [:image url] #(do @s/media-tick (media/path-when-ready url)))) |
| 102 | 102 | |
| 103 | -(defonce ^:private hovered-face | |
| 104 | - ;; Which message's face the pointer is on. `profile/hovering` says whose, and | |
| 105 | - ;; the same person's face is on every message they sent: asked by name alone, | |
| 106 | - ;; one hover woke every one of those rows and hung a card off each face. | |
| 107 | - (atom nil)) | |
| 108 | - | |
| 109 | 103 | ;; How big a face is on a message, in points — the size the window has always |
| 110 | 104 | ;; drawn one at. A terminal's cell is eight points across and sixteen down, so |
| 111 | 105 | ;; the same number is four columns by two rows there: a cached 128-pixel |
| @@ -114,9 +108,14 @@ | ||
| 114 | 108 | |
| 115 | 109 | ;; ---------------------------------------------------------------- chats |
| 116 | 110 | |
| 111 | +;; The way into a room. Standard rather than `:primary`: the theme paints a | |
| 112 | +;; suggested button as a filled lozenge, which is the highest-contrast fill it | |
| 113 | +;; has — and a filled lozenge on every card made the brightest, most regular | |
| 114 | +;; rhythm down the list the one word that is the same on every row, with the | |
| 115 | +;; name that differs set quieter than it. Nothing moves; the button stops | |
| 116 | +;; shouting. | |
| 117 | 117 | (defn- open-button [name] |
| 118 | 118 | [:button {:label "Open" |
| 119 | - :kind :primary | |
| 120 | 119 | :on-click #(s/open-channel! name)}]) |
| 121 | 120 | |
| 122 | 121 | ;; The only way out of a room. Everything else adds one — the server saying we |
| @@ -132,10 +131,23 @@ | ||
| 132 | 131 | a list of rooms." |
| 133 | 132 | [text] |
| 134 | 133 | (let [line (str/replace (str text) #"\s+" " ")] |
| 135 | - (if (> (count line) 90) | |
| 136 | - (str (subs line 0 89) "…") | |
| 134 | + ;; 60 rather than a card's worth: at `sidebar-width` a 90-character line is | |
| 135 | + ;; three wrapped rows, and three rows of somebody else's last sentence is | |
| 136 | + ;; the row shouting over the name above it. One row of it says which | |
| 137 | + ;; conversation this is, which is all the list is for. | |
| 138 | + (if (> (count line) 60) | |
| 139 | + (str (subs line 0 59) "…") | |
| 137 | 140 | line))) |
| 138 | 141 | |
| 142 | +;; What everything in the chats pane keeps clear of its right edge. | |
| 143 | +;; | |
| 144 | +;; It is one number because it is one edge: the scrollbar rides it, and the | |
| 145 | +;; cards in the list have to stop short of the bar rather than be drawn under | |
| 146 | +;; it — but the Join box above the list is outside the scroll and has no bar | |
| 147 | +;; beside it, so left to itself it ran on past the cards and the pane had two | |
| 148 | +;; right edges half a finger apart. Whatever the gap is, both of them take it. | |
| 149 | +(def ^:private list-gutter 16) | |
| 150 | + | |
| 139 | 151 | (defn conversation-row [buffer] |
| 140 | 152 | (let [name (:name buffer) |
| 141 | 153 | ;; Defaulted rather than assumed. Everything that builds a buffer |
| @@ -143,44 +155,59 @@ | ||
| 143 | 155 | ;; the atom, and a half-made room reaching here should be a row that |
| 144 | 156 | ;; reads as quiet rather than the frame that killed the client. |
| 145 | 157 | unread (:unread buffer 0)] |
| 146 | - [:card {:key name} | |
| 147 | - ;; The name gets the line, and membership and unread count the one under | |
| 148 | - ;; it. All three on one line is what the list pane has no room for: the | |
| 149 | - ;; name takes what it likes, the badges are left the remainder, and a | |
| 150 | - ;; long name squeezes them into a status reading "joine/d" and a count | |
| 151 | - ;; split down two lines — while the card, sized to a row that no longer | |
| 152 | - ;; fits, sticks out past the cards above and below it. | |
| 153 | - ;; | |
| 154 | - ;; In a terminal the Open button rides up onto the name's line. A row of | |
| 155 | - ;; chrome there is one cell, so a button on a line of its own costs the | |
| 156 | - ;; card a whole row — four of them and the list has spent a screenful on | |
| 157 | - ;; buttons. A window's row is 34 points and its button is a lozenge with | |
| 158 | - ;; air around it, which is why it keeps its own line there. | |
| 159 | - (if @terminal? | |
| 158 | + ;; The card in a wrapper that holds it off the right edge, where the | |
| 159 | + ;; list's scrollbar rides — `message-row` keeps its actions clear of the | |
| 160 | + ;; same edge for the same reason, and without it the cards are drawn | |
| 161 | + ;; under the bar rather than beside it. The wrapper and not the card | |
| 162 | + ;; itself: the window backend pads a card by a fixed 12 and never reads a | |
| 163 | + ;; margin off one, so the room has to be taken outside it. | |
| 164 | + [:vbox {:key name :margin-right list-gutter} | |
| 165 | + [:card {} | |
| 166 | + ;; The name gets the line, and membership and unread count the one under | |
| 167 | + ;; it. All three on one line is what the list pane has no room for: the | |
| 168 | + ;; name takes what it likes, the badges are left the remainder, and a | |
| 169 | + ;; long name squeezes them into a status reading "joine/d" and a count | |
| 170 | + ;; split down two lines — while the card, sized to a row that no longer | |
| 171 | + ;; fits, sticks out past the cards above and below it. | |
| 172 | + ;; | |
| 173 | + ;; In a terminal the Open button rides up onto the name's line. A row of | |
| 174 | + ;; chrome there is one cell, so a button on a line of its own costs the | |
| 175 | + ;; card a whole row — four of them and the list has spent a screenful on | |
| 176 | + ;; buttons. A window's row is 34 points and its button is a lozenge with | |
| 177 | + ;; air around it, which is why it keeps its own line there. | |
| 178 | + (if @terminal? | |
| 160 | 179 | [:hbox {:spacing 8 :wrap false} |
| 161 | 180 | [:title-2 {:label name}] |
| 162 | 181 | [open-button name] |
| 163 | 182 | [close-button name]] |
| 164 | 183 | [:title-2 {:label name}]) |
| 165 | - ;; `:wrap false` on the badges: they sit beside each other or not at all. | |
| 166 | - [:hbox {:spacing 12 :wrap false} | |
| 167 | - ;; A DM has no membership to report — there is nothing to be in — so the | |
| 168 | - ;; badge says what the buffer is instead of answering a question nobody | |
| 169 | - ;; asked of it. | |
| 170 | - [:status {:label (cond (s/dm? name) "direct message" | |
| 171 | - (:joined? buffer) "joined" | |
| 172 | - :else "not joined") | |
| 173 | - :live (boolean (:joined? buffer))}] | |
| 174 | - ;; A mention is not more unread, it is different unread: the dot says | |
| 175 | - ;; how much there is and the name says it was aimed at you. Both or | |
| 176 | - ;; neither — a room with your name in it always has a line to count. | |
| 177 | - (when (pos? unread) | |
| 178 | - [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])] | |
| 179 | - [:dim-label {:label (preview-line (s/last-preview buffer))}] | |
| 180 | - (when-not @terminal? | |
| 181 | - [:hbox {:spacing 8 :wrap false} | |
| 182 | - [open-button name] | |
| 183 | - [close-button name]])])) | |
| 184 | + ;; The badge line, and only when there is a badge: every row carrying a | |
| 185 | + ;; green "joined" was a row spending a third of its height saying the | |
| 186 | + ;; ordinary thing, and a list where every card says the same word is a | |
| 187 | + ;; list you read by skipping. So membership is reported when it is news — | |
| 188 | + ;; a room in the list we are not in — and a DM says nothing at all, since | |
| 189 | + ;; there is no membership to have and the name already says what it is. | |
| 190 | + ;; | |
| 191 | + ;; In a wrapper of its own, because the line comes and goes with the | |
| 192 | + ;; unread count and the reconciler numbers a card's children by position. | |
| 193 | + ;; `:wrap false` on the badges: they sit beside each other or not at all. | |
| 194 | + [:vbox {:key :badges} | |
| 195 | + (let [away? (and (not (s/dm? name)) (not (:joined? buffer)))] | |
| 196 | + (when (or away? (pos? unread)) | |
| 197 | + [:hbox {:spacing 12 :wrap false} | |
| 198 | + (when away? | |
| 199 | + [:status {:label "not joined" :live false}]) | |
| 200 | + ;; A mention is not more unread, it is different unread: the dot | |
| 201 | + ;; says how much there is and the name says it was aimed at you. | |
| 202 | + ;; Both or neither — a room with your name in it always has a line | |
| 203 | + ;; to count. | |
| 204 | + (when (pos? unread) | |
| 205 | + [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]))] | |
| 206 | + [:dim-label {:label (preview-line (s/last-preview buffer))}] | |
| 207 | + (when-not @terminal? | |
| 208 | + [:hbox {:spacing 8 :wrap false} | |
| 209 | + [open-button name] | |
| 210 | + [close-button name]])]])) | |
| 184 | 211 | |
| 185 | 212 | (def ^:private sidebar-width 320) |
| 186 | 213 | |
| @@ -264,11 +291,15 @@ | ||
| 264 | 291 | ;; under it. Every screen that pins something to the bottom says this on |
| 265 | 292 | ;; its own root — the split view already said it, one wrapper further out. |
| 266 | 293 | [:vbox {:spacing 8 :margin 12 :fill-height true} |
| 267 | - [:vbox {:key :head :spacing 8} | |
| 294 | + [:vbox {:key :head :spacing 8 :margin-right list-gutter} | |
| 268 | 295 | ;; Your nick in the title, where "Chats" alone said nothing you did not |
| 269 | - ;; already know. It is what every channel calls you and what your own | |
| 270 | - ;; lines are signed with, and the only other place it showed was Settings. | |
| 271 | - [:title {:label (if (s/connected?) (str "Chats as " @s/form-nick) "Chats")}] | |
| 296 | + ;; already know — and said plainly, as a sign-in rather than as a name | |
| 297 | + ;; the list is somehow held in. It is what every channel calls you and | |
| 298 | + ;; what your own lines are signed with, and the only other place it | |
| 299 | + ;; showed was Settings. | |
| 300 | + [:title {:label (if (s/connected?) | |
| 301 | + (str "Logged in as " @s/form-nick) | |
| 302 | + "Chats")}] | |
| 272 | 303 | [error-note] |
| 273 | 304 | ;; Join and search are the same shape — a channel box with a button |
| 274 | 305 | ;; beside it — so they read as one control block rather than a titled |
| @@ -322,7 +353,8 @@ | ||
| 322 | 353 | ;; the slot the reader clicked while the row that moved into it is |
| 323 | 354 | ;; someone else's conversation. |
| 324 | 355 | (for [b buffers] ^{:key (:name b)} [conversation-row b]) |
| 325 | - [:card {} [:dim-label {:label "No conversations yet — join a channel."}]])]] | |
| 356 | + [:vbox {:margin-right list-gutter} | |
| 357 | + [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]])]] | |
| 326 | 358 | [:vbox {:key :foot :spacing 8} |
| 327 | 359 | [:separator {}] |
| 328 | 360 | [tab-bar]]])) |
| @@ -521,13 +553,7 @@ | ||
| 521 | 553 | than a button with the emoji as its label — the chip draws the glyph from the |
| 522 | 554 | Twemoji pack, in colour, where a label gets whatever the text font has." |
| 523 | 555 | [channel m] |
| 524 | - (let [reactions (:reactions m) | |
| 525 | - ;; Which of this message's pills the pointer is on, if any. Asked once | |
| 526 | - ;; per message, so moving between pills wakes the rows involved and not | |
| 527 | - ;; every row that has a reaction on it. | |
| 528 | - hovered @(derived [:pill-hover (:id m)] | |
| 529 | - #(let [h @s/reaction-hover] | |
| 530 | - (when (= (:id m) (:id h)) (:emoji h))))] | |
| 556 | + (let [reactions (:reactions m)] | |
| 531 | 557 | ;; `into` and not a lazy `for` inside the vector. The pills read |
| 532 | 558 | ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being |
| 533 | 559 | ;; realised somewhere other than the render is a read the component never |
| @@ -547,13 +573,14 @@ | ||
| 547 | 573 | ;; says who put it there. On a phone the pill is a button |
| 548 | 574 | ;; and nothing more: there is no hover to answer. |
| 549 | 575 | (platform/desktop?) |
| 576 | + ;; Nothing is hung under the pill any more: who is on a | |
| 577 | + ;; reaction is shown the way everything else the pointer | |
| 578 | + ;; asks for is shown now — the dialog at the root of the | |
| 579 | + ;; tree, which `reactor-dialog` builds out of | |
| 580 | + ;; `reaction-hover`. The pill's only job is to say where | |
| 581 | + ;; the pointer is. | |
| 550 | 582 | (assoc :on-hover #(s/hover-reaction! (:id m) emoji) |
| 551 | - :on-unhover #(s/unhover-reaction! (:id m) emoji))) | |
| 552 | - ;; Only the hovered pill carries a card: the panel is painted from | |
| 553 | - ;; whatever children the node has, and a channel's worth of unseen | |
| 554 | - ;; lists is a tree nobody looks at. | |
| 555 | - (when (and (platform/desktop?) (= emoji hovered)) | |
| 556 | - [reactor-card emoji (get reactions emoji)])])))) | |
| 583 | + :on-unhover #(s/unhover-reaction! (:id m) emoji)))])))) | |
| 557 | 584 | |
| 558 | 585 | (def ^:private picker-columns |
| 559 | 586 | "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a |
| @@ -620,48 +647,6 @@ | ||
| 620 | 647 | (when (pos? over) |
| 621 | 648 | [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]])) |
| 622 | 649 | |
| 623 | -(defn hover-card | |
| 624 | - "Who someone is, in the few lines that fit beside a pointer. | |
| 625 | - | |
| 626 | - The profile screen's opening, without the ways onward: a picture, the name | |
| 627 | - they go by, their handle, and the first of their bio. What it cannot say yet | |
| 628 | - it says plainly — a fetch in flight, a guest with no identity to look up — | |
| 629 | - because a card that is blank while it waits reads as a card with nothing on | |
| 630 | - it." | |
| 631 | - [nick actor] | |
| 632 | - (let [_ @profile/tick | |
| 633 | - _ @s/media-tick | |
| 634 | - pr (profile/entry actor) | |
| 635 | - ready? (= :ready (:status pr)) | |
| 636 | - display (or (:display-name pr) nick)] | |
| 637 | - [:vbox {:spacing 6} | |
| 638 | - [:hbox {:spacing 10} | |
| 639 | - [:avatar {:label nick | |
| 640 | - :src (or (avatars/path-when-ready actor) "") | |
| 641 | - :size 48}] | |
| 642 | - [:vbox {:spacing 2} | |
| 643 | - [:title-2 {:label display}] | |
| 644 | - [:vbox {:key :nick} | |
| 645 | - (when (not= display nick) [:dim-label {:label nick}])] | |
| 646 | - [:vbox {:key :handle} | |
| 647 | - (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 648 | - [:label {:label (str "@" h)}])]]] | |
| 649 | - ;; Shorter than the screen's: this is a glance, not a read, and a bio | |
| 650 | - ;; that fills the window beside a pointer is in the way of the chat. | |
| 651 | - [:vbox {:key :bio :spacing 2} | |
| 652 | - (when-let [bio (and ready? (:description pr))] | |
| 653 | - (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 200)))] | |
| 654 | - [:label {:key i :label line}]))] | |
| 655 | - [:vbox {:key :stats} | |
| 656 | - (when-let [line (and ready? (profile/stats-line pr))] | |
| 657 | - [:dim-label {:label line}])] | |
| 658 | - [:vbox {:key :status} | |
| 659 | - (cond | |
| 660 | - (nil? actor) [:dim-label {:label "Guest — no Bluesky identity"}] | |
| 661 | - (= :loading (:status pr)) [:dim-label {:label "Loading…"}] | |
| 662 | - (= :failed (:status pr)) [:dim-label {:label "No Bluesky profile found"}])] | |
| 663 | - [:dim-label {:label "Click for the full profile"}]])) | |
| 664 | - | |
| 665 | 650 | (defn- preview-height |
| 666 | 651 | "How tall a picture in the conversation may be. |
| 667 | 652 | |
| @@ -744,21 +729,17 @@ | ||
| 744 | 729 | ;; the whole message rather than off its heading — `message-row` has it. |
| 745 | 730 | (when-not @terminal? |
| 746 | 731 | (let [src @(avatar-path (:actor m))] |
| 732 | + ;; One profile, two gestures, and no card hung under the face: the | |
| 733 | + ;; pointer opens the dialog and the press pins it. What makes that | |
| 734 | + ;; work is the dialog being non-modal while the pointer is what is | |
| 735 | + ;; holding it open — see `profile-dialog`. | |
| 747 | 736 | [:avatar (cond-> {:label (:from m) |
| 748 | 737 | :src (or src "") |
| 749 | 738 | :size face-size |
| 750 | 739 | :on-click #(profile/open! (:from m) (:actor m))} |
| 751 | 740 | (platform/desktop?) |
| 752 | - (assoc :on-hover #(do (reset! hovered-face (:id m)) | |
| 753 | - (profile/hover! (:from m) (:actor m))) | |
| 754 | - :on-unhover #(profile/unhover! (:from m)))) | |
| 755 | - ;; Only the hovered face carries one: a card is painted when its | |
| 756 | - ;; node has children, and the pointer is on one face at a time. | |
| 757 | - (when (and (platform/desktop?) | |
| 758 | - @(derived [:hovering (:id m) (:from m)] | |
| 759 | - #(and (= (:id m) @hovered-face) | |
| 760 | - (= (:from m) (:nick @profile/hovering))))) | |
| 761 | - [hover-card (:from m) (:actor m)])])) | |
| 741 | + (assoc :on-hover #(profile/hover! (:from m) (:actor m)) | |
| 742 | + :on-unhover #(profile/unhover! (:from m))))])) | |
| 762 | 743 | ;; The name carries the row, so it is set at body size in the plain |
| 763 | 744 | ;; text colour: dimmed caption made the one thing you scan a column |
| 764 | 745 | ;; for the faintest thing on it. |
| @@ -920,6 +901,120 @@ | ||
| 920 | 901 | (range (count messages)) |
| 921 | 902 | messages)) |
| 922 | 903 | |
| 904 | +(defn- profile-dialog | |
| 905 | + "Who someone is, as libcosmic's own dialog: centred over the window, with | |
| 906 | + what you were reading dimmed behind it rather than replaced. | |
| 907 | + | |
| 908 | + The window can do this and the terminal cannot, which is the whole reason | |
| 909 | + there are two of these. `profile-screen` below is the terminal's, and says | |
| 910 | + why it is a screen. | |
| 911 | + | |
| 912 | + Both buttons are always here, the Bluesky one insensitive until there is a | |
| 913 | + profile to open: a dialog whose second button appears a moment after it | |
| 914 | + opens is a dialog that moves under the pointer, and the fetch lands whenever | |
| 915 | + it lands. | |
| 916 | + | |
| 917 | + Who it is about comes from either of two places, and that — with the | |
| 918 | + modality below — is the whole of what hovering and pressing a face do | |
| 919 | + differently. Resting on one sets `hovering`, which the pointer takes away | |
| 920 | + again when it leaves; pressing one sets `viewing`, which nothing takes away | |
| 921 | + but Close. `viewing` is read first, so a pinned profile is not swapped out | |
| 922 | + from under the reader by a face the pointer crosses on the way to it. | |
| 923 | + | |
| 924 | + And a dialog the pointer is holding open is not modal. A modal one makes | |
| 925 | + the window underneath it deaf — libcosmic wraps the app in a popover that | |
| 926 | + hands its content an `Unavailable` cursor while a popup is up — so the face | |
| 927 | + that opened it never hears the pointer leave, and what a hover opened could | |
| 928 | + never close itself. Non-modal, the face keeps hearing, and moving away shuts | |
| 929 | + it. A pinned one is modal, which is what being pinned means: it is the thing | |
| 930 | + on the screen until it is dismissed." | |
| 931 | + [] | |
| 932 | + (let [pinned? (some? @profile/viewing) | |
| 933 | + {:keys [nick actor]} (or @profile/viewing @profile/hovering) | |
| 934 | + _ @profile/tick | |
| 935 | + _ @s/media-tick | |
| 936 | + pr (profile/entry actor) | |
| 937 | + ready? (= :ready (:status pr)) | |
| 938 | + display (or (:display-name pr) nick) | |
| 939 | + url (when ready? (profile/web-url pr))] | |
| 940 | + [:dialog {:label display :max-width 520 :modal pinned? | |
| 941 | + :on-hover profile/enter-dialog! | |
| 942 | + :on-unhover profile/leave-dialog!} | |
| 943 | + ;; The body is the screen's card without its heading: the dialog's own | |
| 944 | + ;; title is the name now, so repeating it under the picture is a line | |
| 945 | + ;; that says nothing. | |
| 946 | + [:vbox {:key :body :spacing 8} | |
| 947 | + [:hbox {:spacing 12} | |
| 948 | + [:avatar {:label nick | |
| 949 | + :src (or (avatars/path-when-ready actor) "") | |
| 950 | + :size 72}] | |
| 951 | + [:vbox {:spacing 2} | |
| 952 | + [:vbox {:key :nick} | |
| 953 | + (when (not= display nick) [:dim-label {:label nick}])] | |
| 954 | + [:vbox {:key :handle} | |
| 955 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | |
| 956 | + [:label {:label (str "@" h)}])] | |
| 957 | + [:vbox {:key :did} | |
| 958 | + (when-let [did (:did pr)] [:dim-label {:label did}])]]] | |
| 959 | + [:vbox {:key :bio :spacing 2} | |
| 960 | + (when-let [bio (and ready? (:description pr))] | |
| 961 | + (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | |
| 962 | + [:label {:key i :label line}]))] | |
| 963 | + [:vbox {:key :stats} | |
| 964 | + (when-let [line (and ready? (profile/stats-line pr))] | |
| 965 | + [:dim-label {:label line}])] | |
| 966 | + [:vbox {:key :status :spacing 4} | |
| 967 | + (cond | |
| 968 | + (nil? actor) | |
| 969 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | |
| 970 | + | |
| 971 | + (= :loading (:status pr)) | |
| 972 | + [:hbox {:spacing 8} | |
| 973 | + [:spinner {}] | |
| 974 | + [:dim-label {:label "Loading Bluesky profile…"}]] | |
| 975 | + | |
| 976 | + (= :failed (:status pr)) | |
| 977 | + [:dim-label {:label "No Bluesky profile found"}])] | |
| 978 | + ;; What the pointer is holding open says how to stop having to hold it. | |
| 979 | + ;; Pinned, that line has nothing left to tell anyone. | |
| 980 | + [:vbox {:key :hint} | |
| 981 | + (when-not pinned? | |
| 982 | + [:dim-label {:label "Click the face to keep this open"}])]] | |
| 983 | + ;; `slot` is where libcosmic puts a button: the two actions go to the | |
| 984 | + ;; foot of the dialog, and anything else here would be another control | |
| 985 | + ;; stacked in the body. | |
| 986 | + ;; | |
| 987 | + ;; Both are here whether the profile is pinned or only hovered. They were | |
| 988 | + ;; hidden while hovering, back when a hover could not be walked into: the | |
| 989 | + ;; dialog reports its own pointer now, so moving towards a button in it | |
| 990 | + ;; keeps it open instead of closing it, and a button you can reach is a | |
| 991 | + ;; button worth drawing. | |
| 992 | + [:button {:key :close :slot "primary" | |
| 993 | + :label (if pinned? "Close" "Dismiss") | |
| 994 | + :on-click profile/dismiss!}] | |
| 995 | + [:button {:key :web :slot "secondary" :label "Bluesky ↗" | |
| 996 | + :sensitive (boolean url) | |
| 997 | + :on-click #(when url (platform/open-url! url))}]])) | |
| 998 | + | |
| 999 | +(defn- reactor-dialog | |
| 1000 | + "Who is on the reaction the pointer is resting on. | |
| 1001 | + | |
| 1002 | + The same dialog a face gets, for the same reason: two hover cards in two | |
| 1003 | + shapes was two things to look at and two things to keep working. Non-modal | |
| 1004 | + and no buttons, because the pointer is what is holding it open — a modal one | |
| 1005 | + would go deaf to the pill leaving and never close, and a button in it could | |
| 1006 | + never be reached. | |
| 1007 | + | |
| 1008 | + The message is looked up rather than carried: a pill says which message and | |
| 1009 | + which emoji it is, and the room it is in is the one being read — a pill in | |
| 1010 | + any other room is not under a pointer." | |
| 1011 | + [] | |
| 1012 | + (let [{:keys [id emoji]} @s/reaction-hover | |
| 1013 | + nicks (get (:reactions (s/message-by-id @s/current id)) emoji)] | |
| 1014 | + (when (seq nicks) | |
| 1015 | + [:dialog {:label "Reactions" :max-width 320 :modal false} | |
| 1016 | + [reactor-card emoji nicks]]))) | |
| 1017 | + | |
| 923 | 1018 | (defn profile-screen |
| 924 | 1019 | "Who someone is: sleek's peer profile modal, as a screen. |
| 925 | 1020 | |
| @@ -1196,6 +1291,19 @@ | ||
| 1196 | 1291 | ;; there is one. Those rows do move the compose bar, and should: each appears |
| 1197 | 1292 | ;; because the reader just asked for something — unlike the jump button, which |
| 1198 | 1293 | ;; appears on its own and must not shift what is under it. |
| 1294 | +;; What the overview strip costs the conversation above it, in points: a row | |
| 1295 | +;; for its heading, a row a line, and the separator and gaps around them. The | |
| 1296 | +;; backlog's `:reserve` takes this too, so the strip is paid for out of the | |
| 1297 | +;; conversation rather than pushing the compose bar off the bottom — which is | |
| 1298 | +;; what every other row under the message list has had to say for itself. | |
| 1299 | +;; | |
| 1300 | +;; Counted from the lines there actually are, not from the ceiling: the strip | |
| 1301 | +;; holds a few per room, so its height is the client's rooms, and reserving | |
| 1302 | +;; for a full one in a client with two channels in it would take a third of | |
| 1303 | +;; the window off the conversation to leave it empty. | |
| 1304 | +(defn- overview-height [] | |
| 1305 | + (* (chrome-scale) (+ 24 (* 20 (count (s/recent-everywhere))) 16))) | |
| 1306 | + | |
| 1199 | 1307 | (defn- below-messages [] |
| 1200 | 1308 | ;; 140 is that, counted: the gap under the row of columns, the jump button's |
| 1201 | 1309 | ;; 34pt row, the separator and the two empty wrappers with a gap apiece, then |
| @@ -1218,7 +1326,12 @@ | ||
| 1218 | 1326 | ;; box rounded up: the reserve has to be at least what the field took, |
| 1219 | 1327 | ;; because a point too few does not crop the list, it slides the |
| 1220 | 1328 | ;; compose bar off the bottom of the window. |
| 1221 | - (* 20 (dec @draft-rows)))) | |
| 1329 | + (* 20 (dec @draft-rows)) | |
| 1330 | + ;; And the overview strip, when it is up. Reserved here rather than | |
| 1331 | + ;; anywhere else because this is the number the backlog is laid out | |
| 1332 | + ;; against: without it the strip is drawn past the bottom of the window | |
| 1333 | + ;; and takes the compose bar with it. | |
| 1334 | + (if @s/overview? (overview-height) 0))) | |
| 1222 | 1335 | |
| 1223 | 1336 | (defn- messages-width |
| 1224 | 1337 | "How wide the message list may be with the people panel beside it. |
| @@ -1229,7 +1342,9 @@ | ||
| 1229 | 1342 | first `sidebar-width` of the window; the rest is the margins and the gap |
| 1230 | 1343 | between the two columns." |
| 1231 | 1344 | [] |
| 1232 | - (let [pane (- @s/window-width (if (s/wide?) sidebar-width 0))] | |
| 1345 | + (let [pane (- @s/window-width (if (and (s/wide?) (not @s/hide-chat-list?)) | |
| 1346 | + sidebar-width | |
| 1347 | + 0))] | |
| 1233 | 1348 | (max 240 (- pane users-width 8 28)))) |
| 1234 | 1349 | |
| 1235 | 1350 | (defn- member-row [{:keys [nick prefix]}] |
| @@ -1305,6 +1420,44 @@ | ||
| 1305 | 1420 | [:button {:label "Accept policy" :kind :primary |
| 1306 | 1421 | :on-click #(s/accept-policy! name)}]])) |
| 1307 | 1422 | |
| 1423 | +(defn- overview-row | |
| 1424 | + "One line from somewhere else: which room, who, and what — on one row. | |
| 1425 | + | |
| 1426 | + The room leads, because that is the whole question the strip answers. The | |
| 1427 | + name is a button for the same reason it is one in the people panel: the | |
| 1428 | + room is somewhere to go, and a line you want more of is a line you want the | |
| 1429 | + conversation behind it." | |
| 1430 | + [i m] | |
| 1431 | + [:hbox {:key i :spacing 8 :wrap false} | |
| 1432 | + [:button {:label (:channel m) | |
| 1433 | + :on-click #(s/open-channel! (:channel m))}] | |
| 1434 | + [:vbox {:key :said} | |
| 1435 | + [:dim-label {:label (str (when-let [at (:at m)] (str (clock/clock-time at) " ")) | |
| 1436 | + (:from m) ": " | |
| 1437 | + (preview-line (:text m)))}]]]) | |
| 1438 | + | |
| 1439 | +(defn- overview-pane | |
| 1440 | + "Every room's last few lines in one list, oldest at the top. | |
| 1441 | + | |
| 1442 | + A strip under the conversation rather than a screen of its own: the question | |
| 1443 | + it answers — is anything happening anywhere else — is one you ask while | |
| 1444 | + reading something, and an answer you have to leave the room for is one you | |
| 1445 | + stop asking for. | |
| 1446 | + | |
| 1447 | + It does not scroll. A fixed handful of lines is what makes the room it takes | |
| 1448 | + from the backlog a number this file can reserve; a scroll here would be a | |
| 1449 | + second thing on the screen sizing itself against the window, fighting the | |
| 1450 | + backlog above it for the same points." | |
| 1451 | + [] | |
| 1452 | + (let [lines (s/recent-everywhere)] | |
| 1453 | + [:vbox {:key :overview :spacing 4 :margin-top 4} | |
| 1454 | + [:separator {}] | |
| 1455 | + [:dim-label {:label "Everywhere else"}] | |
| 1456 | + (if (seq lines) | |
| 1457 | + (for [[i m] (map-indexed vector lines)] | |
| 1458 | + ^{:key (str (:channel m) "-" (or (:id m) i))} [overview-row i m]) | |
| 1459 | + [:dim-label {:label "Nothing has been said in any room yet."}])])) | |
| 1460 | + | |
| 1308 | 1461 | (defn chat-screen [] |
| 1309 | 1462 | (let [name @s/current |
| 1310 | 1463 | buffer (get @s/channels name) |
| @@ -1333,6 +1486,14 @@ | ||
| 1333 | 1486 | [:vbox {:key :back} |
| 1334 | 1487 | (when-not (s/wide?) |
| 1335 | 1488 | [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] |
| 1489 | + ;; And the same room's other way of appearing, on a window wide enough | |
| 1490 | + ;; to have been showing both: the list folds away and the conversation | |
| 1491 | + ;; takes the whole row. Its own wrapper, since it is only offered where | |
| 1492 | + ;; there are two panes to choose between. | |
| 1493 | + [:vbox {:key :fold} | |
| 1494 | + (when (s/wide?) | |
| 1495 | + [:button {:label (if @s/hide-chat-list? "☰ Chats" "☰") | |
| 1496 | + :on-click s/toggle-chat-list!}])] | |
| 1336 | 1497 | [:title {:label (or name "Chat")}] |
| 1337 | 1498 | ;; Same wrapper trick: only in a channel, and only when there is no call |
| 1338 | 1499 | ;; to join already — the bar below offers Join in that case, and two ways |
| @@ -1351,7 +1512,14 @@ | ||
| 1351 | 1512 | (when (and name (str/starts-with? name "#")) |
| 1352 | 1513 | [:button {:label (str "People " (s/member-count name)) |
| 1353 | 1514 | :kind (when @s/show-users? :primary) |
| 1354 | - :on-click s/toggle-users!}])]] | |
| 1515 | + :on-click s/toggle-users!}])] | |
| 1516 | + ;; The overview's switch. Not in a wrapper conditioned on anything: it | |
| 1517 | + ;; is about every room rather than this one, so it is offered in a DM | |
| 1518 | + ;; and in a channel alike. | |
| 1519 | + [:button {:key :overview-toggle | |
| 1520 | + :label "Overview" | |
| 1521 | + :kind (when @s/overview? :primary) | |
| 1522 | + :on-click s/toggle-overview!}]] | |
| 1355 | 1523 | [error-note] |
| 1356 | 1524 | ;; In a wrapper of its own, for the reconciler's sake: it comes and goes. |
| 1357 | 1525 | [:vbox {:key :policy} |
| @@ -1393,6 +1561,11 @@ | ||
| 1393 | 1561 | ;; compose bar sits in whether or not the panel was showing. |
| 1394 | 1562 | [:vbox {:key :people-pane} |
| 1395 | 1563 | (when show-users? [users-panel name])]] |
| 1564 | + ;; Under the conversation and above the jump button: it is a second list | |
| 1565 | + ;; of messages, so it belongs with the first rather than down among the | |
| 1566 | + ;; compose bar's rows. In a wrapper of its own, since it comes and goes. | |
| 1567 | + [:vbox {:key :overview-pane} | |
| 1568 | + (when @s/overview? [overview-pane])] | |
| 1396 | 1569 | ;; Only while it is needed, and directly above the compose bar: the way |
| 1397 | 1570 | ;; back to the present belongs next to the thing that puts you there. |
| 1398 | 1571 | ;; The row keeps its height whether or not the button is in it, so the |
| @@ -1509,7 +1682,16 @@ | ||
| 1509 | 1682 | [] |
| 1510 | 1683 | [:vbox {:spacing 8 :margin 12} |
| 1511 | 1684 | [:title {:label "frq"}] |
| 1512 | - [:card {} [:dim-label {:label "Pick a conversation on the left."}]]]) | |
| 1685 | + ;; With the list folded away there is nothing on the left to pick from, and | |
| 1686 | + ;; no conversation here carrying the switch that would bring it back — so | |
| 1687 | + ;; this pane carries it instead. Without that the reader who hid the list | |
| 1688 | + ;; and then closed the last room has put the app away, not the list. | |
| 1689 | + (if @s/hide-chat-list? | |
| 1690 | + [:card {} | |
| 1691 | + [:dim-label {:label "The chats list is hidden."}] | |
| 1692 | + [:button {:label "☰ Chats" :kind :primary | |
| 1693 | + :on-click s/toggle-chat-list!}]] | |
| 1694 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]])]) | |
| 1513 | 1695 | |
| 1514 | 1696 | (defn split-screen |
| 1515 | 1697 | "The chats list and the conversation side by side, for a window wide enough |
| @@ -1544,9 +1726,18 @@ | ||
| 1544 | 1726 | ;; axis alone: the height without the share. The conversation keeps |
| 1545 | 1727 | ;; :fill-height and so keeps the slack, which is what the note above says |
| 1546 | 1728 | ;; it takes. |
| 1547 | - [:vbox {:key :list :width-request sidebar-width :fill-height true | |
| 1548 | - :expand :cross} | |
| 1549 | - [chats-screen]] | |
| 1729 | + ;; The list pane, when the reader has not put it away. Hidden, the wrapper | |
| 1730 | + ;; stays and empties: a child that vanished would renumber the row for the | |
| 1731 | + ;; reconciler and take the conversation's scroll position with it every | |
| 1732 | + ;; time the list was toggled — the same trick the people panel plays. | |
| 1733 | + ;; Empty, it asks for nothing: no width and no `:fill-height`, because a | |
| 1734 | + ;; column that fills the height is also a column that is there, and a | |
| 1735 | + ;; 320-point hole where the list was is not hiding it. | |
| 1736 | + (if @s/hide-chat-list? | |
| 1737 | + [:vbox {:key :list}] | |
| 1738 | + [:vbox {:key :list :width-request sidebar-width :fill-height true | |
| 1739 | + :expand :cross} | |
| 1740 | + [chats-screen]]) | |
| 1550 | 1741 | [:vbox {:key :chat :fill-height true} |
| 1551 | 1742 | (if @s/current |
| 1552 | 1743 | [chat-screen] |
| @@ -1638,9 +1829,30 @@ | ||
| 1638 | 1829 | ;; from the conversation you were in a moment ago. A key that changes with |
| 1639 | 1830 | ;; the screen makes the swap a swap: the old tree comes out whole and the new |
| 1640 | 1831 | ;; one goes in whole. |
| 1641 | - (cond | |
| 1832 | + ;; And the dialog beside them all rather than instead of one of them. A | |
| 1833 | + ;; `dialog` node is not painted where it stands — the window backend hands | |
| 1834 | + ;; it to libcosmic, which puts it over the middle of the window with what is | |
| 1835 | + ;; behind it dimmed — so the screen under it keeps its place in the tree, | |
| 1836 | + ;; and its scroll position with it. The wrapper is always here and only its | |
| 1837 | + ;; child comes and goes, for the reason every other wrapper in this file | |
| 1838 | + ;; gives: a child that appeared and vanished would renumber the root. | |
| 1839 | + ;; | |
| 1840 | + ;; The terminal has no such thing, and a `dialog` tag it has not grown would | |
| 1841 | + ;; paint its contents inline at the bottom of the screen. So there it stays | |
| 1842 | + ;; a screen you go to and come back from, which is `profile-screen`. | |
| 1843 | + [:vbox {:key :root :fill-height true} | |
| 1844 | + ;; One dialog at a time, and a pinned profile outranks both pointers: it is | |
| 1845 | + ;; the only one of the three that was asked for by a press rather than by | |
| 1846 | + ;; where the pointer happens to be resting. | |
| 1847 | + [:vbox {:key :dialog} | |
| 1848 | + (when-not @terminal? | |
| 1849 | + (cond | |
| 1850 | + (or @profile/viewing @profile/hovering) [profile-dialog] | |
| 1851 | + @s/reaction-hover [reactor-dialog]))] | |
| 1852 | + (cond | |
| 1642 | 1853 | @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] |
| 1643 | - @profile/viewing [:vbox {:key :screen-profile} [profile-screen]] | |
| 1854 | + (and @terminal? @profile/viewing) | |
| 1855 | + [:vbox {:key :screen-profile} [profile-screen]] | |
| 1644 | 1856 | @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] |
| 1645 | 1857 | ;; Wide enough for both, and on one of the two screens that are halves of |
| 1646 | 1858 | ;; the same thing: the list and the conversation it opens. Discover and |
| @@ -1653,7 +1865,7 @@ | ||
| 1653 | 1865 | :chat [:vbox {:key :screen-chat} [chat-screen]] |
| 1654 | 1866 | :discover [:vbox {:key :screen-discover} [discover-screen]] |
| 1655 | 1867 | :settings [:vbox {:key :screen-settings} [settings-screen]] |
| 1656 | - [:vbox {:key :screen-chats} [chats-screen]]))) | |
| 1868 | + [:vbox {:key :screen-chats} [chats-screen]]))]) | |
| 1657 | 1869 | |
| 1658 | 1870 | (defn start! |
| 1659 | 1871 | "Everything a launch does before the loop starts, for whichever backend is |
| @@ -100,12 +100,6 @@ | |||
| 100 | [url] | 100 | [url] |
| 101 | (derived [:image url] #(do @s/media-tick (media/path-when-ready url)))) | 101 | (derived [:image url] #(do @s/media-tick (media/path-when-ready url)))) |
| 102 | 102 | ||
| 103 | -(defonce ^:private hovered-face | ||
| 104 | - ;; Which message's face the pointer is on. `profile/hovering` says whose, and | ||
| 105 | - ;; the same person's face is on every message they sent: asked by name alone, | ||
| 106 | - ;; one hover woke every one of those rows and hung a card off each face. | ||
| 107 | - (atom nil)) | ||
| 108 | - | ||
| 109 | ;; How big a face is on a message, in points — the size the window has always | 103 | ;; How big a face is on a message, in points — the size the window has always |
| 110 | ;; drawn one at. A terminal's cell is eight points across and sixteen down, so | 104 | ;; drawn one at. A terminal's cell is eight points across and sixteen down, so |
| 111 | ;; the same number is four columns by two rows there: a cached 128-pixel | 105 | ;; the same number is four columns by two rows there: a cached 128-pixel |
| @@ -114,9 +108,14 @@ | |||
| 114 | 108 | ||
| 115 | ;; ---------------------------------------------------------------- chats | 109 | ;; ---------------------------------------------------------------- chats |
| 116 | 110 | ||
| 111 | +;; The way into a room. Standard rather than `:primary`: the theme paints a | ||
| 112 | +;; suggested button as a filled lozenge, which is the highest-contrast fill it | ||
| 113 | +;; has — and a filled lozenge on every card made the brightest, most regular | ||
| 114 | +;; rhythm down the list the one word that is the same on every row, with the | ||
| 115 | +;; name that differs set quieter than it. Nothing moves; the button stops | ||
| 116 | +;; shouting. | ||
| 117 | (defn- open-button [name] | 117 | (defn- open-button [name] |
| 118 | [:button {:label "Open" | 118 | [:button {:label "Open" |
| 119 | - :kind :primary | ||
| 120 | :on-click #(s/open-channel! name)}]) | 119 | :on-click #(s/open-channel! name)}]) |
| 121 | 120 | ||
| 122 | ;; The only way out of a room. Everything else adds one — the server saying we | 121 | ;; The only way out of a room. Everything else adds one — the server saying we |
| @@ -132,10 +131,23 @@ | |||
| 132 | a list of rooms." | 131 | a list of rooms." |
| 133 | [text] | 132 | [text] |
| 134 | (let [line (str/replace (str text) #"\s+" " ")] | 133 | (let [line (str/replace (str text) #"\s+" " ")] |
| 135 | - (if (> (count line) 90) | 134 | + ;; 60 rather than a card's worth: at `sidebar-width` a 90-character line is |
| 136 | - (str (subs line 0 89) "…") | 135 | + ;; three wrapped rows, and three rows of somebody else's last sentence is |
| 136 | + ;; the row shouting over the name above it. One row of it says which | ||
| 137 | + ;; conversation this is, which is all the list is for. | ||
| 138 | + (if (> (count line) 60) | ||
| 139 | + (str (subs line 0 59) "…") | ||
| 137 | line))) | 140 | line))) |
| 138 | 141 | ||
| 142 | +;; What everything in the chats pane keeps clear of its right edge. | ||
| 143 | +;; | ||
| 144 | +;; It is one number because it is one edge: the scrollbar rides it, and the | ||
| 145 | +;; cards in the list have to stop short of the bar rather than be drawn under | ||
| 146 | +;; it — but the Join box above the list is outside the scroll and has no bar | ||
| 147 | +;; beside it, so left to itself it ran on past the cards and the pane had two | ||
| 148 | +;; right edges half a finger apart. Whatever the gap is, both of them take it. | ||
| 149 | +(def ^:private list-gutter 16) | ||
| 150 | + | ||
| 139 | (defn conversation-row [buffer] | 151 | (defn conversation-row [buffer] |
| 140 | (let [name (:name buffer) | 152 | (let [name (:name buffer) |
| 141 | ;; Defaulted rather than assumed. Everything that builds a buffer | 153 | ;; Defaulted rather than assumed. Everything that builds a buffer |
| @@ -143,44 +155,59 @@ | |||
| 143 | ;; the atom, and a half-made room reaching here should be a row that | 155 | ;; the atom, and a half-made room reaching here should be a row that |
| 144 | ;; reads as quiet rather than the frame that killed the client. | 156 | ;; reads as quiet rather than the frame that killed the client. |
| 145 | unread (:unread buffer 0)] | 157 | unread (:unread buffer 0)] |
| 146 | - [:card {:key name} | 158 | + ;; The card in a wrapper that holds it off the right edge, where the |
| 147 | - ;; The name gets the line, and membership and unread count the one under | 159 | + ;; list's scrollbar rides — `message-row` keeps its actions clear of the |
| 148 | - ;; it. All three on one line is what the list pane has no room for: the | 160 | + ;; same edge for the same reason, and without it the cards are drawn |
| 149 | - ;; name takes what it likes, the badges are left the remainder, and a | 161 | + ;; under the bar rather than beside it. The wrapper and not the card |
| 150 | - ;; long name squeezes them into a status reading "joine/d" and a count | 162 | + ;; itself: the window backend pads a card by a fixed 12 and never reads a |
| 151 | - ;; split down two lines — while the card, sized to a row that no longer | 163 | + ;; margin off one, so the room has to be taken outside it. |
| 152 | - ;; fits, sticks out past the cards above and below it. | 164 | + [:vbox {:key name :margin-right list-gutter} |
| 153 | - ;; | 165 | + [:card {} |
| 154 | - ;; In a terminal the Open button rides up onto the name's line. A row of | 166 | + ;; The name gets the line, and membership and unread count the one under |
| 155 | - ;; chrome there is one cell, so a button on a line of its own costs the | 167 | + ;; it. All three on one line is what the list pane has no room for: the |
| 156 | - ;; card a whole row — four of them and the list has spent a screenful on | 168 | + ;; name takes what it likes, the badges are left the remainder, and a |
| 157 | - ;; buttons. A window's row is 34 points and its button is a lozenge with | 169 | + ;; long name squeezes them into a status reading "joine/d" and a count |
| 158 | - ;; air around it, which is why it keeps its own line there. | 170 | + ;; split down two lines — while the card, sized to a row that no longer |
| 159 | - (if @terminal? | 171 | + ;; fits, sticks out past the cards above and below it. |
| 172 | + ;; | ||
| 173 | + ;; In a terminal the Open button rides up onto the name's line. A row of | ||
| 174 | + ;; chrome there is one cell, so a button on a line of its own costs the | ||
| 175 | + ;; card a whole row — four of them and the list has spent a screenful on | ||
| 176 | + ;; buttons. A window's row is 34 points and its button is a lozenge with | ||
| 177 | + ;; air around it, which is why it keeps its own line there. | ||
| 178 | + (if @terminal? | ||
| 160 | [:hbox {:spacing 8 :wrap false} | 179 | [:hbox {:spacing 8 :wrap false} |
| 161 | [:title-2 {:label name}] | 180 | [:title-2 {:label name}] |
| 162 | [open-button name] | 181 | [open-button name] |
| 163 | [close-button name]] | 182 | [close-button name]] |
| 164 | [:title-2 {:label name}]) | 183 | [:title-2 {:label name}]) |
| 165 | - ;; `:wrap false` on the badges: they sit beside each other or not at all. | 184 | + ;; The badge line, and only when there is a badge: every row carrying a |
| 166 | - [:hbox {:spacing 12 :wrap false} | 185 | + ;; green "joined" was a row spending a third of its height saying the |
| 167 | - ;; A DM has no membership to report — there is nothing to be in — so the | 186 | + ;; ordinary thing, and a list where every card says the same word is a |
| 168 | - ;; badge says what the buffer is instead of answering a question nobody | 187 | + ;; list you read by skipping. So membership is reported when it is news — |
| 169 | - ;; asked of it. | 188 | + ;; a room in the list we are not in — and a DM says nothing at all, since |
| 170 | - [:status {:label (cond (s/dm? name) "direct message" | 189 | + ;; there is no membership to have and the name already says what it is. |
| 171 | - (:joined? buffer) "joined" | 190 | + ;; |
| 172 | - :else "not joined") | 191 | + ;; In a wrapper of its own, because the line comes and goes with the |
| 173 | - :live (boolean (:joined? buffer))}] | 192 | + ;; unread count and the reconciler numbers a card's children by position. |
| 174 | - ;; A mention is not more unread, it is different unread: the dot says | 193 | + ;; `:wrap false` on the badges: they sit beside each other or not at all. |
| 175 | - ;; how much there is and the name says it was aimed at you. Both or | 194 | + [:vbox {:key :badges} |
| 176 | - ;; neither — a room with your name in it always has a line to count. | 195 | + (let [away? (and (not (s/dm? name)) (not (:joined? buffer)))] |
| 177 | - (when (pos? unread) | 196 | + (when (or away? (pos? unread)) |
| 178 | - [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])] | 197 | + [:hbox {:spacing 12 :wrap false} |
| 179 | - [:dim-label {:label (preview-line (s/last-preview buffer))}] | 198 | + (when away? |
| 180 | - (when-not @terminal? | 199 | + [:status {:label "not joined" :live false}]) |
| 181 | - [:hbox {:spacing 8 :wrap false} | 200 | + ;; A mention is not more unread, it is different unread: the dot |
| 182 | - [open-button name] | 201 | + ;; says how much there is and the name says it was aimed at you. |
| 183 | - [close-button name]])])) | 202 | + ;; Both or neither — a room with your name in it always has a line |
| 203 | + ;; to count. | ||
| 204 | + (when (pos? unread) | ||
| 205 | + [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]))] | ||
| 206 | + [:dim-label {:label (preview-line (s/last-preview buffer))}] | ||
| 207 | + (when-not @terminal? | ||
| 208 | + [:hbox {:spacing 8 :wrap false} | ||
| 209 | + [open-button name] | ||
| 210 | + [close-button name]])]])) | ||
| 184 | 211 | ||
| 185 | (def ^:private sidebar-width 320) | 212 | (def ^:private sidebar-width 320) |
| 186 | 213 | ||
| @@ -264,11 +291,15 @@ | |||
| 264 | ;; under it. Every screen that pins something to the bottom says this on | 291 | ;; under it. Every screen that pins something to the bottom says this on |
| 265 | ;; its own root — the split view already said it, one wrapper further out. | 292 | ;; its own root — the split view already said it, one wrapper further out. |
| 266 | [:vbox {:spacing 8 :margin 12 :fill-height true} | 293 | [:vbox {:spacing 8 :margin 12 :fill-height true} |
| 267 | - [:vbox {:key :head :spacing 8} | 294 | + [:vbox {:key :head :spacing 8 :margin-right list-gutter} |
| 268 | ;; Your nick in the title, where "Chats" alone said nothing you did not | 295 | ;; Your nick in the title, where "Chats" alone said nothing you did not |
| 269 | - ;; already know. It is what every channel calls you and what your own | 296 | + ;; already know — and said plainly, as a sign-in rather than as a name |
| 270 | - ;; lines are signed with, and the only other place it showed was Settings. | 297 | + ;; the list is somehow held in. It is what every channel calls you and |
| 271 | - [:title {:label (if (s/connected?) (str "Chats as " @s/form-nick) "Chats")}] | 298 | + ;; what your own lines are signed with, and the only other place it |
| 299 | + ;; showed was Settings. | ||
| 300 | + [:title {:label (if (s/connected?) | ||
| 301 | + (str "Logged in as " @s/form-nick) | ||
| 302 | + "Chats")}] | ||
| 272 | [error-note] | 303 | [error-note] |
| 273 | ;; Join and search are the same shape — a channel box with a button | 304 | ;; Join and search are the same shape — a channel box with a button |
| 274 | ;; beside it — so they read as one control block rather than a titled | 305 | ;; beside it — so they read as one control block rather than a titled |
| @@ -322,7 +353,8 @@ | |||
| 322 | ;; the slot the reader clicked while the row that moved into it is | 353 | ;; the slot the reader clicked while the row that moved into it is |
| 323 | ;; someone else's conversation. | 354 | ;; someone else's conversation. |
| 324 | (for [b buffers] ^{:key (:name b)} [conversation-row b]) | 355 | (for [b buffers] ^{:key (:name b)} [conversation-row b]) |
| 325 | - [:card {} [:dim-label {:label "No conversations yet — join a channel."}]])]] | 356 | + [:vbox {:margin-right list-gutter} |
| 357 | + [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]])]] | ||
| 326 | [:vbox {:key :foot :spacing 8} | 358 | [:vbox {:key :foot :spacing 8} |
| 327 | [:separator {}] | 359 | [:separator {}] |
| 328 | [tab-bar]]])) | 360 | [tab-bar]]])) |
| @@ -521,13 +553,7 @@ | |||
| 521 | than a button with the emoji as its label — the chip draws the glyph from the | 553 | than a button with the emoji as its label — the chip draws the glyph from the |
| 522 | Twemoji pack, in colour, where a label gets whatever the text font has." | 554 | Twemoji pack, in colour, where a label gets whatever the text font has." |
| 523 | [channel m] | 555 | [channel m] |
| 524 | - (let [reactions (:reactions m) | 556 | + (let [reactions (:reactions m)] |
| 525 | - ;; Which of this message's pills the pointer is on, if any. Asked once | ||
| 526 | - ;; per message, so moving between pills wakes the rows involved and not | ||
| 527 | - ;; every row that has a reaction on it. | ||
| 528 | - hovered @(derived [:pill-hover (:id m)] | ||
| 529 | - #(let [h @s/reaction-hover] | ||
| 530 | - (when (= (:id m) (:id h)) (:emoji h))))] | ||
| 531 | ;; `into` and not a lazy `for` inside the vector. The pills read | 557 | ;; `into` and not a lazy `for` inside the vector. The pills read |
| 532 | ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being | 558 | ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being |
| 533 | ;; realised somewhere other than the render is a read the component never | 559 | ;; realised somewhere other than the render is a read the component never |
| @@ -547,13 +573,14 @@ | |||
| 547 | ;; says who put it there. On a phone the pill is a button | 573 | ;; says who put it there. On a phone the pill is a button |
| 548 | ;; and nothing more: there is no hover to answer. | 574 | ;; and nothing more: there is no hover to answer. |
| 549 | (platform/desktop?) | 575 | (platform/desktop?) |
| 576 | + ;; Nothing is hung under the pill any more: who is on a | ||
| 577 | + ;; reaction is shown the way everything else the pointer | ||
| 578 | + ;; asks for is shown now — the dialog at the root of the | ||
| 579 | + ;; tree, which `reactor-dialog` builds out of | ||
| 580 | + ;; `reaction-hover`. The pill's only job is to say where | ||
| 581 | + ;; the pointer is. | ||
| 550 | (assoc :on-hover #(s/hover-reaction! (:id m) emoji) | 582 | (assoc :on-hover #(s/hover-reaction! (:id m) emoji) |
| 551 | - :on-unhover #(s/unhover-reaction! (:id m) emoji))) | 583 | + :on-unhover #(s/unhover-reaction! (:id m) emoji)))])))) |
| 552 | - ;; Only the hovered pill carries a card: the panel is painted from | ||
| 553 | - ;; whatever children the node has, and a channel's worth of unseen | ||
| 554 | - ;; lists is a tree nobody looks at. | ||
| 555 | - (when (and (platform/desktop?) (= emoji hovered)) | ||
| 556 | - [reactor-card emoji (get reactions emoji)])])))) | ||
| 557 | 584 | ||
| 558 | (def ^:private picker-columns | 585 | (def ^:private picker-columns |
| 559 | "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a | 586 | "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a |
| @@ -620,48 +647,6 @@ | |||
| 620 | (when (pos? over) | 647 | (when (pos? over) |
| 621 | [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]])) | 648 | [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]])) |
| 622 | 649 | ||
| 623 | -(defn hover-card | ||
| 624 | - "Who someone is, in the few lines that fit beside a pointer. | ||
| 625 | - | ||
| 626 | - The profile screen's opening, without the ways onward: a picture, the name | ||
| 627 | - they go by, their handle, and the first of their bio. What it cannot say yet | ||
| 628 | - it says plainly — a fetch in flight, a guest with no identity to look up — | ||
| 629 | - because a card that is blank while it waits reads as a card with nothing on | ||
| 630 | - it." | ||
| 631 | - [nick actor] | ||
| 632 | - (let [_ @profile/tick | ||
| 633 | - _ @s/media-tick | ||
| 634 | - pr (profile/entry actor) | ||
| 635 | - ready? (= :ready (:status pr)) | ||
| 636 | - display (or (:display-name pr) nick)] | ||
| 637 | - [:vbox {:spacing 6} | ||
| 638 | - [:hbox {:spacing 10} | ||
| 639 | - [:avatar {:label nick | ||
| 640 | - :src (or (avatars/path-when-ready actor) "") | ||
| 641 | - :size 48}] | ||
| 642 | - [:vbox {:spacing 2} | ||
| 643 | - [:title-2 {:label display}] | ||
| 644 | - [:vbox {:key :nick} | ||
| 645 | - (when (not= display nick) [:dim-label {:label nick}])] | ||
| 646 | - [:vbox {:key :handle} | ||
| 647 | - (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 648 | - [:label {:label (str "@" h)}])]]] | ||
| 649 | - ;; Shorter than the screen's: this is a glance, not a read, and a bio | ||
| 650 | - ;; that fills the window beside a pointer is in the way of the chat. | ||
| 651 | - [:vbox {:key :bio :spacing 2} | ||
| 652 | - (when-let [bio (and ready? (:description pr))] | ||
| 653 | - (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 200)))] | ||
| 654 | - [:label {:key i :label line}]))] | ||
| 655 | - [:vbox {:key :stats} | ||
| 656 | - (when-let [line (and ready? (profile/stats-line pr))] | ||
| 657 | - [:dim-label {:label line}])] | ||
| 658 | - [:vbox {:key :status} | ||
| 659 | - (cond | ||
| 660 | - (nil? actor) [:dim-label {:label "Guest — no Bluesky identity"}] | ||
| 661 | - (= :loading (:status pr)) [:dim-label {:label "Loading…"}] | ||
| 662 | - (= :failed (:status pr)) [:dim-label {:label "No Bluesky profile found"}])] | ||
| 663 | - [:dim-label {:label "Click for the full profile"}]])) | ||
| 664 | - | ||
| 665 | (defn- preview-height | 650 | (defn- preview-height |
| 666 | "How tall a picture in the conversation may be. | 651 | "How tall a picture in the conversation may be. |
| 667 | 652 | ||
| @@ -744,21 +729,17 @@ | |||
| 744 | ;; the whole message rather than off its heading — `message-row` has it. | 729 | ;; the whole message rather than off its heading — `message-row` has it. |
| 745 | (when-not @terminal? | 730 | (when-not @terminal? |
| 746 | (let [src @(avatar-path (:actor m))] | 731 | (let [src @(avatar-path (:actor m))] |
| 732 | + ;; One profile, two gestures, and no card hung under the face: the | ||
| 733 | + ;; pointer opens the dialog and the press pins it. What makes that | ||
| 734 | + ;; work is the dialog being non-modal while the pointer is what is | ||
| 735 | + ;; holding it open — see `profile-dialog`. | ||
| 747 | [:avatar (cond-> {:label (:from m) | 736 | [:avatar (cond-> {:label (:from m) |
| 748 | :src (or src "") | 737 | :src (or src "") |
| 749 | :size face-size | 738 | :size face-size |
| 750 | :on-click #(profile/open! (:from m) (:actor m))} | 739 | :on-click #(profile/open! (:from m) (:actor m))} |
| 751 | (platform/desktop?) | 740 | (platform/desktop?) |
| 752 | - (assoc :on-hover #(do (reset! hovered-face (:id m)) | 741 | + (assoc :on-hover #(profile/hover! (:from m) (:actor m)) |
| 753 | - (profile/hover! (:from m) (:actor m))) | 742 | + :on-unhover #(profile/unhover! (:from m))))])) |
| 754 | - :on-unhover #(profile/unhover! (:from m)))) | ||
| 755 | - ;; Only the hovered face carries one: a card is painted when its | ||
| 756 | - ;; node has children, and the pointer is on one face at a time. | ||
| 757 | - (when (and (platform/desktop?) | ||
| 758 | - @(derived [:hovering (:id m) (:from m)] | ||
| 759 | - #(and (= (:id m) @hovered-face) | ||
| 760 | - (= (:from m) (:nick @profile/hovering))))) | ||
| 761 | - [hover-card (:from m) (:actor m)])])) | ||
| 762 | ;; The name carries the row, so it is set at body size in the plain | 743 | ;; The name carries the row, so it is set at body size in the plain |
| 763 | ;; text colour: dimmed caption made the one thing you scan a column | 744 | ;; text colour: dimmed caption made the one thing you scan a column |
| 764 | ;; for the faintest thing on it. | 745 | ;; for the faintest thing on it. |
| @@ -920,6 +901,120 @@ | |||
| 920 | (range (count messages)) | 901 | (range (count messages)) |
| 921 | messages)) | 902 | messages)) |
| 922 | 903 | ||
| 904 | +(defn- profile-dialog | ||
| 905 | + "Who someone is, as libcosmic's own dialog: centred over the window, with | ||
| 906 | + what you were reading dimmed behind it rather than replaced. | ||
| 907 | + | ||
| 908 | + The window can do this and the terminal cannot, which is the whole reason | ||
| 909 | + there are two of these. `profile-screen` below is the terminal's, and says | ||
| 910 | + why it is a screen. | ||
| 911 | + | ||
| 912 | + Both buttons are always here, the Bluesky one insensitive until there is a | ||
| 913 | + profile to open: a dialog whose second button appears a moment after it | ||
| 914 | + opens is a dialog that moves under the pointer, and the fetch lands whenever | ||
| 915 | + it lands. | ||
| 916 | + | ||
| 917 | + Who it is about comes from either of two places, and that — with the | ||
| 918 | + modality below — is the whole of what hovering and pressing a face do | ||
| 919 | + differently. Resting on one sets `hovering`, which the pointer takes away | ||
| 920 | + again when it leaves; pressing one sets `viewing`, which nothing takes away | ||
| 921 | + but Close. `viewing` is read first, so a pinned profile is not swapped out | ||
| 922 | + from under the reader by a face the pointer crosses on the way to it. | ||
| 923 | + | ||
| 924 | + And a dialog the pointer is holding open is not modal. A modal one makes | ||
| 925 | + the window underneath it deaf — libcosmic wraps the app in a popover that | ||
| 926 | + hands its content an `Unavailable` cursor while a popup is up — so the face | ||
| 927 | + that opened it never hears the pointer leave, and what a hover opened could | ||
| 928 | + never close itself. Non-modal, the face keeps hearing, and moving away shuts | ||
| 929 | + it. A pinned one is modal, which is what being pinned means: it is the thing | ||
| 930 | + on the screen until it is dismissed." | ||
| 931 | + [] | ||
| 932 | + (let [pinned? (some? @profile/viewing) | ||
| 933 | + {:keys [nick actor]} (or @profile/viewing @profile/hovering) | ||
| 934 | + _ @profile/tick | ||
| 935 | + _ @s/media-tick | ||
| 936 | + pr (profile/entry actor) | ||
| 937 | + ready? (= :ready (:status pr)) | ||
| 938 | + display (or (:display-name pr) nick) | ||
| 939 | + url (when ready? (profile/web-url pr))] | ||
| 940 | + [:dialog {:label display :max-width 520 :modal pinned? | ||
| 941 | + :on-hover profile/enter-dialog! | ||
| 942 | + :on-unhover profile/leave-dialog!} | ||
| 943 | + ;; The body is the screen's card without its heading: the dialog's own | ||
| 944 | + ;; title is the name now, so repeating it under the picture is a line | ||
| 945 | + ;; that says nothing. | ||
| 946 | + [:vbox {:key :body :spacing 8} | ||
| 947 | + [:hbox {:spacing 12} | ||
| 948 | + [:avatar {:label nick | ||
| 949 | + :src (or (avatars/path-when-ready actor) "") | ||
| 950 | + :size 72}] | ||
| 951 | + [:vbox {:spacing 2} | ||
| 952 | + [:vbox {:key :nick} | ||
| 953 | + (when (not= display nick) [:dim-label {:label nick}])] | ||
| 954 | + [:vbox {:key :handle} | ||
| 955 | + (when-let [h (and ready? (not-empty (or (:handle pr) "")))] | ||
| 956 | + [:label {:label (str "@" h)}])] | ||
| 957 | + [:vbox {:key :did} | ||
| 958 | + (when-let [did (:did pr)] [:dim-label {:label did}])]]] | ||
| 959 | + [:vbox {:key :bio :spacing 2} | ||
| 960 | + (when-let [bio (and ready? (:description pr))] | ||
| 961 | + (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))] | ||
| 962 | + [:label {:key i :label line}]))] | ||
| 963 | + [:vbox {:key :stats} | ||
| 964 | + (when-let [line (and ready? (profile/stats-line pr))] | ||
| 965 | + [:dim-label {:label line}])] | ||
| 966 | + [:vbox {:key :status :spacing 4} | ||
| 967 | + (cond | ||
| 968 | + (nil? actor) | ||
| 969 | + [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}] | ||
| 970 | + | ||
| 971 | + (= :loading (:status pr)) | ||
| 972 | + [:hbox {:spacing 8} | ||
| 973 | + [:spinner {}] | ||
| 974 | + [:dim-label {:label "Loading Bluesky profile…"}]] | ||
| 975 | + | ||
| 976 | + (= :failed (:status pr)) | ||
| 977 | + [:dim-label {:label "No Bluesky profile found"}])] | ||
| 978 | + ;; What the pointer is holding open says how to stop having to hold it. | ||
| 979 | + ;; Pinned, that line has nothing left to tell anyone. | ||
| 980 | + [:vbox {:key :hint} | ||
| 981 | + (when-not pinned? | ||
| 982 | + [:dim-label {:label "Click the face to keep this open"}])]] | ||
| 983 | + ;; `slot` is where libcosmic puts a button: the two actions go to the | ||
| 984 | + ;; foot of the dialog, and anything else here would be another control | ||
| 985 | + ;; stacked in the body. | ||
| 986 | + ;; | ||
| 987 | + ;; Both are here whether the profile is pinned or only hovered. They were | ||
| 988 | + ;; hidden while hovering, back when a hover could not be walked into: the | ||
| 989 | + ;; dialog reports its own pointer now, so moving towards a button in it | ||
| 990 | + ;; keeps it open instead of closing it, and a button you can reach is a | ||
| 991 | + ;; button worth drawing. | ||
| 992 | + [:button {:key :close :slot "primary" | ||
| 993 | + :label (if pinned? "Close" "Dismiss") | ||
| 994 | + :on-click profile/dismiss!}] | ||
| 995 | + [:button {:key :web :slot "secondary" :label "Bluesky ↗" | ||
| 996 | + :sensitive (boolean url) | ||
| 997 | + :on-click #(when url (platform/open-url! url))}]])) | ||
| 998 | + | ||
| 999 | +(defn- reactor-dialog | ||
| 1000 | + "Who is on the reaction the pointer is resting on. | ||
| 1001 | + | ||
| 1002 | + The same dialog a face gets, for the same reason: two hover cards in two | ||
| 1003 | + shapes was two things to look at and two things to keep working. Non-modal | ||
| 1004 | + and no buttons, because the pointer is what is holding it open — a modal one | ||
| 1005 | + would go deaf to the pill leaving and never close, and a button in it could | ||
| 1006 | + never be reached. | ||
| 1007 | + | ||
| 1008 | + The message is looked up rather than carried: a pill says which message and | ||
| 1009 | + which emoji it is, and the room it is in is the one being read — a pill in | ||
| 1010 | + any other room is not under a pointer." | ||
| 1011 | + [] | ||
| 1012 | + (let [{:keys [id emoji]} @s/reaction-hover | ||
| 1013 | + nicks (get (:reactions (s/message-by-id @s/current id)) emoji)] | ||
| 1014 | + (when (seq nicks) | ||
| 1015 | + [:dialog {:label "Reactions" :max-width 320 :modal false} | ||
| 1016 | + [reactor-card emoji nicks]]))) | ||
| 1017 | + | ||
| 923 | (defn profile-screen | 1018 | (defn profile-screen |
| 924 | "Who someone is: sleek's peer profile modal, as a screen. | 1019 | "Who someone is: sleek's peer profile modal, as a screen. |
| 925 | 1020 | ||
| @@ -1196,6 +1291,19 @@ | |||
| 1196 | ;; there is one. Those rows do move the compose bar, and should: each appears | 1291 | ;; there is one. Those rows do move the compose bar, and should: each appears |
| 1197 | ;; because the reader just asked for something — unlike the jump button, which | 1292 | ;; because the reader just asked for something — unlike the jump button, which |
| 1198 | ;; appears on its own and must not shift what is under it. | 1293 | ;; appears on its own and must not shift what is under it. |
| 1294 | +;; What the overview strip costs the conversation above it, in points: a row | ||
| 1295 | +;; for its heading, a row a line, and the separator and gaps around them. The | ||
| 1296 | +;; backlog's `:reserve` takes this too, so the strip is paid for out of the | ||
| 1297 | +;; conversation rather than pushing the compose bar off the bottom — which is | ||
| 1298 | +;; what every other row under the message list has had to say for itself. | ||
| 1299 | +;; | ||
| 1300 | +;; Counted from the lines there actually are, not from the ceiling: the strip | ||
| 1301 | +;; holds a few per room, so its height is the client's rooms, and reserving | ||
| 1302 | +;; for a full one in a client with two channels in it would take a third of | ||
| 1303 | +;; the window off the conversation to leave it empty. | ||
| 1304 | +(defn- overview-height [] | ||
| 1305 | + (* (chrome-scale) (+ 24 (* 20 (count (s/recent-everywhere))) 16))) | ||
| 1306 | + | ||
| 1199 | (defn- below-messages [] | 1307 | (defn- below-messages [] |
| 1200 | ;; 140 is that, counted: the gap under the row of columns, the jump button's | 1308 | ;; 140 is that, counted: the gap under the row of columns, the jump button's |
| 1201 | ;; 34pt row, the separator and the two empty wrappers with a gap apiece, then | 1309 | ;; 34pt row, the separator and the two empty wrappers with a gap apiece, then |
| @@ -1218,7 +1326,12 @@ | |||
| 1218 | ;; box rounded up: the reserve has to be at least what the field took, | 1326 | ;; box rounded up: the reserve has to be at least what the field took, |
| 1219 | ;; because a point too few does not crop the list, it slides the | 1327 | ;; because a point too few does not crop the list, it slides the |
| 1220 | ;; compose bar off the bottom of the window. | 1328 | ;; compose bar off the bottom of the window. |
| 1221 | - (* 20 (dec @draft-rows)))) | 1329 | + (* 20 (dec @draft-rows)) |
| 1330 | + ;; And the overview strip, when it is up. Reserved here rather than | ||
| 1331 | + ;; anywhere else because this is the number the backlog is laid out | ||
| 1332 | + ;; against: without it the strip is drawn past the bottom of the window | ||
| 1333 | + ;; and takes the compose bar with it. | ||
| 1334 | + (if @s/overview? (overview-height) 0))) | ||
| 1222 | 1335 | ||
| 1223 | (defn- messages-width | 1336 | (defn- messages-width |
| 1224 | "How wide the message list may be with the people panel beside it. | 1337 | "How wide the message list may be with the people panel beside it. |
| @@ -1229,7 +1342,9 @@ | |||
| 1229 | first `sidebar-width` of the window; the rest is the margins and the gap | 1342 | first `sidebar-width` of the window; the rest is the margins and the gap |
| 1230 | between the two columns." | 1343 | between the two columns." |
| 1231 | [] | 1344 | [] |
| 1232 | - (let [pane (- @s/window-width (if (s/wide?) sidebar-width 0))] | 1345 | + (let [pane (- @s/window-width (if (and (s/wide?) (not @s/hide-chat-list?)) |
| 1346 | + sidebar-width | ||
| 1347 | + 0))] | ||
| 1233 | (max 240 (- pane users-width 8 28)))) | 1348 | (max 240 (- pane users-width 8 28)))) |
| 1234 | 1349 | ||
| 1235 | (defn- member-row [{:keys [nick prefix]}] | 1350 | (defn- member-row [{:keys [nick prefix]}] |
| @@ -1305,6 +1420,44 @@ | |||
| 1305 | [:button {:label "Accept policy" :kind :primary | 1420 | [:button {:label "Accept policy" :kind :primary |
| 1306 | :on-click #(s/accept-policy! name)}]])) | 1421 | :on-click #(s/accept-policy! name)}]])) |
| 1307 | 1422 | ||
| 1423 | +(defn- overview-row | ||
| 1424 | + "One line from somewhere else: which room, who, and what — on one row. | ||
| 1425 | + | ||
| 1426 | + The room leads, because that is the whole question the strip answers. The | ||
| 1427 | + name is a button for the same reason it is one in the people panel: the | ||
| 1428 | + room is somewhere to go, and a line you want more of is a line you want the | ||
| 1429 | + conversation behind it." | ||
| 1430 | + [i m] | ||
| 1431 | + [:hbox {:key i :spacing 8 :wrap false} | ||
| 1432 | + [:button {:label (:channel m) | ||
| 1433 | + :on-click #(s/open-channel! (:channel m))}] | ||
| 1434 | + [:vbox {:key :said} | ||
| 1435 | + [:dim-label {:label (str (when-let [at (:at m)] (str (clock/clock-time at) " ")) | ||
| 1436 | + (:from m) ": " | ||
| 1437 | + (preview-line (:text m)))}]]]) | ||
| 1438 | + | ||
| 1439 | +(defn- overview-pane | ||
| 1440 | + "Every room's last few lines in one list, oldest at the top. | ||
| 1441 | + | ||
| 1442 | + A strip under the conversation rather than a screen of its own: the question | ||
| 1443 | + it answers — is anything happening anywhere else — is one you ask while | ||
| 1444 | + reading something, and an answer you have to leave the room for is one you | ||
| 1445 | + stop asking for. | ||
| 1446 | + | ||
| 1447 | + It does not scroll. A fixed handful of lines is what makes the room it takes | ||
| 1448 | + from the backlog a number this file can reserve; a scroll here would be a | ||
| 1449 | + second thing on the screen sizing itself against the window, fighting the | ||
| 1450 | + backlog above it for the same points." | ||
| 1451 | + [] | ||
| 1452 | + (let [lines (s/recent-everywhere)] | ||
| 1453 | + [:vbox {:key :overview :spacing 4 :margin-top 4} | ||
| 1454 | + [:separator {}] | ||
| 1455 | + [:dim-label {:label "Everywhere else"}] | ||
| 1456 | + (if (seq lines) | ||
| 1457 | + (for [[i m] (map-indexed vector lines)] | ||
| 1458 | + ^{:key (str (:channel m) "-" (or (:id m) i))} [overview-row i m]) | ||
| 1459 | + [:dim-label {:label "Nothing has been said in any room yet."}])])) | ||
| 1460 | + | ||
| 1308 | (defn chat-screen [] | 1461 | (defn chat-screen [] |
| 1309 | (let [name @s/current | 1462 | (let [name @s/current |
| 1310 | buffer (get @s/channels name) | 1463 | buffer (get @s/channels name) |
| @@ -1333,6 +1486,14 @@ | |||
| 1333 | [:vbox {:key :back} | 1486 | [:vbox {:key :back} |
| 1334 | (when-not (s/wide?) | 1487 | (when-not (s/wide?) |
| 1335 | [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] | 1488 | [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] |
| 1489 | + ;; And the same room's other way of appearing, on a window wide enough | ||
| 1490 | + ;; to have been showing both: the list folds away and the conversation | ||
| 1491 | + ;; takes the whole row. Its own wrapper, since it is only offered where | ||
| 1492 | + ;; there are two panes to choose between. | ||
| 1493 | + [:vbox {:key :fold} | ||
| 1494 | + (when (s/wide?) | ||
| 1495 | + [:button {:label (if @s/hide-chat-list? "☰ Chats" "☰") | ||
| 1496 | + :on-click s/toggle-chat-list!}])] | ||
| 1336 | [:title {:label (or name "Chat")}] | 1497 | [:title {:label (or name "Chat")}] |
| 1337 | ;; Same wrapper trick: only in a channel, and only when there is no call | 1498 | ;; Same wrapper trick: only in a channel, and only when there is no call |
| 1338 | ;; to join already — the bar below offers Join in that case, and two ways | 1499 | ;; to join already — the bar below offers Join in that case, and two ways |
| @@ -1351,7 +1512,14 @@ | |||
| 1351 | (when (and name (str/starts-with? name "#")) | 1512 | (when (and name (str/starts-with? name "#")) |
| 1352 | [:button {:label (str "People " (s/member-count name)) | 1513 | [:button {:label (str "People " (s/member-count name)) |
| 1353 | :kind (when @s/show-users? :primary) | 1514 | :kind (when @s/show-users? :primary) |
| 1354 | - :on-click s/toggle-users!}])]] | 1515 | + :on-click s/toggle-users!}])] |
| 1516 | + ;; The overview's switch. Not in a wrapper conditioned on anything: it | ||
| 1517 | + ;; is about every room rather than this one, so it is offered in a DM | ||
| 1518 | + ;; and in a channel alike. | ||
| 1519 | + [:button {:key :overview-toggle | ||
| 1520 | + :label "Overview" | ||
| 1521 | + :kind (when @s/overview? :primary) | ||
| 1522 | + :on-click s/toggle-overview!}]] | ||
| 1355 | [error-note] | 1523 | [error-note] |
| 1356 | ;; In a wrapper of its own, for the reconciler's sake: it comes and goes. | 1524 | ;; In a wrapper of its own, for the reconciler's sake: it comes and goes. |
| 1357 | [:vbox {:key :policy} | 1525 | [:vbox {:key :policy} |
| @@ -1393,6 +1561,11 @@ | |||
| 1393 | ;; compose bar sits in whether or not the panel was showing. | 1561 | ;; compose bar sits in whether or not the panel was showing. |
| 1394 | [:vbox {:key :people-pane} | 1562 | [:vbox {:key :people-pane} |
| 1395 | (when show-users? [users-panel name])]] | 1563 | (when show-users? [users-panel name])]] |
| 1564 | + ;; Under the conversation and above the jump button: it is a second list | ||
| 1565 | + ;; of messages, so it belongs with the first rather than down among the | ||
| 1566 | + ;; compose bar's rows. In a wrapper of its own, since it comes and goes. | ||
| 1567 | + [:vbox {:key :overview-pane} | ||
| 1568 | + (when @s/overview? [overview-pane])] | ||
| 1396 | ;; Only while it is needed, and directly above the compose bar: the way | 1569 | ;; Only while it is needed, and directly above the compose bar: the way |
| 1397 | ;; back to the present belongs next to the thing that puts you there. | 1570 | ;; back to the present belongs next to the thing that puts you there. |
| 1398 | ;; The row keeps its height whether or not the button is in it, so the | 1571 | ;; The row keeps its height whether or not the button is in it, so the |
| @@ -1509,7 +1682,16 @@ | |||
| 1509 | [] | 1682 | [] |
| 1510 | [:vbox {:spacing 8 :margin 12} | 1683 | [:vbox {:spacing 8 :margin 12} |
| 1511 | [:title {:label "frq"}] | 1684 | [:title {:label "frq"}] |
| 1512 | - [:card {} [:dim-label {:label "Pick a conversation on the left."}]]]) | 1685 | + ;; With the list folded away there is nothing on the left to pick from, and |
| 1686 | + ;; no conversation here carrying the switch that would bring it back — so | ||
| 1687 | + ;; this pane carries it instead. Without that the reader who hid the list | ||
| 1688 | + ;; and then closed the last room has put the app away, not the list. | ||
| 1689 | + (if @s/hide-chat-list? | ||
| 1690 | + [:card {} | ||
| 1691 | + [:dim-label {:label "The chats list is hidden."}] | ||
| 1692 | + [:button {:label "☰ Chats" :kind :primary | ||
| 1693 | + :on-click s/toggle-chat-list!}]] | ||
| 1694 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]])]) | ||
| 1513 | 1695 | ||
| 1514 | (defn split-screen | 1696 | (defn split-screen |
| 1515 | "The chats list and the conversation side by side, for a window wide enough | 1697 | "The chats list and the conversation side by side, for a window wide enough |
| @@ -1544,9 +1726,18 @@ | |||
| 1544 | ;; axis alone: the height without the share. The conversation keeps | 1726 | ;; axis alone: the height without the share. The conversation keeps |
| 1545 | ;; :fill-height and so keeps the slack, which is what the note above says | 1727 | ;; :fill-height and so keeps the slack, which is what the note above says |
| 1546 | ;; it takes. | 1728 | ;; it takes. |
| 1547 | - [:vbox {:key :list :width-request sidebar-width :fill-height true | 1729 | + ;; The list pane, when the reader has not put it away. Hidden, the wrapper |
| 1548 | - :expand :cross} | 1730 | + ;; stays and empties: a child that vanished would renumber the row for the |
| 1549 | - [chats-screen]] | 1731 | + ;; reconciler and take the conversation's scroll position with it every |
| 1732 | + ;; time the list was toggled — the same trick the people panel plays. | ||
| 1733 | + ;; Empty, it asks for nothing: no width and no `:fill-height`, because a | ||
| 1734 | + ;; column that fills the height is also a column that is there, and a | ||
| 1735 | + ;; 320-point hole where the list was is not hiding it. | ||
| 1736 | + (if @s/hide-chat-list? | ||
| 1737 | + [:vbox {:key :list}] | ||
| 1738 | + [:vbox {:key :list :width-request sidebar-width :fill-height true | ||
| 1739 | + :expand :cross} | ||
| 1740 | + [chats-screen]]) | ||
| 1550 | [:vbox {:key :chat :fill-height true} | 1741 | [:vbox {:key :chat :fill-height true} |
| 1551 | (if @s/current | 1742 | (if @s/current |
| 1552 | [chat-screen] | 1743 | [chat-screen] |
| @@ -1638,9 +1829,30 @@ | |||
| 1638 | ;; from the conversation you were in a moment ago. A key that changes with | 1829 | ;; from the conversation you were in a moment ago. A key that changes with |
| 1639 | ;; the screen makes the swap a swap: the old tree comes out whole and the new | 1830 | ;; the screen makes the swap a swap: the old tree comes out whole and the new |
| 1640 | ;; one goes in whole. | 1831 | ;; one goes in whole. |
| 1641 | - (cond | 1832 | + ;; And the dialog beside them all rather than instead of one of them. A |
| 1833 | + ;; `dialog` node is not painted where it stands — the window backend hands | ||
| 1834 | + ;; it to libcosmic, which puts it over the middle of the window with what is | ||
| 1835 | + ;; behind it dimmed — so the screen under it keeps its place in the tree, | ||
| 1836 | + ;; and its scroll position with it. The wrapper is always here and only its | ||
| 1837 | + ;; child comes and goes, for the reason every other wrapper in this file | ||
| 1838 | + ;; gives: a child that appeared and vanished would renumber the root. | ||
| 1839 | + ;; | ||
| 1840 | + ;; The terminal has no such thing, and a `dialog` tag it has not grown would | ||
| 1841 | + ;; paint its contents inline at the bottom of the screen. So there it stays | ||
| 1842 | + ;; a screen you go to and come back from, which is `profile-screen`. | ||
| 1843 | + [:vbox {:key :root :fill-height true} | ||
| 1844 | + ;; One dialog at a time, and a pinned profile outranks both pointers: it is | ||
| 1845 | + ;; the only one of the three that was asked for by a press rather than by | ||
| 1846 | + ;; where the pointer happens to be resting. | ||
| 1847 | + [:vbox {:key :dialog} | ||
| 1848 | + (when-not @terminal? | ||
| 1849 | + (cond | ||
| 1850 | + (or @profile/viewing @profile/hovering) [profile-dialog] | ||
| 1851 | + @s/reaction-hover [reactor-dialog]))] | ||
| 1852 | + (cond | ||
| 1642 | @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] | 1853 | @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]] |
| 1643 | - @profile/viewing [:vbox {:key :screen-profile} [profile-screen]] | 1854 | + (and @terminal? @profile/viewing) |
| 1855 | + [:vbox {:key :screen-profile} [profile-screen]] | ||
| 1644 | @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] | 1856 | @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]] |
| 1645 | ;; Wide enough for both, and on one of the two screens that are halves of | 1857 | ;; Wide enough for both, and on one of the two screens that are halves of |
| 1646 | ;; the same thing: the list and the conversation it opens. Discover and | 1858 | ;; the same thing: the list and the conversation it opens. Discover and |
| @@ -1653,7 +1865,7 @@ | |||
| 1653 | :chat [:vbox {:key :screen-chat} [chat-screen]] | 1865 | :chat [:vbox {:key :screen-chat} [chat-screen]] |
| 1654 | :discover [:vbox {:key :screen-discover} [discover-screen]] | 1866 | :discover [:vbox {:key :screen-discover} [discover-screen]] |
| 1655 | :settings [:vbox {:key :screen-settings} [settings-screen]] | 1867 | :settings [:vbox {:key :screen-settings} [settings-screen]] |
| 1656 | - [:vbox {:key :screen-chats} [chats-screen]]))) | 1868 | + [:vbox {:key :screen-chats} [chats-screen]]))]) |
| 1657 | 1869 | ||
| 1658 | (defn start! | 1870 | (defn start! |
| 1659 | "Everything a launch does before the loop starts, for whichever backend is | 1871 | "Everything a launch does before the loop starts, for whichever backend is |
modified
src/frq/cosmic.clj +30 -3 | @@ -33,14 +33,41 @@ | ||
| 33 | 33 | [path] |
| 34 | 34 | (cosmic/every! 3000 #(spit path (cosmic/dump-str)))) |
| 35 | 35 | |
| 36 | +(defn- open-url! | |
| 37 | + "Hand a URL to the desktop's browser. | |
| 38 | + | |
| 39 | + libcosmic has none of its own, so this is xdg-open — which is what the | |
| 40 | + desktop's answer to \"show me this page\" has always been, and what | |
| 41 | + `frq.platform/open-url!` means by a backend that has a browser to hand it | |
| 42 | + to. Detached and its output thrown away: frq is not waiting on it, and a | |
| 43 | + child whose pipes nobody reads is a child that can block on a full one. | |
| 44 | + | |
| 45 | + http and https only. Every link in a conversation reaches this from | |
| 46 | + somewhere else's message, and xdg-open takes far more than a web page — a | |
| 47 | + `file:` URL is a file manager, and a bare path is whatever is registered for | |
| 48 | + it. A scheme this client did not mean to offer is not opened at all." | |
| 49 | + [url] | |
| 50 | + (boolean | |
| 51 | + (when (re-matches #"(?i)https?://[^\s]+" (str url)) | |
| 52 | + (try | |
| 53 | + (-> (ProcessBuilder. (into-array String ["xdg-open" url])) | |
| 54 | + (.redirectOutput java.lang.ProcessBuilder$Redirect/DISCARD) | |
| 55 | + (.redirectError java.lang.ProcessBuilder$Redirect/DISCARD) | |
| 56 | + (.start)) | |
| 57 | + true | |
| 58 | + (catch Exception _ false))))) | |
| 59 | + | |
| 36 | 60 | (defn -main [& _] |
| 37 | 61 | ;; Everything this backend can do. What is missing is missing on purpose: |
| 38 | - ;; libcosmic has no `open-url!`, so the connect screen falls back to showing | |
| 39 | - ;; the URL, and no texture to push call frames into, which is the same thing | |
| 40 | - ;; `:av? false` below says from the other end. | |
| 62 | + ;; libcosmic has no texture to push call frames into, which is the same | |
| 63 | + ;; thing `:av? false` below says from the other end. It has no browser | |
| 64 | + ;; either, but the desktop it is running on does — `open-url!` above is | |
| 65 | + ;; that, and it is why the connect screen opens a browser here now rather | |
| 66 | + ;; than falling back to printing the URL for someone to copy. | |
| 41 | 67 | (platform/override! {:after! cosmic/after! |
| 42 | 68 | :every! cosmic/every! |
| 43 | 69 | :quit! cosmic/quit! |
| 70 | + :open-url! open-url! | |
| 44 | 71 | :screen-size cosmic/window-size |
| 45 | 72 | :pick-image! cosmic/pick-image! |
| 46 | 73 | :picked-image! cosmic/picked-image! |
| @@ -33,14 +33,41 @@ | |||
| 33 | [path] | 33 | [path] |
| 34 | (cosmic/every! 3000 #(spit path (cosmic/dump-str)))) | 34 | (cosmic/every! 3000 #(spit path (cosmic/dump-str)))) |
| 35 | 35 | ||
| 36 | +(defn- open-url! | ||
| 37 | + "Hand a URL to the desktop's browser. | ||
| 38 | + | ||
| 39 | + libcosmic has none of its own, so this is xdg-open — which is what the | ||
| 40 | + desktop's answer to \"show me this page\" has always been, and what | ||
| 41 | + `frq.platform/open-url!` means by a backend that has a browser to hand it | ||
| 42 | + to. Detached and its output thrown away: frq is not waiting on it, and a | ||
| 43 | + child whose pipes nobody reads is a child that can block on a full one. | ||
| 44 | + | ||
| 45 | + http and https only. Every link in a conversation reaches this from | ||
| 46 | + somewhere else's message, and xdg-open takes far more than a web page — a | ||
| 47 | + `file:` URL is a file manager, and a bare path is whatever is registered for | ||
| 48 | + it. A scheme this client did not mean to offer is not opened at all." | ||
| 49 | + [url] | ||
| 50 | + (boolean | ||
| 51 | + (when (re-matches #"(?i)https?://[^\s]+" (str url)) | ||
| 52 | + (try | ||
| 53 | + (-> (ProcessBuilder. (into-array String ["xdg-open" url])) | ||
| 54 | + (.redirectOutput java.lang.ProcessBuilder$Redirect/DISCARD) | ||
| 55 | + (.redirectError java.lang.ProcessBuilder$Redirect/DISCARD) | ||
| 56 | + (.start)) | ||
| 57 | + true | ||
| 58 | + (catch Exception _ false))))) | ||
| 59 | + | ||
| 36 | (defn -main [& _] | 60 | (defn -main [& _] |
| 37 | ;; Everything this backend can do. What is missing is missing on purpose: | 61 | ;; Everything this backend can do. What is missing is missing on purpose: |
| 38 | - ;; libcosmic has no `open-url!`, so the connect screen falls back to showing | 62 | + ;; libcosmic has no texture to push call frames into, which is the same |
| 39 | - ;; the URL, and no texture to push call frames into, which is the same thing | 63 | + ;; thing `:av? false` below says from the other end. It has no browser |
| 40 | - ;; `:av? false` below says from the other end. | 64 | + ;; either, but the desktop it is running on does — `open-url!` above is |
| 65 | + ;; that, and it is why the connect screen opens a browser here now rather | ||
| 66 | + ;; than falling back to printing the URL for someone to copy. | ||
| 41 | (platform/override! {:after! cosmic/after! | 67 | (platform/override! {:after! cosmic/after! |
| 42 | :every! cosmic/every! | 68 | :every! cosmic/every! |
| 43 | :quit! cosmic/quit! | 69 | :quit! cosmic/quit! |
| 70 | + :open-url! open-url! | ||
| 44 | :screen-size cosmic/window-size | 71 | :screen-size cosmic/window-size |
| 45 | :pick-image! cosmic/pick-image! | 72 | :pick-image! cosmic/pick-image! |
| 46 | :picked-image! cosmic/picked-image! | 73 | :picked-image! cosmic/picked-image! |
modified
src/frq/profile.clj +64 -5 | @@ -13,7 +13,8 @@ | ||
| 13 | 13 | (:require [clojure.string :as str] |
| 14 | 14 | [glimmer.ratom :as r :refer [atom]] |
| 15 | 15 | [frq.atproto :as atproto] |
| 16 | - [frq.avatars :as avatars])) | |
| 16 | + [frq.avatars :as avatars] | |
| 17 | + [frq.platform :as platform])) | |
| 17 | 18 | |
| 18 | 19 | (def ^:private directory-host "public.api.bsky.app") |
| 19 | 20 | |
| @@ -96,12 +97,70 @@ | ||
| 96 | 97 | (when (seq actor) (avatars/fetch! actor #(swap! tick inc))) |
| 97 | 98 | (fetch! actor)) |
| 98 | 99 | |
| 100 | +;; Whether the pointer is on the dialog the hover put up. | |
| 101 | +;; | |
| 102 | +;; This is what makes a hovered profile something you can move into and read | |
| 103 | +;; rather than something you can only glance at: the dialog reports its own | |
| 104 | +;; pointer, so leaving the face is not the end of the hover if the pointer | |
| 105 | +;; turned up here instead. | |
| 106 | +(defonce ^:private over-dialog? (atom false)) | |
| 107 | + | |
| 108 | +(defn dismiss! | |
| 109 | + "Put the profile away, however it was opened. | |
| 110 | + | |
| 111 | + The dialog is shown for `viewing` or for `hovering`, so a Close that cleared | |
| 112 | + only the first left one the pointer had opened on screen with its own button | |
| 113 | + doing nothing to it." | |
| 114 | + [] | |
| 115 | + (reset! viewing nil) | |
| 116 | + (reset! hovering nil) | |
| 117 | + ;; And the pointer's claim on it. Close takes the dialog out from under the | |
| 118 | + ;; pointer, so there is no leaving edge coming to say so — left set, it | |
| 119 | + ;; would hold the next hover open for good. | |
| 120 | + (reset! over-dialog? false)) | |
| 121 | + | |
| 122 | +;; How long the pointer may be on neither the face nor the dialog before the | |
| 123 | +;; dialog goes. | |
| 124 | +;; | |
| 125 | +;; There is a gap between the two — the dialog is centred and the face is | |
| 126 | +;; wherever the message is — and a hover that ended the instant the pointer | |
| 127 | +;; left the face would close it halfway across every time. Long enough to | |
| 128 | +;; cross, short enough that a pointer moving somewhere else entirely does not | |
| 129 | +;; drag it along. | |
| 130 | +(def ^:private grace-ms 400) | |
| 131 | + | |
| 132 | +(defn- release! | |
| 133 | + "Let `nick`'s hover go, unless something has taken it up again. | |
| 134 | + | |
| 135 | + Three things can have happened in the grace period: the pointer arrived on | |
| 136 | + the dialog, it went back to the face, or it landed on someone else's. In all | |
| 137 | + three there is a hover to keep, and it is not this one's to end — which is | |
| 138 | + what the nick guard says." | |
| 139 | + [nick] | |
| 140 | + (when-not @over-dialog? | |
| 141 | + (swap! hovering #(when-not (= nick (:nick %)) %)))) | |
| 142 | + | |
| 99 | 143 | (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." | |
| 144 | + "The pointer has left `nick`'s face — which is not yet the end of it. | |
| 145 | + | |
| 146 | + Guarded by who is being left, so the leaving of one face cannot take down | |
| 147 | + the card of the next one: both edges arrive in the same frame when the | |
| 148 | + pointer crosses straight over." | |
| 103 | 149 | [nick] |
| 104 | - (swap! hovering #(when-not (= nick (:nick %)) %))) | |
| 150 | + (platform/after! grace-ms #(release! nick))) | |
| 151 | + | |
| 152 | +(defn enter-dialog! | |
| 153 | + "The pointer is on the dialog. Whatever hover put it there is now this." | |
| 154 | + [] | |
| 155 | + (reset! over-dialog? true)) | |
| 156 | + | |
| 157 | +(defn leave-dialog! | |
| 158 | + "The pointer has left the dialog, and with it the last thing holding the | |
| 159 | + profile open — unless it went back to the face it came from." | |
| 160 | + [] | |
| 161 | + (reset! over-dialog? false) | |
| 162 | + (let [nick (:nick @hovering)] | |
| 163 | + (platform/after! grace-ms #(release! nick)))) | |
| 105 | 164 | |
| 106 | 165 | (defn web-url |
| 107 | 166 | "Their profile on the web, by handle where there is one and DID otherwise." |
| @@ -13,7 +13,8 @@ | |||
| 13 | (:require [clojure.string :as str] | 13 | (:require [clojure.string :as str] |
| 14 | [glimmer.ratom :as r :refer [atom]] | 14 | [glimmer.ratom :as r :refer [atom]] |
| 15 | [frq.atproto :as atproto] | 15 | [frq.atproto :as atproto] |
| 16 | - [frq.avatars :as avatars])) | 16 | + [frq.avatars :as avatars] |
| 17 | + [frq.platform :as platform])) | ||
| 17 | 18 | ||
| 18 | (def ^:private directory-host "public.api.bsky.app") | 19 | (def ^:private directory-host "public.api.bsky.app") |
| 19 | 20 | ||
| @@ -96,12 +97,70 @@ | |||
| 96 | (when (seq actor) (avatars/fetch! actor #(swap! tick inc))) | 97 | (when (seq actor) (avatars/fetch! actor #(swap! tick inc))) |
| 97 | (fetch! actor)) | 98 | (fetch! actor)) |
| 98 | 99 | ||
| 100 | +;; Whether the pointer is on the dialog the hover put up. | ||
| 101 | +;; | ||
| 102 | +;; This is what makes a hovered profile something you can move into and read | ||
| 103 | +;; rather than something you can only glance at: the dialog reports its own | ||
| 104 | +;; pointer, so leaving the face is not the end of the hover if the pointer | ||
| 105 | +;; turned up here instead. | ||
| 106 | +(defonce ^:private over-dialog? (atom false)) | ||
| 107 | + | ||
| 108 | +(defn dismiss! | ||
| 109 | + "Put the profile away, however it was opened. | ||
| 110 | + | ||
| 111 | + The dialog is shown for `viewing` or for `hovering`, so a Close that cleared | ||
| 112 | + only the first left one the pointer had opened on screen with its own button | ||
| 113 | + doing nothing to it." | ||
| 114 | + [] | ||
| 115 | + (reset! viewing nil) | ||
| 116 | + (reset! hovering nil) | ||
| 117 | + ;; And the pointer's claim on it. Close takes the dialog out from under the | ||
| 118 | + ;; pointer, so there is no leaving edge coming to say so — left set, it | ||
| 119 | + ;; would hold the next hover open for good. | ||
| 120 | + (reset! over-dialog? false)) | ||
| 121 | + | ||
| 122 | +;; How long the pointer may be on neither the face nor the dialog before the | ||
| 123 | +;; dialog goes. | ||
| 124 | +;; | ||
| 125 | +;; There is a gap between the two — the dialog is centred and the face is | ||
| 126 | +;; wherever the message is — and a hover that ended the instant the pointer | ||
| 127 | +;; left the face would close it halfway across every time. Long enough to | ||
| 128 | +;; cross, short enough that a pointer moving somewhere else entirely does not | ||
| 129 | +;; drag it along. | ||
| 130 | +(def ^:private grace-ms 400) | ||
| 131 | + | ||
| 132 | +(defn- release! | ||
| 133 | + "Let `nick`'s hover go, unless something has taken it up again. | ||
| 134 | + | ||
| 135 | + Three things can have happened in the grace period: the pointer arrived on | ||
| 136 | + the dialog, it went back to the face, or it landed on someone else's. In all | ||
| 137 | + three there is a hover to keep, and it is not this one's to end — which is | ||
| 138 | + what the nick guard says." | ||
| 139 | + [nick] | ||
| 140 | + (when-not @over-dialog? | ||
| 141 | + (swap! hovering #(when-not (= nick (:nick %)) %)))) | ||
| 142 | + | ||
| 99 | (defn unhover! | 143 | (defn unhover! |
| 100 | - "The pointer has left `nick`'s face. Guarded by who is being left, so the | 144 | + "The pointer has left `nick`'s face — which is not yet the end of it. |
| 101 | - leaving of one face cannot take down the card of the next one — both edges | 145 | + |
| 102 | - arrive in the same frame when the pointer crosses straight over." | 146 | + Guarded by who is being left, so the leaving of one face cannot take down |
| 147 | + the card of the next one: both edges arrive in the same frame when the | ||
| 148 | + pointer crosses straight over." | ||
| 103 | [nick] | 149 | [nick] |
| 104 | - (swap! hovering #(when-not (= nick (:nick %)) %))) | 150 | + (platform/after! grace-ms #(release! nick))) |
| 151 | + | ||
| 152 | +(defn enter-dialog! | ||
| 153 | + "The pointer is on the dialog. Whatever hover put it there is now this." | ||
| 154 | + [] | ||
| 155 | + (reset! over-dialog? true)) | ||
| 156 | + | ||
| 157 | +(defn leave-dialog! | ||
| 158 | + "The pointer has left the dialog, and with it the last thing holding the | ||
| 159 | + profile open — unless it went back to the face it came from." | ||
| 160 | + [] | ||
| 161 | + (reset! over-dialog? false) | ||
| 162 | + (let [nick (:nick @hovering)] | ||
| 163 | + (platform/after! grace-ms #(release! nick)))) | ||
| 105 | 164 | ||
| 106 | (defn web-url | 165 | (defn web-url |
| 107 | "Their profile on the web, by handle where there is one and DID otherwise." | 166 | "Their profile on the web, by handle where there is one and DID otherwise." |
modified
src/frq/state.clj +72 -0 | @@ -103,6 +103,33 @@ | ||
| 103 | 103 | |
| 104 | 104 | (defn toggle-users! [] (swap! show-users? not)) |
| 105 | 105 | |
| 106 | +;; Whether the wide window is holding the chats list back, leaving the whole | |
| 107 | +;; row to the conversation. Only a wide window has anything to hide: below | |
| 108 | +;; `wide-width` the list and the conversation already take turns, and hiding | |
| 109 | +;; the list there would be hiding the only way to another room. | |
| 110 | +;; | |
| 111 | +;; Saved with the other settings rather than reset per launch: it is a choice | |
| 112 | +;; about how this screen is read, and a reader who wants the conversation | |
| 113 | +;; whole wants it whole again tomorrow. | |
| 114 | +(defonce hide-chat-list? (atom false)) | |
| 115 | + | |
| 116 | +(declare save-prefs!) | |
| 117 | + | |
| 118 | +(defn toggle-chat-list! [] | |
| 119 | + (swap! hide-chat-list? not) | |
| 120 | + (save-prefs!)) | |
| 121 | + | |
| 122 | +;; Whether the conversation is sharing its room with the overview strip — | |
| 123 | +;; every channel's last lines in one list, under the one you are reading. | |
| 124 | +;; Saved for the same reason as the fold above it: it is a choice about how | |
| 125 | +;; this screen is laid out, and a layout a reader chose should be the one they | |
| 126 | +;; come back to. | |
| 127 | +(defonce overview? (atom false)) | |
| 128 | + | |
| 129 | +(defn toggle-overview! [] | |
| 130 | + (swap! overview? not) | |
| 131 | + (save-prefs!)) | |
| 132 | + | |
| 106 | 133 | ;; The window's content width in points, polled from the backend a few times a |
| 107 | 134 | ;; second. The app is laid out for a phone-width window, and this is what lets |
| 108 | 135 | ;; a wide one be more than a phone with margins: past `wide-width` the channel |
| @@ -199,6 +226,8 @@ | ||
| 199 | 226 | (defn- save-prefs! [] |
| 200 | 227 | (future (store/save-prefs! (assoc (store/load-prefs) |
| 201 | 228 | :hide-join-part? @hide-join-part? |
| 229 | + :hide-chat-list? @hide-chat-list? | |
| 230 | + :overview? @overview? | |
| 202 | 231 | :room-list-owned? @room-list-owned?)))) |
| 203 | 232 | |
| 204 | 233 | (defn toggle-hide-join-part! [] |
| @@ -210,6 +239,8 @@ | ||
| 210 | 239 | [] |
| 211 | 240 | (let [prefs (store/load-prefs)] |
| 212 | 241 | (reset! hide-join-part? (boolean (:hide-join-part? prefs))) |
| 242 | + (reset! hide-chat-list? (boolean (:hide-chat-list? prefs))) | |
| 243 | + (reset! overview? (boolean (:overview? prefs))) | |
| 213 | 244 | (reset! room-list-owned? (boolean (:room-list-owned? prefs))) |
| 214 | 245 | prefs)) |
| 215 | 246 | |
| @@ -1925,6 +1956,47 @@ | ||
| 1925 | 1956 | |
| 1926 | 1957 | |
| 1927 | 1958 | |
| 1959 | +;; How many lines the overview takes from each room. Per room rather than | |
| 1960 | +;; across all of them: the strip is there to say what is happening everywhere, | |
| 1961 | +;; and one busy channel taking the whole of a shared budget is the strip | |
| 1962 | +;; answering about one room — which is the room you are probably already in. | |
| 1963 | +(def overview-per-channel 3) | |
| 1964 | + | |
| 1965 | +;; And a ceiling on the lot, because the rooms are not a fixed number: at three | |
| 1966 | +;; apiece, a client in twenty channels would hand the strip sixty rows and the | |
| 1967 | +;; conversation above it nothing. Past this the newest win, which is the same | |
| 1968 | +;; bargain every other list here makes. | |
| 1969 | +(def overview-limit 24) | |
| 1970 | + | |
| 1971 | +(defn recent-everywhere | |
| 1972 | + "The last `overview-per-channel` lines from every buffer at once, oldest | |
| 1973 | + first, and at most `overview-limit` of them. | |
| 1974 | + | |
| 1975 | + Each carries the room it was said in, since that is the one thing a line | |
| 1976 | + taken out of its own conversation no longer says for itself. | |
| 1977 | + | |
| 1978 | + Bounded per room before the sort, so the cost is the number of rooms rather | |
| 1979 | + than the length of their backlogs: a channel with a week of history in it | |
| 1980 | + must not make this the most expensive thing on the screen. | |
| 1981 | + | |
| 1982 | + Joins, parts and the rest of the system's own chatter are left out. They are | |
| 1983 | + the noise this strip would drown in: a room nobody has spoken in for a day | |
| 1984 | + still reports everyone who came and went in it. | |
| 1985 | + | |
| 1986 | + So is the room being read. It is on the screen already, in full, directly | |
| 1987 | + above — repeating its last three lines under itself spends the strip's room | |
| 1988 | + saying what the conversation just said, and what the strip is for is the | |
| 1989 | + rooms you are not looking at." | |
| 1990 | + [] | |
| 1991 | + (->> (dissoc @channels @current) | |
| 1992 | + (mapcat (fn [[name buffer]] | |
| 1993 | + (->> (:messages buffer) | |
| 1994 | + (remove :system?) | |
| 1995 | + (take-last overview-per-channel) | |
| 1996 | + (map #(assoc % :channel name))))) | |
| 1997 | + (sort-by #(or (:at %) 0)) | |
| 1998 | + (take-last overview-limit))) | |
| 1999 | + | |
| 1928 | 2000 | (defn last-preview [buffer] |
| 1929 | 2001 | (if-let [m (last (:messages buffer))] |
| 1930 | 2002 | (str (:from m) ": " (:text m)) |
| @@ -103,6 +103,33 @@ | |||
| 103 | 103 | ||
| 104 | (defn toggle-users! [] (swap! show-users? not)) | 104 | (defn toggle-users! [] (swap! show-users? not)) |
| 105 | 105 | ||
| 106 | +;; Whether the wide window is holding the chats list back, leaving the whole | ||
| 107 | +;; row to the conversation. Only a wide window has anything to hide: below | ||
| 108 | +;; `wide-width` the list and the conversation already take turns, and hiding | ||
| 109 | +;; the list there would be hiding the only way to another room. | ||
| 110 | +;; | ||
| 111 | +;; Saved with the other settings rather than reset per launch: it is a choice | ||
| 112 | +;; about how this screen is read, and a reader who wants the conversation | ||
| 113 | +;; whole wants it whole again tomorrow. | ||
| 114 | +(defonce hide-chat-list? (atom false)) | ||
| 115 | + | ||
| 116 | +(declare save-prefs!) | ||
| 117 | + | ||
| 118 | +(defn toggle-chat-list! [] | ||
| 119 | + (swap! hide-chat-list? not) | ||
| 120 | + (save-prefs!)) | ||
| 121 | + | ||
| 122 | +;; Whether the conversation is sharing its room with the overview strip — | ||
| 123 | +;; every channel's last lines in one list, under the one you are reading. | ||
| 124 | +;; Saved for the same reason as the fold above it: it is a choice about how | ||
| 125 | +;; this screen is laid out, and a layout a reader chose should be the one they | ||
| 126 | +;; come back to. | ||
| 127 | +(defonce overview? (atom false)) | ||
| 128 | + | ||
| 129 | +(defn toggle-overview! [] | ||
| 130 | + (swap! overview? not) | ||
| 131 | + (save-prefs!)) | ||
| 132 | + | ||
| 106 | ;; The window's content width in points, polled from the backend a few times a | 133 | ;; The window's content width in points, polled from the backend a few times a |
| 107 | ;; second. The app is laid out for a phone-width window, and this is what lets | 134 | ;; second. The app is laid out for a phone-width window, and this is what lets |
| 108 | ;; a wide one be more than a phone with margins: past `wide-width` the channel | 135 | ;; a wide one be more than a phone with margins: past `wide-width` the channel |
| @@ -199,6 +226,8 @@ | |||
| 199 | (defn- save-prefs! [] | 226 | (defn- save-prefs! [] |
| 200 | (future (store/save-prefs! (assoc (store/load-prefs) | 227 | (future (store/save-prefs! (assoc (store/load-prefs) |
| 201 | :hide-join-part? @hide-join-part? | 228 | :hide-join-part? @hide-join-part? |
| 229 | + :hide-chat-list? @hide-chat-list? | ||
| 230 | + :overview? @overview? | ||
| 202 | :room-list-owned? @room-list-owned?)))) | 231 | :room-list-owned? @room-list-owned?)))) |
| 203 | 232 | ||
| 204 | (defn toggle-hide-join-part! [] | 233 | (defn toggle-hide-join-part! [] |
| @@ -210,6 +239,8 @@ | |||
| 210 | [] | 239 | [] |
| 211 | (let [prefs (store/load-prefs)] | 240 | (let [prefs (store/load-prefs)] |
| 212 | (reset! hide-join-part? (boolean (:hide-join-part? prefs))) | 241 | (reset! hide-join-part? (boolean (:hide-join-part? prefs))) |
| 242 | + (reset! hide-chat-list? (boolean (:hide-chat-list? prefs))) | ||
| 243 | + (reset! overview? (boolean (:overview? prefs))) | ||
| 213 | (reset! room-list-owned? (boolean (:room-list-owned? prefs))) | 244 | (reset! room-list-owned? (boolean (:room-list-owned? prefs))) |
| 214 | prefs)) | 245 | prefs)) |
| 215 | 246 | ||
| @@ -1925,6 +1956,47 @@ | |||
| 1925 | 1956 | ||
| 1926 | 1957 | ||
| 1927 | 1958 | ||
| 1959 | +;; How many lines the overview takes from each room. Per room rather than | ||
| 1960 | +;; across all of them: the strip is there to say what is happening everywhere, | ||
| 1961 | +;; and one busy channel taking the whole of a shared budget is the strip | ||
| 1962 | +;; answering about one room — which is the room you are probably already in. | ||
| 1963 | +(def overview-per-channel 3) | ||
| 1964 | + | ||
| 1965 | +;; And a ceiling on the lot, because the rooms are not a fixed number: at three | ||
| 1966 | +;; apiece, a client in twenty channels would hand the strip sixty rows and the | ||
| 1967 | +;; conversation above it nothing. Past this the newest win, which is the same | ||
| 1968 | +;; bargain every other list here makes. | ||
| 1969 | +(def overview-limit 24) | ||
| 1970 | + | ||
| 1971 | +(defn recent-everywhere | ||
| 1972 | + "The last `overview-per-channel` lines from every buffer at once, oldest | ||
| 1973 | + first, and at most `overview-limit` of them. | ||
| 1974 | + | ||
| 1975 | + Each carries the room it was said in, since that is the one thing a line | ||
| 1976 | + taken out of its own conversation no longer says for itself. | ||
| 1977 | + | ||
| 1978 | + Bounded per room before the sort, so the cost is the number of rooms rather | ||
| 1979 | + than the length of their backlogs: a channel with a week of history in it | ||
| 1980 | + must not make this the most expensive thing on the screen. | ||
| 1981 | + | ||
| 1982 | + Joins, parts and the rest of the system's own chatter are left out. They are | ||
| 1983 | + the noise this strip would drown in: a room nobody has spoken in for a day | ||
| 1984 | + still reports everyone who came and went in it. | ||
| 1985 | + | ||
| 1986 | + So is the room being read. It is on the screen already, in full, directly | ||
| 1987 | + above — repeating its last three lines under itself spends the strip's room | ||
| 1988 | + saying what the conversation just said, and what the strip is for is the | ||
| 1989 | + rooms you are not looking at." | ||
| 1990 | + [] | ||
| 1991 | + (->> (dissoc @channels @current) | ||
| 1992 | + (mapcat (fn [[name buffer]] | ||
| 1993 | + (->> (:messages buffer) | ||
| 1994 | + (remove :system?) | ||
| 1995 | + (take-last overview-per-channel) | ||
| 1996 | + (map #(assoc % :channel name))))) | ||
| 1997 | + (sort-by #(or (:at %) 0)) | ||
| 1998 | + (take-last overview-limit))) | ||
| 1999 | + | ||
| 1928 | (defn last-preview [buffer] | 2000 | (defn last-preview [buffer] |
| 1929 | (if-let [m (last (:messages buffer))] | 2001 | (if-let [m (last (:messages buffer))] |
| 1930 | (str (:from m) ": " (:text m)) | 2002 | (str (:from m) ": " (:text m)) |