nandi/frqpublic Fork 0
39459ed
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-11T20:22:47-07:00 Browse files
39459ed parent: 80eb088
modified src/frq/app.clj +334 -122
@@ -100,12 +100,6 @@
100100 [url]
101101 (derived [:image url] #(do @s/media-tick (media/path-when-ready url))))
102102
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-
109103 ;; How big a face is on a message, in points — the size the window has always
110104 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so
111105 ;; the same number is four columns by two rows there: a cached 128-pixel
@@ -114,9 +108,14 @@
114108
115109 ;; ---------------------------------------------------------------- chats
116110
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.
117117 (defn- open-button [name]
118118 [:button {:label "Open"
119- :kind :primary
120119 :on-click #(s/open-channel! name)}])
121120
122121 ;; The only way out of a room. Everything else adds one — the server saying we
@@ -132,10 +131,23 @@
132131 a list of rooms."
133132 [text]
134133 (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) "")
137140 line)))
138141
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+
139151 (defn conversation-row [buffer]
140152 (let [name (:name buffer)
141153 ;; Defaulted rather than assumed. Everything that builds a buffer
@@ -143,44 +155,59 @@
143155 ;; the atom, and a half-made room reaching here should be a row that
144156 ;; reads as quiet rather than the frame that killed the client.
145157 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?
160179 [:hbox {:spacing 8 :wrap false}
161180 [:title-2 {:label name}]
162181 [open-button name]
163182 [close-button name]]
164183 [: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]])]]))
184211
185212 (def ^:private sidebar-width 320)
186213
@@ -264,11 +291,15 @@
264291 ;; under it. Every screen that pins something to the bottom says this on
265292 ;; its own root — the split view already said it, one wrapper further out.
266293 [:vbox {:spacing 8 :margin 12 :fill-height true}
267- [:vbox {:key :head :spacing 8}
294+ [:vbox {:key :head :spacing 8 :margin-right list-gutter}
268295 ;; 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")}]
272303 [error-note]
273304 ;; Join and search are the same shape — a channel box with a button
274305 ;; beside it — so they read as one control block rather than a titled
@@ -322,7 +353,8 @@
322353 ;; the slot the reader clicked while the row that moved into it is
323354 ;; someone else's conversation.
324355 (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."}]]])]]
326358 [:vbox {:key :foot :spacing 8}
327359 [:separator {}]
328360 [tab-bar]]]))
@@ -521,13 +553,7 @@
521553 than a button with the emoji as its label — the chip draws the glyph from the
522554 Twemoji pack, in colour, where a label gets whatever the text font has."
523555 [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)]
531557 ;; `into` and not a lazy `for` inside the vector. The pills read
532558 ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being
533559 ;; realised somewhere other than the render is a read the component never
@@ -547,13 +573,14 @@
547573 ;; says who put it there. On a phone the pill is a button
548574 ;; and nothing more: there is no hover to answer.
549575 (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.
550582 (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)))]))))
557584
558585 (def ^:private picker-columns
559586 "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
@@ -620,48 +647,6 @@
620647 (when (pos? over)
621648 [:dim-label {:label (str "and " over " more keep typing to narrow it")}])]]))
622649
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-
665650 (defn- preview-height
666651 "How tall a picture in the conversation may be.
667652
@@ -744,21 +729,17 @@
744729 ;; the whole message rather than off its heading — `message-row` has it.
745730 (when-not @terminal?
746731 (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`.
747736 [:avatar (cond-> {:label (:from m)
748737 :src (or src "")
749738 :size face-size
750739 :on-click #(profile/open! (:from m) (:actor m))}
751740 (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))))]))
762743 ;; The name carries the row, so it is set at body size in the plain
763744 ;; text colour: dimmed caption made the one thing you scan a column
764745 ;; for the faintest thing on it.
@@ -920,6 +901,120 @@
920901 (range (count messages))
921902 messages))
922903
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+
9231018 (defn profile-screen
9241019 "Who someone is: sleek's peer profile modal, as a screen.
9251020
@@ -1196,6 +1291,19 @@
11961291 ;; there is one. Those rows do move the compose bar, and should: each appears
11971292 ;; because the reader just asked for something — unlike the jump button, which
11981293 ;; 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+
11991307 (defn- below-messages []
12001308 ;; 140 is that, counted: the gap under the row of columns, the jump button's
12011309 ;; 34pt row, the separator and the two empty wrappers with a gap apiece, then
@@ -1218,7 +1326,12 @@
12181326 ;; box rounded up: the reserve has to be at least what the field took,
12191327 ;; because a point too few does not crop the list, it slides the
12201328 ;; 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)))
12221335
12231336 (defn- messages-width
12241337 "How wide the message list may be with the people panel beside it.
@@ -1229,7 +1342,9 @@
12291342 first `sidebar-width` of the window; the rest is the margins and the gap
12301343 between the two columns."
12311344 []
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))]
12331348 (max 240 (- pane users-width 8 28))))
12341349
12351350 (defn- member-row [{:keys [nick prefix]}]
@@ -1305,6 +1420,44 @@
13051420 [:button {:label "Accept policy" :kind :primary
13061421 :on-click #(s/accept-policy! name)}]]))
13071422
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+
13081461 (defn chat-screen []
13091462 (let [name @s/current
13101463 buffer (get @s/channels name)
@@ -1333,6 +1486,14 @@
13331486 [:vbox {:key :back}
13341487 (when-not (s/wide?)
13351488 [: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!}])]
13361497 [:title {:label (or name "Chat")}]
13371498 ;; Same wrapper trick: only in a channel, and only when there is no call
13381499 ;; to join already — the bar below offers Join in that case, and two ways
@@ -1351,7 +1512,14 @@
13511512 (when (and name (str/starts-with? name "#"))
13521513 [:button {:label (str "People " (s/member-count name))
13531514 :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!}]]
13551523 [error-note]
13561524 ;; In a wrapper of its own, for the reconciler's sake: it comes and goes.
13571525 [:vbox {:key :policy}
@@ -1393,6 +1561,11 @@
13931561 ;; compose bar sits in whether or not the panel was showing.
13941562 [:vbox {:key :people-pane}
13951563 (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])]
13961569 ;; Only while it is needed, and directly above the compose bar: the way
13971570 ;; back to the present belongs next to the thing that puts you there.
13981571 ;; The row keeps its height whether or not the button is in it, so the
@@ -1509,7 +1682,16 @@
15091682 []
15101683 [:vbox {:spacing 8 :margin 12}
15111684 [: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."}]])])
15131695
15141696 (defn split-screen
15151697 "The chats list and the conversation side by side, for a window wide enough
@@ -1544,9 +1726,18 @@
15441726 ;; axis alone: the height without the share. The conversation keeps
15451727 ;; :fill-height and so keeps the slack, which is what the note above says
15461728 ;; 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]])
15501741 [:vbox {:key :chat :fill-height true}
15511742 (if @s/current
15521743 [chat-screen]
@@ -1638,9 +1829,30 @@
16381829 ;; from the conversation you were in a moment ago. A key that changes with
16391830 ;; the screen makes the swap a swap: the old tree comes out whole and the new
16401831 ;; 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
16421853 @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]]
16441856 @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]]
16451857 ;; Wide enough for both, and on one of the two screens that are halves of
16461858 ;; the same thing: the list and the conversation it opens. Discover and
@@ -1653,7 +1865,7 @@
16531865 :chat [:vbox {:key :screen-chat} [chat-screen]]
16541866 :discover [:vbox {:key :screen-discover} [discover-screen]]
16551867 :settings [:vbox {:key :screen-settings} [settings-screen]]
1656- [:vbox {:key :screen-chats} [chats-screen]])))
1868+ [:vbox {:key :screen-chats} [chats-screen]]))])
16571869
16581870 (defn start!
16591871 "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 always103 ;; 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, so104 ;; 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-pixel105 ;; the same number is four columns by two rows there: a cached 128-pixel
@@ -114,9 +108,14 @@
114 108
115 ;; ---------------------------------------------------------------- chats109 ;; ---------------------------------------------------------------- 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 we121 ;; 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 buffer153 ;; 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 that155 ;; 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 under159+ ;; 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: the160+ ;; 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 a161+ ;; 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 count162+ ;; 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 longer163+ ;; 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 of166+ ;; 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 the167+ ;; 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 on168+ ;; 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 with169+ ;; 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 the186+ ;; 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 nobody187+ ;; 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 says193+ ;; `: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 or194+ [: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 on291 ;; 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 not295 ;; 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 own296+ ;; 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 button304 ;; 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 titled305 ;; 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 is353 ;; 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 the553 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 read557 ;; `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 being558 ;; `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 never559 ;; 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 button573 ;; 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-columns585 (def ^:private picker-columns
559 "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a586 "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-height650 (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-size738 :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 plain743 ;; 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 column744 ;; 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-screen1018 (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 appears1291 ;; 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, which1292 ;; 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's1308 ;; 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, then1309 ;; 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 the1327 ;; 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-width1336 (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 gap1342 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 :primary1420 [: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/current1462 (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 call1498 ;; 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 ways1499 ;; 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 way1569 ;; 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 the1571 ;; 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-screen1696 (defn split-screen
1515 "The chats list and the conversation side by side, for a window wide enough1697 "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 keeps1726 ;; axis alone: the height without the share. The conversation keeps
1545 ;; :fill-height and so keeps the slack, which is what the note above says1727 ;; :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 true1729+ ;; 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/current1742 (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 with1829 ;; 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 new1830 ;; 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- (cond1832+ ;; 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 of1857 ;; 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 and1858 ;; 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 is1871 "Everything a launch does before the loop starts, for whichever backend is
modified src/frq/cosmic.clj +30 -3
@@ -33,14 +33,41 @@
3333 [path]
3434 (cosmic/every! 3000 #(spit path (cosmic/dump-str))))
3535
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+
3660 (defn -main [& _]
3761 ;; 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.
4167 (platform/override! {:after! cosmic/after!
4268 :every! cosmic/every!
4369 :quit! cosmic/quit!
70+ :open-url! open-url!
4471 :screen-size cosmic/window-size
4572 :pick-image! cosmic/pick-image!
4673 :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 showing62+ ;; 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 thing63+ ;; 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-size71 :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 @@
1313 (:require [clojure.string :as str]
1414 [glimmer.ratom :as r :refer [atom]]
1515 [frq.atproto :as atproto]
16- [frq.avatars :as avatars]))
16+ [frq.avatars :as avatars]
17+ [frq.platform :as platform]))
1718
1819 (def ^:private directory-host "public.api.bsky.app")
1920
@@ -96,12 +97,70 @@
9697 (when (seq actor) (avatars/fetch! actor #(swap! tick inc)))
9798 (fetch! actor))
9899
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+
99143 (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."
103149 [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))))
105164
106165 (defn web-url
107166 "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 the144+ "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 edges145+
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-url165 (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 @@
103103
104104 (defn toggle-users! [] (swap! show-users? not))
105105
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+
106133 ;; The window's content width in points, polled from the backend a few times a
107134 ;; second. The app is laid out for a phone-width window, and this is what lets
108135 ;; a wide one be more than a phone with margins: past `wide-width` the channel
@@ -199,6 +226,8 @@
199226 (defn- save-prefs! []
200227 (future (store/save-prefs! (assoc (store/load-prefs)
201228 :hide-join-part? @hide-join-part?
229+ :hide-chat-list? @hide-chat-list?
230+ :overview? @overview?
202231 :room-list-owned? @room-list-owned?))))
203232
204233 (defn toggle-hide-join-part! []
@@ -210,6 +239,8 @@
210239 []
211240 (let [prefs (store/load-prefs)]
212241 (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)))
213244 (reset! room-list-owned? (boolean (:room-list-owned? prefs)))
214245 prefs))
215246
@@ -1925,6 +1956,47 @@
19251956
19261957
19271958
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+
19282000 (defn last-preview [buffer]
19292001 (if-let [m (last (:messages buffer))]
19302002 (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 a133 ;; 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 lets134 ;; 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 channel135 ;; 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))