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

Share the root, and with it the last of the screens

`frq.screens.app` is the decision about which screen is showing, the split
view that shows two at once, and the three dialogs that sit over whichever it
is. The phone renders `[screens/app]` now and nothing else — it had been
switching screens by hand, because the thing that decides had not moved yet.

frq.app is 226 lines. It was 1,744. What is left is what cannot move:
`derived` and the two asset lookups, which are a glimmer reaction over a
fetch-and-cache; the metrics aliases frq.tui writes; and `start!`.

The renderer needed one more thing, and it is the last of the fill-height
family. The root wraps every screen in a plain `:vbox`, and the child is
`[chat-screen]` — a function, not a tag. `fills-column?` reads tags, so it
could not see that the screen inside wanted the whole height, and the
conversation came back unbounded. Components are expanded before the question
is asked now, once, and both asked and rendered from the same expansion.

The picker, the lightbox and the profile card come with the root and lean on
things the phone has not got: a directory listing, a picture cache, an HTTPS
profile fetch. All three go through `frq.actions`, so a platform that installs
none of them draws the shape they take when the answer is nil rather than
being special-cased in the screen.

Verified: the TUI is identical to a same-prefs baseline, the split view still
draws the list beside the conversation, and the phone walks connect → chats →
open → conversation entirely through the shared root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T23:35:09-07:00 Browse files
433c3c8 parent: 49fb82f
modified common/frq/actions.cljc +26 -0
@@ -139,3 +139,29 @@
139139 (defn profile-unhover! [& args] (call :profile-unhover! args))
140140 (defn profile-open! [& args] (call :profile-open! args))
141141 (defn toggle-hide-join-part! [] (call :toggle-hide-join-part! []))
142+
143+;; --------------------------------------------------- the picker and a face
144+;;
145+;; Browsing the filesystem for a picture, and the profile behind a nick. Both
146+;; are the host's: one is a directory listing and the other is an HTTPS fetch
147+;; with a cache, and a platform that has neither answers nil.
148+
149+(defn browse! [& args] (call :browse! args))
150+(defn close-image-picker! [& args] (call :close-image-picker! args))
151+(defn pick-image! [& args] (call :pick-image! args))
152+(defn parent-dir [& args] (call :parent-dir args))
153+(defn picker-entries [& args] (call :picker-entries args))
154+(defn picker-roots [& args] (call :picker-roots args))
155+(defn media-tick [] (call :media-tick []))
156+(defn avatar-ready [& args] (call :avatar-ready args))
157+
158+(defn profile-close! [& args] (call :profile-close! args))
159+(defn profile-dismiss! [& args] (call :profile-dismiss! args))
160+(defn profile-enter-dialog! [& args] (call :profile-enter-dialog! args))
161+(defn profile-entry [& args] (call :profile-entry args))
162+(defn profile-leave-dialog! [& args] (call :profile-leave-dialog! args))
163+(defn profile-stats-line [& args] (call :profile-stats-line args))
164+(defn profile-tick [] (call :profile-tick []))
165+(defn profile-truncate [& args] (call :profile-truncate args))
166+(defn profile-web-url [& args] (call :profile-web-url args))
167+
@@ -139,3 +139,29 @@
139 (defn profile-unhover! [& args] (call :profile-unhover! args))139 (defn profile-unhover! [& args] (call :profile-unhover! args))
140 (defn profile-open! [& args] (call :profile-open! args))140 (defn profile-open! [& args] (call :profile-open! args))
141 (defn toggle-hide-join-part! [] (call :toggle-hide-join-part! []))141 (defn toggle-hide-join-part! [] (call :toggle-hide-join-part! []))
142+
143+;; --------------------------------------------------- the picker and a face
144+;;
145+;; Browsing the filesystem for a picture, and the profile behind a nick. Both
146+;; are the host's: one is a directory listing and the other is an HTTPS fetch
147+;; with a cache, and a platform that has neither answers nil.
148+
149+(defn browse! [& args] (call :browse! args))
150+(defn close-image-picker! [& args] (call :close-image-picker! args))
151+(defn pick-image! [& args] (call :pick-image! args))
152+(defn parent-dir [& args] (call :parent-dir args))
153+(defn picker-entries [& args] (call :picker-entries args))
154+(defn picker-roots [& args] (call :picker-roots args))
155+(defn media-tick [] (call :media-tick []))
156+(defn avatar-ready [& args] (call :avatar-ready args))
157+
158+(defn profile-close! [& args] (call :profile-close! args))
159+(defn profile-dismiss! [& args] (call :profile-dismiss! args))
160+(defn profile-enter-dialog! [& args] (call :profile-enter-dialog! args))
161+(defn profile-entry [& args] (call :profile-entry args))
162+(defn profile-leave-dialog! [& args] (call :profile-leave-dialog! args))
163+(defn profile-stats-line [& args] (call :profile-stats-line args))
164+(defn profile-tick [] (call :profile-tick []))
165+(defn profile-truncate [& args] (call :profile-truncate args))
166+(defn profile-web-url [& args] (call :profile-web-url args))
167+
modified common/frq/cells.cljc +4 -0
@@ -208,3 +208,7 @@
208208
209209
210210 ;; joined as soon as the server sends 001
211+
212+;; Where the picker is looking, or nil when it is closed. A path, so the
213+;; browsing is just this cell moving.
214+(defonce image-picker (atom nil))
@@ -208,3 +208,7 @@
208 208
209 209
210 ;; joined as soon as the server sends 001210 ;; joined as soon as the server sends 001
211+
212+;; Where the picker is looking, or nil when it is closed. A path, so the
213+;; browsing is just this cell moving.
214+(defonce image-picker (atom nil))
added common/frq/screens/app.cljc +378 -0
new file mode 100644
@@ -0,0 +1,378 @@
1+(ns frq.screens.app
2+ "The screens as one app: which one is showing, the split view that shows two
3+ at once, and the three that sit over the top of whichever it is.
4+
5+ The last of `frq.app` to move, and the piece that makes the rest of it
6+ reachable a phone was switching screens by hand before this, because the
7+ thing that decides had not been shared yet.
8+
9+ `profile-screen`, `lightbox-screen` and `image-picker-screen` come with it
10+ because `app` is what shows them. All three lean on things a phone does not
11+ have yet a picture cache, a profile fetch, a filesystem to browse and
12+ all three ask `frq.actions` for them, so what a phone gets is the shape they
13+ take when the answer is nil."
14+ (:require [clojure.string :as str]
15+ [frq.actions :as actions]
16+ [frq.cells :as cells]
17+ [frq.metrics :refer [terminal?]]
18+ [frq.rooms :as rooms]
19+ [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog
20+ sidebar-width]]
21+ [frq.screens.chats :refer [chats-screen conversation-row tab-bar]]
22+ [frq.screens.connect :refer [connect-screen error-note]]
23+ [frq.screens.settings :refer [discover-screen settings-screen
24+ tab-screen]]))
25+
26+(defn image-picker-screen
27+ "The pictures on this device, to send one of.
28+
29+ A screen rather than a panel over the compose bar: choosing a file is
30+ browsing, and browsing wants the window the backend paints in one layer
31+ anyway, so there is no overlay to put it in.
32+
33+ A directory at a time, PNG only. Which directories there are to start from is
34+ the platform's answer, not this screen's: a desktop opens on ~/Pictures, and
35+ a phone on what the app can read without a permission it has no code to ask
36+ for, which may be very little. Either way the list says what it found."
37+ []
38+ (let [dir @cells/image-picker
39+ {:keys [dirs files]} (actions/picker-entries dir)
40+ up (actions/parent-dir dir)]
41+ [:vbox {:spacing 8 :margin 12}
42+ [:hbox {:spacing 8}
43+ [:button {:label "← Back" :on-click actions/close-image-picker!}]
44+ [:title {:label "Send a picture"}]]
45+ [:dim-label {:label (str dir)}]
46+ [error-note]
47+ ;; The places worth starting from, always in reach: browsing into a corner
48+ ;; of the filesystem should not cost the way back to the pictures folder.
49+ [:hbox {:key :roots :spacing 6}
50+ (for [root (actions/picker-roots)]
51+ [:button {:key root
52+ :label (or (last (str/split root #"/")) root)
53+ :kind (if (= root dir) :primary :default)
54+ :on-click #(actions/browse! root)}])]
55+ [:separator {}]
56+ [:scroll {:scroll-key "image-picker"
57+ :orientation :vertical
58+ :reserve 40}
59+ [:vbox {:spacing 6}
60+ [:vbox {:key :up}
61+ (when up
62+ [:button {:label "⬆ Up" :on-click #(actions/browse! up)}])]
63+ [:vbox {:key :dirs :spacing 4}
64+ (for [d dirs]
65+ [:button {:key d
66+ :label (str "📁 " (last (str/split d #"/")))
67+ :on-click #(actions/browse! d)}])]
68+ ;; The picture itself is the button: a filename is not what anyone is
69+ ;; choosing between, and a thumbnail answers "is this the one" in a way
70+ ;; no name does.
71+ [:vbox {:key :files :spacing 6}
72+ (for [f files]
73+ [:hbox {:key f :spacing 8}
74+ [:image {:src f :max-height 72 :on-click #(actions/pick-image! f)}]
75+ [:button {:label (last (str/split f #"/"))
76+ :on-click #(actions/pick-image! f)}]])]
77+ [:vbox {:key :empty}
78+ (when (and (empty? dirs) (empty? files))
79+ [:dim-label {:label "No pictures here that this app can read."}])]]]]))
80+
81+;; What the people panel asks for, and what the conversation beside it has to
82+;; be told to leave. A column with `:fill-height` and no width takes the whole
83+;; row so the panel is only ever on screen if the message list is given a
84+;; width that stops short of it.
85+
86+(defn lightbox-screen
87+ "One picture, as big as the window will paint it.
88+
89+ A screen rather than an overlay: the tree backend paints in one layer and has
90+ no z-order to put something on top of everything else with. So the picture
91+ takes the window `:fit` gives it every point below the one row that is not
92+ it, centred and in proportion, scaled up as readily as down.
93+
94+ That row is the way back. Clicking the picture closes it too the same
95+ gesture that opened it but a way out you have to guess at is not one, and
96+ the row costs the picture a line."
97+ []
98+ (let [{:keys [path]} @cells/lightbox]
99+ [:vbox {:spacing 4 :margin 4}
100+ [:hbox {:spacing 8}
101+ [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]]
102+ [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]]))
103+
104+(defn- profile-dialog
105+ "Who someone is, as libcosmic's own dialog: centred over the window, with
106+ what you were reading dimmed behind it rather than replaced.
107+
108+ The window can do this and the terminal cannot, which is the whole reason
109+ there are two of these. `profile-screen` below is the terminal's, and says
110+ why it is a screen.
111+
112+ Both buttons are always here, the Bluesky one insensitive until there is a
113+ profile to open: a dialog whose second button appears a moment after it
114+ opens is a dialog that moves under the pointer, and the fetch lands whenever
115+ it lands.
116+
117+ Who it is about comes from either of two places, and that with the
118+ modality below is the whole of what hovering and pressing a face do
119+ differently. Resting on one sets `hovering`, which the pointer takes away
120+ again when it leaves; pressing one sets `viewing`, which nothing takes away
121+ but Close. `viewing` is read first, so a pinned profile is not swapped out
122+ from under the reader by a face the pointer crosses on the way to it.
123+
124+ And a dialog the pointer is holding open is not modal. A modal one makes
125+ the window underneath it deaf libcosmic wraps the app in a popover that
126+ hands its content an `Unavailable` cursor while a popup is up so the face
127+ that opened it never hears the pointer leave, and what a hover opened could
128+ never close itself. Non-modal, the face keeps hearing, and moving away shuts
129+ it. A pinned one is modal, which is what being pinned means: it is the thing
130+ on the screen until it is dismissed."
131+ []
132+ (let [pinned? (some? (actions/viewing))
133+ {:keys [nick actor]} (or (actions/viewing) (actions/hovering))
134+ _ (actions/profile-tick)
135+ _ @actions/media-tick
136+ pr (actions/profile-entry actor)
137+ ready? (= :ready (:status pr))
138+ display (or (:display-name pr) nick)
139+ url (when ready? (actions/profile-web-url pr))]
140+ [:dialog {:label display :max-width 520 :modal pinned?
141+ :on-hover actions/profile-enter-dialog!
142+ :on-unhover actions/profile-leave-dialog!}
143+ ;; The body is the screen's card without its heading: the dialog's own
144+ ;; title is the name now, so repeating it under the picture is a line
145+ ;; that says nothing.
146+ [:vbox {:key :body :spacing 8}
147+ [:hbox {:spacing 12}
148+ [:avatar {:label nick
149+ :src (or (actions/avatar-ready actor) "")
150+ :size 72}]
151+ [:vbox {:spacing 2}
152+ [:vbox {:key :nick}
153+ (when (not= display nick) [:dim-label {:label nick}])]
154+ [:vbox {:key :handle}
155+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
156+ [:label {:label (str "@" h)}])]
157+ [:vbox {:key :did}
158+ (when-let [did (:did pr)] [:dim-label {:label did}])]]]
159+ [:vbox {:key :bio :spacing 2}
160+ (when-let [bio (and ready? (:description pr))]
161+ (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))]
162+ [:label {:key i :label line}]))]
163+ [:vbox {:key :stats}
164+ (when-let [line (and ready? (actions/profile-stats-line pr))]
165+ [:dim-label {:label line}])]
166+ [:vbox {:key :status :spacing 4}
167+ (cond
168+ (nil? actor)
169+ [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
170+
171+ (= :loading (:status pr))
172+ [:hbox {:spacing 8}
173+ [:spinner {}]
174+ [:dim-label {:label "Loading Bluesky profile…"}]]
175+
176+ (= :failed (:status pr))
177+ [:dim-label {:label "No Bluesky profile found"}])]
178+]
179+ ;; `slot` is where libcosmic puts a button: the two actions go to the
180+ ;; foot of the dialog, and anything else here would be another control
181+ ;; stacked in the body.
182+ ;;
183+ ;; Both are here whether the profile is pinned or only hovered. They were
184+ ;; hidden while hovering, back when a hover could not be walked into: the
185+ ;; dialog reports its own pointer now, so moving towards a button in it
186+ ;; keeps it open instead of closing it, and a button you can reach is a
187+ ;; button worth drawing.
188+ [:button {:key :close :slot "primary"
189+ :label (if pinned? "Close" "Dismiss")
190+ :on-click actions/profile-dismiss!}]
191+ [:button {:key :web :slot "secondary" :label "Bluesky ↗"
192+ :sensitive (boolean url)
193+ :on-click #(when url (actions/open-url! url))}]]))
194+
195+(defn profile-screen
196+ "Who someone is: sleek's peer profile modal, as a screen.
197+
198+ A screen rather than a layer for the same reason the lightbox is one the
199+ tree backend paints in a single layer, with no z-order to hang a modal from.
200+ So the back row does what sleek's ✕ and backdrop did.
201+
202+ The picture is the same cached file the chat column draws, at a size worth
203+ looking at; the bio is split into a label a line, so a bio that was written
204+ as several lines is still several lines here."
205+ []
206+ (let [{:keys [nick actor]} (actions/viewing)
207+ ;; Reading both ticks subscribes this screen to the two fetches it is
208+ ;; waiting on: the profile itself, and the picture on it.
209+ _ (actions/profile-tick)
210+ _ @actions/media-tick
211+ pr (actions/profile-entry actor)
212+ ready? (= :ready (:status pr))
213+ display (or (:display-name pr) nick)
214+ url (when ready? (actions/profile-web-url pr))]
215+ [:page {:max-width 520}
216+ [:hbox {:spacing 8}
217+ [:button {:label "← Back" :on-click actions/profile-close!}]]
218+ [:card {}
219+ [:hbox {:spacing 12}
220+ [:avatar {:label nick
221+ :src (or (actions/avatar-ready actor) "")
222+ :size 72}]
223+ [:vbox {:spacing 2}
224+ [:title-2 {:label display}]
225+ ;; The nick under the display name only when they differ repeating
226+ ;; it is a line that says nothing.
227+ [:vbox {:key :nick}
228+ (when (not= display nick) [:dim-label {:label nick}])]
229+ [:vbox {:key :handle}
230+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
231+ [:label {:label (str "@" h)}])]]]
232+ ;; The DID is the identity itself, and outlasts both the nick and the
233+ ;; handle so it is on the screen, in full, rather than implied.
234+ [:vbox {:key :did :spacing 2}
235+ (when-let [did (:did pr)] [:dim-label {:label did}])]
236+ [:vbox {:key :bio :spacing 2}
237+ (when-let [bio (and ready? (:description pr))]
238+ (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))]
239+ [:label {:key i :label line}]))]
240+ [:vbox {:key :stats}
241+ (when-let [line (and ready? (actions/profile-stats-line pr))]
242+ [:dim-label {:label line}])]
243+ ;; What is happening, or why nothing is: a guest has no identity to look
244+ ;; up, and saying so is a better answer than a spinner that never lands.
245+ [:vbox {:key :status :spacing 4}
246+ (cond
247+ (nil? actor)
248+ [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
249+
250+ (= :loading (:status pr))
251+ [:hbox {:spacing 8}
252+ [:spinner {}]
253+ [:dim-label {:label "Loading Bluesky profile…"}]]
254+
255+ (= :failed (:status pr))
256+ [:dim-label {:label "No Bluesky profile found"}])]
257+ [:vbox {:key :actions}
258+ (when url
259+ [:button {:label "Bluesky ↗"
260+ :kind :primary
261+ :on-click #(actions/open-url! url)}])]]]))
262+
263+(defn- no-chat-pane
264+ "What fills the second pane before a conversation has been picked. The pane
265+ is there either way a list that widened into two columns and back again as
266+ channels were opened would be a worse answer than an empty half."
267+ []
268+ [:vbox {:spacing 8 :margin 12}
269+ [:title {:label "frq"}]
270+ ;; With the list folded away there is nothing on the left to pick from, and
271+ ;; no conversation here carrying the switch that would bring it back so
272+ ;; this pane carries it instead. Without that the reader who hid the list
273+ ;; and then closed the last room has put the app away, not the list.
274+ (if @cells/hide-chat-list?
275+ [:card {}
276+ [:dim-label {:label "The chats list is hidden."}]
277+ [:button {:label "☰ Chats" :kind :primary
278+ :on-click actions/toggle-chat-list!}]]
279+ [:card {} [:dim-label {:label "Pick a conversation on the left."}]])])
280+
281+(defn split-screen
282+ "The chats list and the conversation side by side, for a window wide enough
283+ to hold both.
284+
285+ Same components as the narrow layout, in a row instead of one at a time: the
286+ list keeps its own scroll and its own tab bar, and the conversation keeps the
287+ compose bar pinned under a backlog that scrolls on its own.
288+
289+ Both panes take `:fill-height`: a column in a row is otherwise as tall as the
290+ row, which at the moment it is placed is one button and the list and the
291+ message backlog both size themselves against the height they are handed.
292+
293+ Only the list is given a width. The conversation takes what is left, rather
294+ than the window's width minus the list's: the list costs a little more than
295+ its 320 (its page has padding of its own), and a second column asking for
296+ more than remains is wrapped onto a row below painting the whole
297+ conversation off the bottom of the window, which reads exactly like a
298+ conversation that has gone missing."
299+ []
300+ ;; And the row itself takes the window, for the reason each narrow root
301+ ;; gives: a backend that sizes a box by what is in it hands two panes that
302+ ;; both asked for the rest of the screen the height of the taller one's
303+ ;; contents. Marking the children alone is not enough what they fill is
304+ ;; whatever the row got.
305+ [:hbox {:spacing 0 :wrap false :fill-height true}
306+ ;; :expand :cross so the width-request is the width. Both panes fill the
307+ ;; height, and a pane that fills the height is also told to take a share
308+ ;; of the row's spare WIDTH — which two panes split between them, so the
309+ ;; list came out half the window with its cards clipped at the fold and
310+ ;; the conversation squeezed beside it. Down a row, :cross is the vertical
311+ ;; axis alone: the height without the share. The conversation keeps
312+ ;; :fill-height and so keeps the slack, which is what the note above says
313+ ;; it takes.
314+ ;; The list pane, when the reader has not put it away. Hidden, the wrapper
315+ ;; stays and empties: a child that vanished would renumber the row for the
316+ ;; reconciler and take the conversation's scroll position with it every
317+ ;; time the list was toggled the same trick the people panel plays.
318+ ;; Empty, it asks for nothing: no width and no `:fill-height`, because a
319+ ;; column that fills the height is also a column that is there, and a
320+ ;; 320-point hole where the list was is not hiding it.
321+ (if @cells/hide-chat-list?
322+ [:vbox {:key :list}]
323+ [:vbox {:key :list :width-request sidebar-width :fill-height true
324+ :expand :cross}
325+ [chats-screen]])
326+ [:vbox {:key :chat :fill-height true}
327+ (if @cells/current
328+ [chat-screen]
329+ [no-chat-pane])]])
330+
331+(defn app []
332+ ;; Every branch in its own keyed wrapper, and every key different.
333+ ;;
334+ ;; Without them the screens are all one position in the tree, and moving
335+ ;; between two of them is a diff of one screen's children against another's:
336+ ;; the reconciler matches what it can by position and patches the rest, which
337+ ;; leaves nodes from the screen you left standing in the one you arrived at
338+ ;; the chats screen's join box turning up under its tab bar, an error box
339+ ;; from the conversation you were in a moment ago. A key that changes with
340+ ;; the screen makes the swap a swap: the old tree comes out whole and the new
341+ ;; one goes in whole.
342+ ;; And the dialog beside them all rather than instead of one of them. A
343+ ;; `dialog` node is not painted where it stands the window backend hands
344+ ;; it to libcosmic, which puts it over the middle of the window with what is
345+ ;; behind it dimmed so the screen under it keeps its place in the tree,
346+ ;; and its scroll position with it. The wrapper is always here and only its
347+ ;; child comes and goes, for the reason every other wrapper in this file
348+ ;; gives: a child that appeared and vanished would renumber the root.
349+ ;;
350+ ;; The terminal has no such thing, and a `dialog` tag it has not grown would
351+ ;; paint its contents inline at the bottom of the screen. So there it stays
352+ ;; a screen you go to and come back from, which is `profile-screen`.
353+ [:vbox {:key :root :fill-height true}
354+ ;; One dialog at a time, and a pinned profile outranks both pointers: it is
355+ ;; the only one of the three that was asked for by a press rather than by
356+ ;; where the pointer happens to be resting.
357+ [:vbox {:key :dialog}
358+ (when-not @terminal?
359+ (cond
360+ (or (actions/viewing) (actions/hovering)) [profile-dialog]
361+ @cells/reaction-hover [reactor-dialog]))]
362+ (cond
363+ @cells/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]]
364+ (and @terminal? (actions/viewing))
365+ [:vbox {:key :screen-profile} [profile-screen]]
366+ @cells/image-picker [:vbox {:key :screen-picker} [image-picker-screen]]
367+ ;; Wide enough for both, and on one of the two screens that are halves of
368+ ;; the same thing: the list and the conversation it opens. Discover and
369+ ;; settings stay whole screens they are somewhere else, not the other
370+ ;; half of here.
371+ (and (actions/wide?) (contains? #{:chats :chat} @cells/screen))
372+ [:vbox {:key :screen-split} [split-screen]]
373+ :else (case @cells/screen
374+ :connect [:vbox {:key :screen-connect} [connect-screen]]
375+ :chat [:vbox {:key :screen-chat} [chat-screen]]
376+ :discover [:vbox {:key :screen-discover} [discover-screen]]
377+ :settings [:vbox {:key :screen-settings} [settings-screen]]
378+ [:vbox {:key :screen-chats} [chats-screen]]))])
new file mode 100644
@@ -0,0 +1,378 @@
1+(ns frq.screens.app
2+ "The screens as one app: which one is showing, the split view that shows two
3+ at once, and the three that sit over the top of whichever it is.
4+
5+ The last of `frq.app` to move, and the piece that makes the rest of it
6+ reachable a phone was switching screens by hand before this, because the
7+ thing that decides had not been shared yet.
8+
9+ `profile-screen`, `lightbox-screen` and `image-picker-screen` come with it
10+ because `app` is what shows them. All three lean on things a phone does not
11+ have yet a picture cache, a profile fetch, a filesystem to browse and
12+ all three ask `frq.actions` for them, so what a phone gets is the shape they
13+ take when the answer is nil."
14+ (:require [clojure.string :as str]
15+ [frq.actions :as actions]
16+ [frq.cells :as cells]
17+ [frq.metrics :refer [terminal?]]
18+ [frq.rooms :as rooms]
19+ [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog
20+ sidebar-width]]
21+ [frq.screens.chats :refer [chats-screen conversation-row tab-bar]]
22+ [frq.screens.connect :refer [connect-screen error-note]]
23+ [frq.screens.settings :refer [discover-screen settings-screen
24+ tab-screen]]))
25+
26+(defn image-picker-screen
27+ "The pictures on this device, to send one of.
28+
29+ A screen rather than a panel over the compose bar: choosing a file is
30+ browsing, and browsing wants the window the backend paints in one layer
31+ anyway, so there is no overlay to put it in.
32+
33+ A directory at a time, PNG only. Which directories there are to start from is
34+ the platform's answer, not this screen's: a desktop opens on ~/Pictures, and
35+ a phone on what the app can read without a permission it has no code to ask
36+ for, which may be very little. Either way the list says what it found."
37+ []
38+ (let [dir @cells/image-picker
39+ {:keys [dirs files]} (actions/picker-entries dir)
40+ up (actions/parent-dir dir)]
41+ [:vbox {:spacing 8 :margin 12}
42+ [:hbox {:spacing 8}
43+ [:button {:label "← Back" :on-click actions/close-image-picker!}]
44+ [:title {:label "Send a picture"}]]
45+ [:dim-label {:label (str dir)}]
46+ [error-note]
47+ ;; The places worth starting from, always in reach: browsing into a corner
48+ ;; of the filesystem should not cost the way back to the pictures folder.
49+ [:hbox {:key :roots :spacing 6}
50+ (for [root (actions/picker-roots)]
51+ [:button {:key root
52+ :label (or (last (str/split root #"/")) root)
53+ :kind (if (= root dir) :primary :default)
54+ :on-click #(actions/browse! root)}])]
55+ [:separator {}]
56+ [:scroll {:scroll-key "image-picker"
57+ :orientation :vertical
58+ :reserve 40}
59+ [:vbox {:spacing 6}
60+ [:vbox {:key :up}
61+ (when up
62+ [:button {:label "⬆ Up" :on-click #(actions/browse! up)}])]
63+ [:vbox {:key :dirs :spacing 4}
64+ (for [d dirs]
65+ [:button {:key d
66+ :label (str "📁 " (last (str/split d #"/")))
67+ :on-click #(actions/browse! d)}])]
68+ ;; The picture itself is the button: a filename is not what anyone is
69+ ;; choosing between, and a thumbnail answers "is this the one" in a way
70+ ;; no name does.
71+ [:vbox {:key :files :spacing 6}
72+ (for [f files]
73+ [:hbox {:key f :spacing 8}
74+ [:image {:src f :max-height 72 :on-click #(actions/pick-image! f)}]
75+ [:button {:label (last (str/split f #"/"))
76+ :on-click #(actions/pick-image! f)}]])]
77+ [:vbox {:key :empty}
78+ (when (and (empty? dirs) (empty? files))
79+ [:dim-label {:label "No pictures here that this app can read."}])]]]]))
80+
81+;; What the people panel asks for, and what the conversation beside it has to
82+;; be told to leave. A column with `:fill-height` and no width takes the whole
83+;; row so the panel is only ever on screen if the message list is given a
84+;; width that stops short of it.
85+
86+(defn lightbox-screen
87+ "One picture, as big as the window will paint it.
88+
89+ A screen rather than an overlay: the tree backend paints in one layer and has
90+ no z-order to put something on top of everything else with. So the picture
91+ takes the window `:fit` gives it every point below the one row that is not
92+ it, centred and in proportion, scaled up as readily as down.
93+
94+ That row is the way back. Clicking the picture closes it too the same
95+ gesture that opened it but a way out you have to guess at is not one, and
96+ the row costs the picture a line."
97+ []
98+ (let [{:keys [path]} @cells/lightbox]
99+ [:vbox {:spacing 4 :margin 4}
100+ [:hbox {:spacing 8}
101+ [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]]
102+ [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]]))
103+
104+(defn- profile-dialog
105+ "Who someone is, as libcosmic's own dialog: centred over the window, with
106+ what you were reading dimmed behind it rather than replaced.
107+
108+ The window can do this and the terminal cannot, which is the whole reason
109+ there are two of these. `profile-screen` below is the terminal's, and says
110+ why it is a screen.
111+
112+ Both buttons are always here, the Bluesky one insensitive until there is a
113+ profile to open: a dialog whose second button appears a moment after it
114+ opens is a dialog that moves under the pointer, and the fetch lands whenever
115+ it lands.
116+
117+ Who it is about comes from either of two places, and that with the
118+ modality below is the whole of what hovering and pressing a face do
119+ differently. Resting on one sets `hovering`, which the pointer takes away
120+ again when it leaves; pressing one sets `viewing`, which nothing takes away
121+ but Close. `viewing` is read first, so a pinned profile is not swapped out
122+ from under the reader by a face the pointer crosses on the way to it.
123+
124+ And a dialog the pointer is holding open is not modal. A modal one makes
125+ the window underneath it deaf libcosmic wraps the app in a popover that
126+ hands its content an `Unavailable` cursor while a popup is up so the face
127+ that opened it never hears the pointer leave, and what a hover opened could
128+ never close itself. Non-modal, the face keeps hearing, and moving away shuts
129+ it. A pinned one is modal, which is what being pinned means: it is the thing
130+ on the screen until it is dismissed."
131+ []
132+ (let [pinned? (some? (actions/viewing))
133+ {:keys [nick actor]} (or (actions/viewing) (actions/hovering))
134+ _ (actions/profile-tick)
135+ _ @actions/media-tick
136+ pr (actions/profile-entry actor)
137+ ready? (= :ready (:status pr))
138+ display (or (:display-name pr) nick)
139+ url (when ready? (actions/profile-web-url pr))]
140+ [:dialog {:label display :max-width 520 :modal pinned?
141+ :on-hover actions/profile-enter-dialog!
142+ :on-unhover actions/profile-leave-dialog!}
143+ ;; The body is the screen's card without its heading: the dialog's own
144+ ;; title is the name now, so repeating it under the picture is a line
145+ ;; that says nothing.
146+ [:vbox {:key :body :spacing 8}
147+ [:hbox {:spacing 12}
148+ [:avatar {:label nick
149+ :src (or (actions/avatar-ready actor) "")
150+ :size 72}]
151+ [:vbox {:spacing 2}
152+ [:vbox {:key :nick}
153+ (when (not= display nick) [:dim-label {:label nick}])]
154+ [:vbox {:key :handle}
155+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
156+ [:label {:label (str "@" h)}])]
157+ [:vbox {:key :did}
158+ (when-let [did (:did pr)] [:dim-label {:label did}])]]]
159+ [:vbox {:key :bio :spacing 2}
160+ (when-let [bio (and ready? (:description pr))]
161+ (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))]
162+ [:label {:key i :label line}]))]
163+ [:vbox {:key :stats}
164+ (when-let [line (and ready? (actions/profile-stats-line pr))]
165+ [:dim-label {:label line}])]
166+ [:vbox {:key :status :spacing 4}
167+ (cond
168+ (nil? actor)
169+ [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
170+
171+ (= :loading (:status pr))
172+ [:hbox {:spacing 8}
173+ [:spinner {}]
174+ [:dim-label {:label "Loading Bluesky profile…"}]]
175+
176+ (= :failed (:status pr))
177+ [:dim-label {:label "No Bluesky profile found"}])]
178+]
179+ ;; `slot` is where libcosmic puts a button: the two actions go to the
180+ ;; foot of the dialog, and anything else here would be another control
181+ ;; stacked in the body.
182+ ;;
183+ ;; Both are here whether the profile is pinned or only hovered. They were
184+ ;; hidden while hovering, back when a hover could not be walked into: the
185+ ;; dialog reports its own pointer now, so moving towards a button in it
186+ ;; keeps it open instead of closing it, and a button you can reach is a
187+ ;; button worth drawing.
188+ [:button {:key :close :slot "primary"
189+ :label (if pinned? "Close" "Dismiss")
190+ :on-click actions/profile-dismiss!}]
191+ [:button {:key :web :slot "secondary" :label "Bluesky ↗"
192+ :sensitive (boolean url)
193+ :on-click #(when url (actions/open-url! url))}]]))
194+
195+(defn profile-screen
196+ "Who someone is: sleek's peer profile modal, as a screen.
197+
198+ A screen rather than a layer for the same reason the lightbox is one the
199+ tree backend paints in a single layer, with no z-order to hang a modal from.
200+ So the back row does what sleek's ✕ and backdrop did.
201+
202+ The picture is the same cached file the chat column draws, at a size worth
203+ looking at; the bio is split into a label a line, so a bio that was written
204+ as several lines is still several lines here."
205+ []
206+ (let [{:keys [nick actor]} (actions/viewing)
207+ ;; Reading both ticks subscribes this screen to the two fetches it is
208+ ;; waiting on: the profile itself, and the picture on it.
209+ _ (actions/profile-tick)
210+ _ @actions/media-tick
211+ pr (actions/profile-entry actor)
212+ ready? (= :ready (:status pr))
213+ display (or (:display-name pr) nick)
214+ url (when ready? (actions/profile-web-url pr))]
215+ [:page {:max-width 520}
216+ [:hbox {:spacing 8}
217+ [:button {:label "← Back" :on-click actions/profile-close!}]]
218+ [:card {}
219+ [:hbox {:spacing 12}
220+ [:avatar {:label nick
221+ :src (or (actions/avatar-ready actor) "")
222+ :size 72}]
223+ [:vbox {:spacing 2}
224+ [:title-2 {:label display}]
225+ ;; The nick under the display name only when they differ repeating
226+ ;; it is a line that says nothing.
227+ [:vbox {:key :nick}
228+ (when (not= display nick) [:dim-label {:label nick}])]
229+ [:vbox {:key :handle}
230+ (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
231+ [:label {:label (str "@" h)}])]]]
232+ ;; The DID is the identity itself, and outlasts both the nick and the
233+ ;; handle so it is on the screen, in full, rather than implied.
234+ [:vbox {:key :did :spacing 2}
235+ (when-let [did (:did pr)] [:dim-label {:label did}])]
236+ [:vbox {:key :bio :spacing 2}
237+ (when-let [bio (and ready? (:description pr))]
238+ (for [[i line] (map-indexed vector (str/split-lines (actions/profile-truncate bio 600)))]
239+ [:label {:key i :label line}]))]
240+ [:vbox {:key :stats}
241+ (when-let [line (and ready? (actions/profile-stats-line pr))]
242+ [:dim-label {:label line}])]
243+ ;; What is happening, or why nothing is: a guest has no identity to look
244+ ;; up, and saying so is a better answer than a spinner that never lands.
245+ [:vbox {:key :status :spacing 4}
246+ (cond
247+ (nil? actor)
248+ [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
249+
250+ (= :loading (:status pr))
251+ [:hbox {:spacing 8}
252+ [:spinner {}]
253+ [:dim-label {:label "Loading Bluesky profile…"}]]
254+
255+ (= :failed (:status pr))
256+ [:dim-label {:label "No Bluesky profile found"}])]
257+ [:vbox {:key :actions}
258+ (when url
259+ [:button {:label "Bluesky ↗"
260+ :kind :primary
261+ :on-click #(actions/open-url! url)}])]]]))
262+
263+(defn- no-chat-pane
264+ "What fills the second pane before a conversation has been picked. The pane
265+ is there either way a list that widened into two columns and back again as
266+ channels were opened would be a worse answer than an empty half."
267+ []
268+ [:vbox {:spacing 8 :margin 12}
269+ [:title {:label "frq"}]
270+ ;; With the list folded away there is nothing on the left to pick from, and
271+ ;; no conversation here carrying the switch that would bring it back so
272+ ;; this pane carries it instead. Without that the reader who hid the list
273+ ;; and then closed the last room has put the app away, not the list.
274+ (if @cells/hide-chat-list?
275+ [:card {}
276+ [:dim-label {:label "The chats list is hidden."}]
277+ [:button {:label "☰ Chats" :kind :primary
278+ :on-click actions/toggle-chat-list!}]]
279+ [:card {} [:dim-label {:label "Pick a conversation on the left."}]])])
280+
281+(defn split-screen
282+ "The chats list and the conversation side by side, for a window wide enough
283+ to hold both.
284+
285+ Same components as the narrow layout, in a row instead of one at a time: the
286+ list keeps its own scroll and its own tab bar, and the conversation keeps the
287+ compose bar pinned under a backlog that scrolls on its own.
288+
289+ Both panes take `:fill-height`: a column in a row is otherwise as tall as the
290+ row, which at the moment it is placed is one button and the list and the
291+ message backlog both size themselves against the height they are handed.
292+
293+ Only the list is given a width. The conversation takes what is left, rather
294+ than the window's width minus the list's: the list costs a little more than
295+ its 320 (its page has padding of its own), and a second column asking for
296+ more than remains is wrapped onto a row below painting the whole
297+ conversation off the bottom of the window, which reads exactly like a
298+ conversation that has gone missing."
299+ []
300+ ;; And the row itself takes the window, for the reason each narrow root
301+ ;; gives: a backend that sizes a box by what is in it hands two panes that
302+ ;; both asked for the rest of the screen the height of the taller one's
303+ ;; contents. Marking the children alone is not enough what they fill is
304+ ;; whatever the row got.
305+ [:hbox {:spacing 0 :wrap false :fill-height true}
306+ ;; :expand :cross so the width-request is the width. Both panes fill the
307+ ;; height, and a pane that fills the height is also told to take a share
308+ ;; of the row's spare WIDTH — which two panes split between them, so the
309+ ;; list came out half the window with its cards clipped at the fold and
310+ ;; the conversation squeezed beside it. Down a row, :cross is the vertical
311+ ;; axis alone: the height without the share. The conversation keeps
312+ ;; :fill-height and so keeps the slack, which is what the note above says
313+ ;; it takes.
314+ ;; The list pane, when the reader has not put it away. Hidden, the wrapper
315+ ;; stays and empties: a child that vanished would renumber the row for the
316+ ;; reconciler and take the conversation's scroll position with it every
317+ ;; time the list was toggled the same trick the people panel plays.
318+ ;; Empty, it asks for nothing: no width and no `:fill-height`, because a
319+ ;; column that fills the height is also a column that is there, and a
320+ ;; 320-point hole where the list was is not hiding it.
321+ (if @cells/hide-chat-list?
322+ [:vbox {:key :list}]
323+ [:vbox {:key :list :width-request sidebar-width :fill-height true
324+ :expand :cross}
325+ [chats-screen]])
326+ [:vbox {:key :chat :fill-height true}
327+ (if @cells/current
328+ [chat-screen]
329+ [no-chat-pane])]])
330+
331+(defn app []
332+ ;; Every branch in its own keyed wrapper, and every key different.
333+ ;;
334+ ;; Without them the screens are all one position in the tree, and moving
335+ ;; between two of them is a diff of one screen's children against another's:
336+ ;; the reconciler matches what it can by position and patches the rest, which
337+ ;; leaves nodes from the screen you left standing in the one you arrived at
338+ ;; the chats screen's join box turning up under its tab bar, an error box
339+ ;; from the conversation you were in a moment ago. A key that changes with
340+ ;; the screen makes the swap a swap: the old tree comes out whole and the new
341+ ;; one goes in whole.
342+ ;; And the dialog beside them all rather than instead of one of them. A
343+ ;; `dialog` node is not painted where it stands the window backend hands
344+ ;; it to libcosmic, which puts it over the middle of the window with what is
345+ ;; behind it dimmed so the screen under it keeps its place in the tree,
346+ ;; and its scroll position with it. The wrapper is always here and only its
347+ ;; child comes and goes, for the reason every other wrapper in this file
348+ ;; gives: a child that appeared and vanished would renumber the root.
349+ ;;
350+ ;; The terminal has no such thing, and a `dialog` tag it has not grown would
351+ ;; paint its contents inline at the bottom of the screen. So there it stays
352+ ;; a screen you go to and come back from, which is `profile-screen`.
353+ [:vbox {:key :root :fill-height true}
354+ ;; One dialog at a time, and a pinned profile outranks both pointers: it is
355+ ;; the only one of the three that was asked for by a press rather than by
356+ ;; where the pointer happens to be resting.
357+ [:vbox {:key :dialog}
358+ (when-not @terminal?
359+ (cond
360+ (or (actions/viewing) (actions/hovering)) [profile-dialog]
361+ @cells/reaction-hover [reactor-dialog]))]
362+ (cond
363+ @cells/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]]
364+ (and @terminal? (actions/viewing))
365+ [:vbox {:key :screen-profile} [profile-screen]]
366+ @cells/image-picker [:vbox {:key :screen-picker} [image-picker-screen]]
367+ ;; Wide enough for both, and on one of the two screens that are halves of
368+ ;; the same thing: the list and the conversation it opens. Discover and
369+ ;; settings stay whole screens they are somewhere else, not the other
370+ ;; half of here.
371+ (and (actions/wide?) (contains? #{:chats :chat} @cells/screen))
372+ [:vbox {:key :screen-split} [split-screen]]
373+ :else (case @cells/screen
374+ :connect [:vbox {:key :screen-connect} [connect-screen]]
375+ :chat [:vbox {:key :screen-chat} [chat-screen]]
376+ :discover [:vbox {:key :screen-discover} [discover-screen]]
377+ :settings [:vbox {:key :screen-settings} [settings-screen]]
378+ [:vbox {:key :screen-chats} [chats-screen]]))])
modified flutter/README.md +9 -5
@@ -135,13 +135,17 @@ screen rather than the subtree that read it. Fine at this size.
135135 reads is `frq.cells` and what it calls is `frq.actions`, and each platform
136136 fills those in: `frq.state`'s reducers on the desktop, dart:io here.
137137
138-## Five screens shared, all drawn on both
138+## Every screen shared, and the root that picks between them
139139
140-`frq.screens.connect`, `frq.screens.chats`, `frq.screens.chat` and
141-`frq.screens.settings` — the last carrying Discover, Settings and the frame the
142-tab bar sits in — are in `common/`, with the cells under them in `frq.cells`, the derivations in
140+All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`,
141+`settings` (with Discover and the tab frame) and `app`, which carries the
142+split view, the three dialogs and the decision about which screen is showing.
143+The phone renders `[screens/app]` and nothing else; it was switching by hand
144+until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in
143145 `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen
144-cannot do itself behind `frq.actions`. `frq.app` is 565 lines and was 1,744.
146+cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that
147+cannot move: `derived` and the two asset lookups, which are a glimmer reaction
148+over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`.
145149
146150 What `Length::Fill` means took four goes to get right, and the rule it ended
147151 at is worth stating once: a child that fills is Flutter's `Expanded`, the
@@ -135,13 +135,17 @@ screen rather than the subtree that read it. Fine at this size.
135 reads is `frq.cells` and what it calls is `frq.actions`, and each platform135 reads is `frq.cells` and what it calls is `frq.actions`, and each platform
136 fills those in: `frq.state`'s reducers on the desktop, dart:io here.136 fills those in: `frq.state`'s reducers on the desktop, dart:io here.
137 137
138-## Five screens shared, all drawn on both138+## Every screen shared, and the root that picks between them
139 139
140-`frq.screens.connect`, `frq.screens.chats`, `frq.screens.chat` and140+All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`,
141-`frq.screens.settings` — the last carrying Discover, Settings and the frame the141+`settings` (with Discover and the tab frame) and `app`, which carries the
142-tab bar sits in — are in `common/`, with the cells under them in `frq.cells`, the derivations in142+split view, the three dialogs and the decision about which screen is showing.
143+The phone renders `[screens/app]` and nothing else; it was switching by hand
144+until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in
143 `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen145 `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen
144-cannot do itself behind `frq.actions`. `frq.app` is 565 lines and was 1,744.146+cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that
147+cannot move: `derived` and the two asset lookups, which are a glimmer reaction
148+over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`.
145 149
146 What `Length::Fill` means took four goes to get right, and the rule it ended150 What `Length::Fill` means took four goes to get right, and the rule it ended
147 at is worth stating once: a child that fills is Flutter's `Expanded`, the151 at is worth stating once: a child that fills is Flutter's `Expanded`, the
modified flutter/src/frq/hiccup.cljd +20 -7
@@ -81,6 +81,18 @@
8181
8282 (declare render fills-row?)
8383
84+(defn- expand
85+ "A component vector reduced to the tags it produces.
86+
87+ `[chat-screen]` is a function, and asking whether it fills means calling it —
88+ the root wraps every screen in a plain `:vbox`, and without this the wrapper
89+ could not see that the screen inside it wants the whole height. Expanded
90+ once and both asked and rendered, so the component runs once either way."
91+ [node]
92+ (if (and (vector? node) (seq node) (not (keyword? (first node))))
93+ (expand (apply (first node) (rest node)))
94+ node))
95+
8496 (defn- fills-row?
8597 "Whether a node takes the width its row has left over.
8698
@@ -159,7 +171,7 @@
159171 ;; so the row must be given a height before the column can take
160172 ;; what is left of it.
161173 (and (contains? #{:vbox :card :hbox} tag)
162- (boolean (some fills-column? (body node))))))))
174+ (boolean (some #(fills-column? (expand %)) (body node))))))))
163175
164176 (defn- children
165177 "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in
@@ -180,7 +192,8 @@
180192 `fills?` says which, so a Row asks about width and a Column about height."
181193 [fills? nodes]
182194 (persistent!
183- (reduce (fn [acc n]
195+ (reduce (fn [acc n0]
196+ (let [n (expand n0)]
184197 (cond (nil? n) acc
185198 (seq? n) (reduce conj! acc (flexed fills? n))
186199 (fills? n) (conj! acc (m/Expanded .child (render n)))
@@ -193,7 +206,7 @@
193206 (conj! acc (m/Expanded .child (render n)))
194207 (and (identical? fills? fills-row?) (flexes-in-row? n))
195208 (conj! acc (m/Flexible .child (render n)))
196- :else (conj! acc (render n))))
209+ :else (conj! acc (render n)))))
197210 (transient []) nodes)))
198211
199212 (defn- txt
@@ -258,7 +271,7 @@
258271 ;; and a `:scroll` handing their children unbounded space.
259272 col (fn [sp nodes]
260273 (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
261- .mainAxisSize (if (some fills-column? nodes)
274+ .mainAxisSize (if (some #(fills-column? (expand %)) nodes)
262275 m/MainAxisSize.max
263276 m/MainAxisSize.min)
264277 .spacing sp
@@ -273,7 +286,7 @@
273286 ;; Max when something inside wants the space left over: a
274287 ;; column sized to its contents has none to give.
275288 .mainAxisSize (if (or (:fill-height p)
276- (some fills-column? (body node)))
289+ (some #(fills-column? (expand %)) (body node)))
277290 m/MainAxisSize.max
278291 m/MainAxisSize.min)
279292 .spacing (dbl (:spacing p) 0.0)
@@ -320,12 +333,12 @@
320333 ;; Stretch when a child fills vertically: a Row hands its children a
321334 ;; loose height by default, and a column that wants what is left of it
322335 ;; needs a tight one.
323- .crossAxisAlignment (if (some fills-column? (body node))
336+ .crossAxisAlignment (if (some #(fills-column? (expand %)) (body node))
324337 m/CrossAxisAlignment.stretch
325338 m/CrossAxisAlignment.center)
326339 ;; A row holding something that fills has to be given the width to
327340 ;; divide, so it is max rather than min whenever a child asks.
328- .mainAxisSize (if (some fills-row? (body node))
341+ .mainAxisSize (if (some #(fills-row? (expand %)) (body node))
329342 m/MainAxisSize.max
330343 m/MainAxisSize.min)
331344 .spacing (dbl (:spacing p) 0.0)
@@ -81,6 +81,18 @@
81 81
82 (declare render fills-row?)82 (declare render fills-row?)
83 83
84+(defn- expand
85+ "A component vector reduced to the tags it produces.
86+
87+ `[chat-screen]` is a function, and asking whether it fills means calling it —
88+ the root wraps every screen in a plain `:vbox`, and without this the wrapper
89+ could not see that the screen inside it wants the whole height. Expanded
90+ once and both asked and rendered, so the component runs once either way."
91+ [node]
92+ (if (and (vector? node) (seq node) (not (keyword? (first node))))
93+ (expand (apply (first node) (rest node)))
94+ node))
95+
84 (defn- fills-row?96 (defn- fills-row?
85 "Whether a node takes the width its row has left over.97 "Whether a node takes the width its row has left over.
86 98
@@ -159,7 +171,7 @@
159 ;; so the row must be given a height before the column can take171 ;; so the row must be given a height before the column can take
160 ;; what is left of it.172 ;; what is left of it.
161 (and (contains? #{:vbox :card :hbox} tag)173 (and (contains? #{:vbox :card :hbox} tag)
162- (boolean (some fills-column? (body node))))))))174+ (boolean (some #(fills-column? (expand %)) (body node))))))))
163 175
164 (defn- children176 (defn- children
165 "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in177 "Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in
@@ -180,7 +192,8 @@
180 `fills?` says which, so a Row asks about width and a Column about height."192 `fills?` says which, so a Row asks about width and a Column about height."
181 [fills? nodes]193 [fills? nodes]
182 (persistent!194 (persistent!
183- (reduce (fn [acc n]195+ (reduce (fn [acc n0]
196+ (let [n (expand n0)]
184 (cond (nil? n) acc197 (cond (nil? n) acc
185 (seq? n) (reduce conj! acc (flexed fills? n))198 (seq? n) (reduce conj! acc (flexed fills? n))
186 (fills? n) (conj! acc (m/Expanded .child (render n)))199 (fills? n) (conj! acc (m/Expanded .child (render n)))
@@ -193,7 +206,7 @@
193 (conj! acc (m/Expanded .child (render n)))206 (conj! acc (m/Expanded .child (render n)))
194 (and (identical? fills? fills-row?) (flexes-in-row? n))207 (and (identical? fills? fills-row?) (flexes-in-row? n))
195 (conj! acc (m/Flexible .child (render n)))208 (conj! acc (m/Flexible .child (render n)))
196- :else (conj! acc (render n))))209+ :else (conj! acc (render n)))))
197 (transient []) nodes)))210 (transient []) nodes)))
198 211
199 (defn- txt212 (defn- txt
@@ -258,7 +271,7 @@
258 ;; and a `:scroll` handing their children unbounded space.271 ;; and a `:scroll` handing their children unbounded space.
259 col (fn [sp nodes]272 col (fn [sp nodes]
260 (m/Column .crossAxisAlignment m/CrossAxisAlignment.start273 (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
261- .mainAxisSize (if (some fills-column? nodes)274+ .mainAxisSize (if (some #(fills-column? (expand %)) nodes)
262 m/MainAxisSize.max275 m/MainAxisSize.max
263 m/MainAxisSize.min)276 m/MainAxisSize.min)
264 .spacing sp277 .spacing sp
@@ -273,7 +286,7 @@
273 ;; Max when something inside wants the space left over: a286 ;; Max when something inside wants the space left over: a
274 ;; column sized to its contents has none to give.287 ;; column sized to its contents has none to give.
275 .mainAxisSize (if (or (:fill-height p)288 .mainAxisSize (if (or (:fill-height p)
276- (some fills-column? (body node)))289+ (some #(fills-column? (expand %)) (body node)))
277 m/MainAxisSize.max290 m/MainAxisSize.max
278 m/MainAxisSize.min)291 m/MainAxisSize.min)
279 .spacing (dbl (:spacing p) 0.0)292 .spacing (dbl (:spacing p) 0.0)
@@ -320,12 +333,12 @@
320 ;; Stretch when a child fills vertically: a Row hands its children a333 ;; Stretch when a child fills vertically: a Row hands its children a
321 ;; loose height by default, and a column that wants what is left of it334 ;; loose height by default, and a column that wants what is left of it
322 ;; needs a tight one.335 ;; needs a tight one.
323- .crossAxisAlignment (if (some fills-column? (body node))336+ .crossAxisAlignment (if (some #(fills-column? (expand %)) (body node))
324 m/CrossAxisAlignment.stretch337 m/CrossAxisAlignment.stretch
325 m/CrossAxisAlignment.center)338 m/CrossAxisAlignment.center)
326 ;; A row holding something that fills has to be given the width to339 ;; A row holding something that fills has to be given the width to
327 ;; divide, so it is max rather than min whenever a child asks.340 ;; divide, so it is max rather than min whenever a child asks.
328- .mainAxisSize (if (some fills-row? (body node))341+ .mainAxisSize (if (some #(fills-row? (expand %)) (body node))
329 m/MainAxisSize.max342 m/MainAxisSize.max
330 m/MainAxisSize.min)343 m/MainAxisSize.min)
331 .spacing (dbl (:spacing p) 0.0)344 .spacing (dbl (:spacing p) 0.0)
modified flutter/src/frq/main.cljd +5 -7
@@ -33,6 +33,7 @@
3333 [frq.screens.chats :as chats]
3434 [frq.screens.chat :as chat]
3535 [frq.screens.settings :as settings]
36+ [frq.screens.app :as screens]
3637 [frq.rooms :as rooms]
3738 [frq.irc.parse :as irc]))
3839
@@ -331,10 +332,7 @@
331332 ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it —
332333 ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat
333334 ;; in an unbounded column.
334- (h/render (case @cells/screen
335- :connect [connect/connect-screen]
336- :chats [chats/chats-screen]
337- :chat [chat/chat-screen]
338- :discover [settings/discover-screen]
339- :settings [settings/settings-screen]
340- [connected-screen]))))))
335+ (h/render
336+ ;; `frq.screens.app` decides which screen shows, the same way it does
337+ ;; on the desktop. The phone was switching by hand until this moved.
338+ [screens/app])))))
@@ -33,6 +33,7 @@
33 [frq.screens.chats :as chats]33 [frq.screens.chats :as chats]
34 [frq.screens.chat :as chat]34 [frq.screens.chat :as chat]
35 [frq.screens.settings :as settings]35 [frq.screens.settings :as settings]
36+ [frq.screens.app :as screens]
36 [frq.rooms :as rooms]37 [frq.rooms :as rooms]
37 [frq.irc.parse :as irc]))38 [frq.irc.parse :as irc]))
38 39
@@ -331,10 +332,7 @@
331 ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it —332 ;; `:vbox :fill-height` wants the bounded height the Scaffold gives it —
332 ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat333 ;; wrapping the tree took that away, and `:scroll`'s Expanded then sat
333 ;; in an unbounded column.334 ;; in an unbounded column.
334- (h/render (case @cells/screen335+ (h/render
335- :connect [connect/connect-screen]336+ ;; `frq.screens.app` decides which screen shows, the same way it does
336- :chats [chats/chats-screen]337+ ;; on the desktop. The phone was switching by hand until this moved.
337- :chat [chat/chat-screen]338+ [screens/app])))))
338- :discover [settings/discover-screen]
339- :settings [settings/settings-screen]
340- [connected-screen]))))))
modified src/frq/app.clj +15 -354
@@ -37,6 +37,7 @@
3737 ;; phone renders this very file.
3838 [frq.screens.chats :refer [below-list chats-screen conversation-row
3939 preview-line tab-bar]]
40+ [frq.screens.app :as screens]
4041 [frq.screens.settings :refer [discover-screen settings-screen
4142 tab-screen]]
4243 [frq.screens.chat :refer [chat-screen emoji-picker message-row
@@ -112,367 +113,14 @@
112113
113114 ;; ---------------------------------------------------------------- chat
114115
115-(defn- profile-dialog
116- "Who someone is, as libcosmic's own dialog: centred over the window, with
117- what you were reading dimmed behind it rather than replaced.
118-
119- The window can do this and the terminal cannot, which is the whole reason
120- there are two of these. `profile-screen` below is the terminal's, and says
121- why it is a screen.
122-
123- Both buttons are always here, the Bluesky one insensitive until there is a
124- profile to open: a dialog whose second button appears a moment after it
125- opens is a dialog that moves under the pointer, and the fetch lands whenever
126- it lands.
127-
128- Who it is about comes from either of two places, and that — with the
129- modality below — is the whole of what hovering and pressing a face do
130- differently. Resting on one sets `hovering`, which the pointer takes away
131- again when it leaves; pressing one sets `viewing`, which nothing takes away
132- but Close. `viewing` is read first, so a pinned profile is not swapped out
133- from under the reader by a face the pointer crosses on the way to it.
134-
135- And a dialog the pointer is holding open is not modal. A modal one makes
136- the window underneath it deaf — libcosmic wraps the app in a popover that
137- hands its content an `Unavailable` cursor while a popup is up — so the face
138- that opened it never hears the pointer leave, and what a hover opened could
139- never close itself. Non-modal, the face keeps hearing, and moving away shuts
140- it. A pinned one is modal, which is what being pinned means: it is the thing
141- on the screen until it is dismissed."
142- []
143- (let [pinned? (some? @profile/viewing)
144- {:keys [nick actor]} (or @profile/viewing @profile/hovering)
145- _ @profile/tick
146- _ @s/media-tick
147- pr (profile/entry actor)
148- ready? (= :ready (:status pr))
149- display (or (:display-name pr) nick)
150- url (when ready? (profile/web-url pr))]
151- [:dialog {:label display :max-width 520 :modal pinned?
152- :on-hover profile/enter-dialog!
153- :on-unhover profile/leave-dialog!}
154- ;; The body is the screen's card without its heading: the dialog's own
155- ;; title is the name now, so repeating it under the picture is a line
156- ;; that says nothing.
157- [:vbox {:key :body :spacing 8}
158- [:hbox {:spacing 12}
159- [:avatar {:label nick
160- :src (or (avatars/path-when-ready actor) "")
161- :size 72}]
162- [:vbox {:spacing 2}
163- [:vbox {:key :nick}
164- (when (not= display nick) [:dim-label {:label nick}])]
165- [:vbox {:key :handle}
166- (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
167- [:label {:label (str "@" h)}])]
168- [:vbox {:key :did}
169- (when-let [did (:did pr)] [:dim-label {:label did}])]]]
170- [:vbox {:key :bio :spacing 2}
171- (when-let [bio (and ready? (:description pr))]
172- (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))]
173- [:label {:key i :label line}]))]
174- [:vbox {:key :stats}
175- (when-let [line (and ready? (profile/stats-line pr))]
176- [:dim-label {:label line}])]
177- [:vbox {:key :status :spacing 4}
178- (cond
179- (nil? actor)
180- [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
181-
182- (= :loading (:status pr))
183- [:hbox {:spacing 8}
184- [:spinner {}]
185- [:dim-label {:label "Loading Bluesky profile…"}]]
186-
187- (= :failed (:status pr))
188- [:dim-label {:label "No Bluesky profile found"}])]
189-]
190- ;; `slot` is where libcosmic puts a button: the two actions go to the
191- ;; foot of the dialog, and anything else here would be another control
192- ;; stacked in the body.
193- ;;
194- ;; Both are here whether the profile is pinned or only hovered. They were
195- ;; hidden while hovering, back when a hover could not be walked into: the
196- ;; dialog reports its own pointer now, so moving towards a button in it
197- ;; keeps it open instead of closing it, and a button you can reach is a
198- ;; button worth drawing.
199- [:button {:key :close :slot "primary"
200- :label (if pinned? "Close" "Dismiss")
201- :on-click profile/dismiss!}]
202- [:button {:key :web :slot "secondary" :label "Bluesky ↗"
203- :sensitive (boolean url)
204- :on-click #(when url (platform/open-url! url))}]]))
205-
206-(defn profile-screen
207- "Who someone is: sleek's peer profile modal, as a screen.
208-
209- A screen rather than a layer for the same reason the lightbox is one — the
210- tree backend paints in a single layer, with no z-order to hang a modal from.
211- So the back row does what sleek's ✕ and backdrop did.
212-
213- The picture is the same cached file the chat column draws, at a size worth
214- looking at; the bio is split into a label a line, so a bio that was written
215- as several lines is still several lines here."
216- []
217- (let [{:keys [nick actor]} @profile/viewing
218- ;; Reading both ticks subscribes this screen to the two fetches it is
219- ;; waiting on: the profile itself, and the picture on it.
220- _ @profile/tick
221- _ @s/media-tick
222- pr (profile/entry actor)
223- ready? (= :ready (:status pr))
224- display (or (:display-name pr) nick)
225- url (when ready? (profile/web-url pr))]
226- [:page {:max-width 520}
227- [:hbox {:spacing 8}
228- [:button {:label "← Back" :on-click profile/close!}]]
229- [:card {}
230- [:hbox {:spacing 12}
231- [:avatar {:label nick
232- :src (or (avatars/path-when-ready actor) "")
233- :size 72}]
234- [:vbox {:spacing 2}
235- [:title-2 {:label display}]
236- ;; The nick under the display name only when they differ — repeating
237- ;; it is a line that says nothing.
238- [:vbox {:key :nick}
239- (when (not= display nick) [:dim-label {:label nick}])]
240- [:vbox {:key :handle}
241- (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
242- [:label {:label (str "@" h)}])]]]
243- ;; The DID is the identity itself, and outlasts both the nick and the
244- ;; handle — so it is on the screen, in full, rather than implied.
245- [:vbox {:key :did :spacing 2}
246- (when-let [did (:did pr)] [:dim-label {:label did}])]
247- [:vbox {:key :bio :spacing 2}
248- (when-let [bio (and ready? (:description pr))]
249- (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))]
250- [:label {:key i :label line}]))]
251- [:vbox {:key :stats}
252- (when-let [line (and ready? (profile/stats-line pr))]
253- [:dim-label {:label line}])]
254- ;; What is happening, or why nothing is: a guest has no identity to look
255- ;; up, and saying so is a better answer than a spinner that never lands.
256- [:vbox {:key :status :spacing 4}
257- (cond
258- (nil? actor)
259- [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
260-
261- (= :loading (:status pr))
262- [:hbox {:spacing 8}
263- [:spinner {}]
264- [:dim-label {:label "Loading Bluesky profile…"}]]
265-
266- (= :failed (:status pr))
267- [:dim-label {:label "No Bluesky profile found"}])]
268- [:vbox {:key :actions}
269- (when url
270- [:button {:label "Bluesky ↗"
271- :kind :primary
272- :on-click #(platform/open-url! url)}])]]]))
273-
274-(defn lightbox-screen
275- "One picture, as big as the window will paint it.
276-
277- A screen rather than an overlay: the tree backend paints in one layer and has
278- no z-order to put something on top of everything else with. So the picture
279- takes the window — `:fit` gives it every point below the one row that is not
280- it, centred and in proportion, scaled up as readily as down.
281-
282- That row is the way back. Clicking the picture closes it too — the same
283- gesture that opened it — but a way out you have to guess at is not one, and
284- the row costs the picture a line."
285- []
286- (let [{:keys [path]} @s/lightbox]
287- [:vbox {:spacing 4 :margin 4}
288- [:hbox {:spacing 8}
289- [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]
290- [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
291-
292-(defn image-picker-screen
293- "The pictures on this device, to send one of.
294-
295- A screen rather than a panel over the compose bar: choosing a file is
296- browsing, and browsing wants the window — the backend paints in one layer
297- anyway, so there is no overlay to put it in.
298-
299- A directory at a time, PNG only. Which directories there are to start from is
300- the platform's answer, not this screen's: a desktop opens on ~/Pictures, and
301- a phone on what the app can read without a permission it has no code to ask
302- for, which may be very little. Either way the list says what it found."
303- []
304- (let [dir @s/image-picker
305- {:keys [dirs files]} (s/picker-entries dir)
306- up (s/parent-dir dir)]
307- [:vbox {:spacing 8 :margin 12}
308- [:hbox {:spacing 8}
309- [:button {:label "← Back" :on-click s/close-image-picker!}]
310- [:title {:label "Send a picture"}]]
311- [:dim-label {:label (str dir)}]
312- [error-note]
313- ;; The places worth starting from, always in reach: browsing into a corner
314- ;; of the filesystem should not cost the way back to the pictures folder.
315- [:hbox {:key :roots :spacing 6}
316- (for [root (s/picker-roots)]
317- [:button {:key root
318- :label (or (last (str/split root #"/")) root)
319- :kind (if (= root dir) :primary :default)
320- :on-click #(s/browse! root)}])]
321- [:separator {}]
322- [:scroll {:scroll-key "image-picker"
323- :orientation :vertical
324- :reserve 40}
325- [:vbox {:spacing 6}
326- [:vbox {:key :up}
327- (when up
328- [:button {:label "⬆ Up" :on-click #(s/browse! up)}])]
329- [:vbox {:key :dirs :spacing 4}
330- (for [d dirs]
331- [:button {:key d
332- :label (str "📁 " (last (str/split d #"/")))
333- :on-click #(s/browse! d)}])]
334- ;; The picture itself is the button: a filename is not what anyone is
335- ;; choosing between, and a thumbnail answers "is this the one" in a way
336- ;; no name does.
337- [:vbox {:key :files :spacing 6}
338- (for [f files]
339- [:hbox {:key f :spacing 8}
340- [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}]
341- [:button {:label (last (str/split f #"/"))
342- :on-click #(s/pick-image! f)}]])]
343- [:vbox {:key :empty}
344- (when (and (empty? dirs) (empty? files))
345- [:dim-label {:label "No pictures here that this app can read."}])]]]]))
346-
347-;; What the people panel asks for, and what the conversation beside it has to
348-;; be told to leave. A column with `:fill-height` and no width takes the whole
349-;; row — so the panel is only ever on screen if the message list is given a
350-;; width that stops short of it.
351116 ;; ---------------------------------------------------------------- split
352117
353-(defn- no-chat-pane
354- "What fills the second pane before a conversation has been picked. The pane
355- is there either way — a list that widened into two columns and back again as
356- channels were opened would be a worse answer than an empty half."
357- []
358- [:vbox {:spacing 8 :margin 12}
359- [:title {:label "frq"}]
360- ;; With the list folded away there is nothing on the left to pick from, and
361- ;; no conversation here carrying the switch that would bring it back — so
362- ;; this pane carries it instead. Without that the reader who hid the list
363- ;; and then closed the last room has put the app away, not the list.
364- (if @s/hide-chat-list?
365- [:card {}
366- [:dim-label {:label "The chats list is hidden."}]
367- [:button {:label "☰ Chats" :kind :primary
368- :on-click s/toggle-chat-list!}]]
369- [:card {} [:dim-label {:label "Pick a conversation on the left."}]])])
370-
371-(defn split-screen
372- "The chats list and the conversation side by side, for a window wide enough
373- to hold both.
374-
375- Same components as the narrow layout, in a row instead of one at a time: the
376- list keeps its own scroll and its own tab bar, and the conversation keeps the
377- compose bar pinned under a backlog that scrolls on its own.
378-
379- Both panes take `:fill-height`: a column in a row is otherwise as tall as the
380- row, which at the moment it is placed is one button — and the list and the
381- message backlog both size themselves against the height they are handed.
382-
383- Only the list is given a width. The conversation takes what is left, rather
384- than the window's width minus the list's: the list costs a little more than
385- its 320 (its page has padding of its own), and a second column asking for
386- more than remains is wrapped onto a row below — painting the whole
387- conversation off the bottom of the window, which reads exactly like a
388- conversation that has gone missing."
389- []
390- ;; And the row itself takes the window, for the reason each narrow root
391- ;; gives: a backend that sizes a box by what is in it hands two panes that
392- ;; both asked for the rest of the screen the height of the taller one's
393- ;; contents. Marking the children alone is not enough — what they fill is
394- ;; whatever the row got.
395- [:hbox {:spacing 0 :wrap false :fill-height true}
396- ;; :expand :cross so the width-request is the width. Both panes fill the
397- ;; height, and a pane that fills the height is also told to take a share
398- ;; of the row's spare WIDTH — which two panes split between them, so the
399- ;; list came out half the window with its cards clipped at the fold and
400- ;; the conversation squeezed beside it. Down a row, :cross is the vertical
401- ;; axis alone: the height without the share. The conversation keeps
402- ;; :fill-height and so keeps the slack, which is what the note above says
403- ;; it takes.
404- ;; The list pane, when the reader has not put it away. Hidden, the wrapper
405- ;; stays and empties: a child that vanished would renumber the row for the
406- ;; reconciler and take the conversation's scroll position with it every
407- ;; time the list was toggled — the same trick the people panel plays.
408- ;; Empty, it asks for nothing: no width and no `:fill-height`, because a
409- ;; column that fills the height is also a column that is there, and a
410- ;; 320-point hole where the list was is not hiding it.
411- (if @s/hide-chat-list?
412- [:vbox {:key :list}]
413- [:vbox {:key :list :width-request sidebar-width :fill-height true
414- :expand :cross}
415- [chats-screen]])
416- [:vbox {:key :chat :fill-height true}
417- (if @s/current
418- [chat-screen]
419- [no-chat-pane])]])
420-
421118 ;; ---------------------------------------------------------------- discover
422119
423120 ;; ---------------------------------------------------------------- settings
424121
425122 ;; ---------------------------------------------------------------- shell
426123
427-(defn app []
428- ;; Every branch in its own keyed wrapper, and every key different.
429- ;;
430- ;; Without them the screens are all one position in the tree, and moving
431- ;; between two of them is a diff of one screen's children against another's:
432- ;; the reconciler matches what it can by position and patches the rest, which
433- ;; leaves nodes from the screen you left standing in the one you arrived at —
434- ;; the chats screen's join box turning up under its tab bar, an error box
435- ;; from the conversation you were in a moment ago. A key that changes with
436- ;; the screen makes the swap a swap: the old tree comes out whole and the new
437- ;; one goes in whole.
438- ;; And the dialog beside them all rather than instead of one of them. A
439- ;; `dialog` node is not painted where it stands — the window backend hands
440- ;; it to libcosmic, which puts it over the middle of the window with what is
441- ;; behind it dimmed — so the screen under it keeps its place in the tree,
442- ;; and its scroll position with it. The wrapper is always here and only its
443- ;; child comes and goes, for the reason every other wrapper in this file
444- ;; gives: a child that appeared and vanished would renumber the root.
445- ;;
446- ;; The terminal has no such thing, and a `dialog` tag it has not grown would
447- ;; paint its contents inline at the bottom of the screen. So there it stays
448- ;; a screen you go to and come back from, which is `profile-screen`.
449- [:vbox {:key :root :fill-height true}
450- ;; One dialog at a time, and a pinned profile outranks both pointers: it is
451- ;; the only one of the three that was asked for by a press rather than by
452- ;; where the pointer happens to be resting.
453- [:vbox {:key :dialog}
454- (when-not @terminal?
455- (cond
456- (or @profile/viewing @profile/hovering) [profile-dialog]
457- @s/reaction-hover [reactor-dialog]))]
458- (cond
459- @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]]
460- (and @terminal? @profile/viewing)
461- [:vbox {:key :screen-profile} [profile-screen]]
462- @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]]
463- ;; Wide enough for both, and on one of the two screens that are halves of
464- ;; the same thing: the list and the conversation it opens. Discover and
465- ;; settings stay whole screens — they are somewhere else, not the other
466- ;; half of here.
467- (and (s/wide?) (contains? #{:chats :chat} @s/screen))
468- [:vbox {:key :screen-split} [split-screen]]
469- :else (case @s/screen
470- :connect [:vbox {:key :screen-connect} [connect-screen]]
471- :chat [:vbox {:key :screen-chat} [chat-screen]]
472- :discover [:vbox {:key :screen-discover} [discover-screen]]
473- :settings [:vbox {:key :screen-settings} [settings-screen]]
474- [:vbox {:key :screen-chats} [chats-screen]]))])
475-
476124 (defn start!
477125 "Everything a launch does before the loop starts, for whichever backend is
478126 about to run it.
@@ -549,6 +197,10 @@
549197 ;; place in this namespace that uses glimmer's own API — and a phone has
550198 ;; neither the reaction nor the cache. It installs nothing and the screens
551199 ;; draw what they draw before a face arrives.
200+;; The root, re-defined here: `frq.tui` and `frq.cosmic` both say `app/app`
201+;; when they mount, and the namespace they say it to is this one.
202+(def app screens/app)
203+
552204 (actions/install!
553205 ;; The value rather than the cell: a platform without a cache answers nil,
554206 ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's
@@ -562,4 +214,13 @@
562214 :viewing (fn [] @profile/viewing)
563215 :profile-hover! profile/hover!
564216 :profile-unhover! profile/unhover!
565- :profile-open! profile/open!})
217+ :profile-open! profile/open!
218+ :profile-close! profile/close!
219+ :profile-dismiss! profile/dismiss!
220+ :profile-enter-dialog! profile/enter-dialog!
221+ :profile-entry profile/entry
222+ :profile-leave-dialog! profile/leave-dialog!
223+ :profile-stats-line profile/stats-line
224+ :profile-tick (fn [] @profile/tick)
225+ :profile-truncate profile/truncate
226+ :profile-web-url profile/web-url})
@@ -37,6 +37,7 @@
37 ;; phone renders this very file.37 ;; phone renders this very file.
38 [frq.screens.chats :refer [below-list chats-screen conversation-row38 [frq.screens.chats :refer [below-list chats-screen conversation-row
39 preview-line tab-bar]]39 preview-line tab-bar]]
40+ [frq.screens.app :as screens]
40 [frq.screens.settings :refer [discover-screen settings-screen41 [frq.screens.settings :refer [discover-screen settings-screen
41 tab-screen]]42 tab-screen]]
42 [frq.screens.chat :refer [chat-screen emoji-picker message-row43 [frq.screens.chat :refer [chat-screen emoji-picker message-row
@@ -112,367 +113,14 @@
112 113
113 ;; ---------------------------------------------------------------- chat114 ;; ---------------------------------------------------------------- chat
114 115
115-(defn- profile-dialog
116- "Who someone is, as libcosmic's own dialog: centred over the window, with
117- what you were reading dimmed behind it rather than replaced.
118-
119- The window can do this and the terminal cannot, which is the whole reason
120- there are two of these. `profile-screen` below is the terminal's, and says
121- why it is a screen.
122-
123- Both buttons are always here, the Bluesky one insensitive until there is a
124- profile to open: a dialog whose second button appears a moment after it
125- opens is a dialog that moves under the pointer, and the fetch lands whenever
126- it lands.
127-
128- Who it is about comes from either of two places, and that — with the
129- modality below — is the whole of what hovering and pressing a face do
130- differently. Resting on one sets `hovering`, which the pointer takes away
131- again when it leaves; pressing one sets `viewing`, which nothing takes away
132- but Close. `viewing` is read first, so a pinned profile is not swapped out
133- from under the reader by a face the pointer crosses on the way to it.
134-
135- And a dialog the pointer is holding open is not modal. A modal one makes
136- the window underneath it deaf — libcosmic wraps the app in a popover that
137- hands its content an `Unavailable` cursor while a popup is up — so the face
138- that opened it never hears the pointer leave, and what a hover opened could
139- never close itself. Non-modal, the face keeps hearing, and moving away shuts
140- it. A pinned one is modal, which is what being pinned means: it is the thing
141- on the screen until it is dismissed."
142- []
143- (let [pinned? (some? @profile/viewing)
144- {:keys [nick actor]} (or @profile/viewing @profile/hovering)
145- _ @profile/tick
146- _ @s/media-tick
147- pr (profile/entry actor)
148- ready? (= :ready (:status pr))
149- display (or (:display-name pr) nick)
150- url (when ready? (profile/web-url pr))]
151- [:dialog {:label display :max-width 520 :modal pinned?
152- :on-hover profile/enter-dialog!
153- :on-unhover profile/leave-dialog!}
154- ;; The body is the screen's card without its heading: the dialog's own
155- ;; title is the name now, so repeating it under the picture is a line
156- ;; that says nothing.
157- [:vbox {:key :body :spacing 8}
158- [:hbox {:spacing 12}
159- [:avatar {:label nick
160- :src (or (avatars/path-when-ready actor) "")
161- :size 72}]
162- [:vbox {:spacing 2}
163- [:vbox {:key :nick}
164- (when (not= display nick) [:dim-label {:label nick}])]
165- [:vbox {:key :handle}
166- (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
167- [:label {:label (str "@" h)}])]
168- [:vbox {:key :did}
169- (when-let [did (:did pr)] [:dim-label {:label did}])]]]
170- [:vbox {:key :bio :spacing 2}
171- (when-let [bio (and ready? (:description pr))]
172- (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))]
173- [:label {:key i :label line}]))]
174- [:vbox {:key :stats}
175- (when-let [line (and ready? (profile/stats-line pr))]
176- [:dim-label {:label line}])]
177- [:vbox {:key :status :spacing 4}
178- (cond
179- (nil? actor)
180- [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
181-
182- (= :loading (:status pr))
183- [:hbox {:spacing 8}
184- [:spinner {}]
185- [:dim-label {:label "Loading Bluesky profile…"}]]
186-
187- (= :failed (:status pr))
188- [:dim-label {:label "No Bluesky profile found"}])]
189-]
190- ;; `slot` is where libcosmic puts a button: the two actions go to the
191- ;; foot of the dialog, and anything else here would be another control
192- ;; stacked in the body.
193- ;;
194- ;; Both are here whether the profile is pinned or only hovered. They were
195- ;; hidden while hovering, back when a hover could not be walked into: the
196- ;; dialog reports its own pointer now, so moving towards a button in it
197- ;; keeps it open instead of closing it, and a button you can reach is a
198- ;; button worth drawing.
199- [:button {:key :close :slot "primary"
200- :label (if pinned? "Close" "Dismiss")
201- :on-click profile/dismiss!}]
202- [:button {:key :web :slot "secondary" :label "Bluesky ↗"
203- :sensitive (boolean url)
204- :on-click #(when url (platform/open-url! url))}]]))
205-
206-(defn profile-screen
207- "Who someone is: sleek's peer profile modal, as a screen.
208-
209- A screen rather than a layer for the same reason the lightbox is one — the
210- tree backend paints in a single layer, with no z-order to hang a modal from.
211- So the back row does what sleek's ✕ and backdrop did.
212-
213- The picture is the same cached file the chat column draws, at a size worth
214- looking at; the bio is split into a label a line, so a bio that was written
215- as several lines is still several lines here."
216- []
217- (let [{:keys [nick actor]} @profile/viewing
218- ;; Reading both ticks subscribes this screen to the two fetches it is
219- ;; waiting on: the profile itself, and the picture on it.
220- _ @profile/tick
221- _ @s/media-tick
222- pr (profile/entry actor)
223- ready? (= :ready (:status pr))
224- display (or (:display-name pr) nick)
225- url (when ready? (profile/web-url pr))]
226- [:page {:max-width 520}
227- [:hbox {:spacing 8}
228- [:button {:label "← Back" :on-click profile/close!}]]
229- [:card {}
230- [:hbox {:spacing 12}
231- [:avatar {:label nick
232- :src (or (avatars/path-when-ready actor) "")
233- :size 72}]
234- [:vbox {:spacing 2}
235- [:title-2 {:label display}]
236- ;; The nick under the display name only when they differ — repeating
237- ;; it is a line that says nothing.
238- [:vbox {:key :nick}
239- (when (not= display nick) [:dim-label {:label nick}])]
240- [:vbox {:key :handle}
241- (when-let [h (and ready? (not-empty (or (:handle pr) "")))]
242- [:label {:label (str "@" h)}])]]]
243- ;; The DID is the identity itself, and outlasts both the nick and the
244- ;; handle — so it is on the screen, in full, rather than implied.
245- [:vbox {:key :did :spacing 2}
246- (when-let [did (:did pr)] [:dim-label {:label did}])]
247- [:vbox {:key :bio :spacing 2}
248- (when-let [bio (and ready? (:description pr))]
249- (for [[i line] (map-indexed vector (str/split-lines (profile/truncate bio 600)))]
250- [:label {:key i :label line}]))]
251- [:vbox {:key :stats}
252- (when-let [line (and ready? (profile/stats-line pr))]
253- [:dim-label {:label line}])]
254- ;; What is happening, or why nothing is: a guest has no identity to look
255- ;; up, and saying so is a better answer than a spinner that never lands.
256- [:vbox {:key :status :spacing 4}
257- (cond
258- (nil? actor)
259- [:dim-label {:label "Guest — no Bluesky / AT Protocol identity"}]
260-
261- (= :loading (:status pr))
262- [:hbox {:spacing 8}
263- [:spinner {}]
264- [:dim-label {:label "Loading Bluesky profile…"}]]
265-
266- (= :failed (:status pr))
267- [:dim-label {:label "No Bluesky profile found"}])]
268- [:vbox {:key :actions}
269- (when url
270- [:button {:label "Bluesky ↗"
271- :kind :primary
272- :on-click #(platform/open-url! url)}])]]]))
273-
274-(defn lightbox-screen
275- "One picture, as big as the window will paint it.
276-
277- A screen rather than an overlay: the tree backend paints in one layer and has
278- no z-order to put something on top of everything else with. So the picture
279- takes the window — `:fit` gives it every point below the one row that is not
280- it, centred and in proportion, scaled up as readily as down.
281-
282- That row is the way back. Clicking the picture closes it too — the same
283- gesture that opened it — but a way out you have to guess at is not one, and
284- the row costs the picture a line."
285- []
286- (let [{:keys [path]} @s/lightbox]
287- [:vbox {:spacing 4 :margin 4}
288- [:hbox {:spacing 8}
289- [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]
290- [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
291-
292-(defn image-picker-screen
293- "The pictures on this device, to send one of.
294-
295- A screen rather than a panel over the compose bar: choosing a file is
296- browsing, and browsing wants the window — the backend paints in one layer
297- anyway, so there is no overlay to put it in.
298-
299- A directory at a time, PNG only. Which directories there are to start from is
300- the platform's answer, not this screen's: a desktop opens on ~/Pictures, and
301- a phone on what the app can read without a permission it has no code to ask
302- for, which may be very little. Either way the list says what it found."
303- []
304- (let [dir @s/image-picker
305- {:keys [dirs files]} (s/picker-entries dir)
306- up (s/parent-dir dir)]
307- [:vbox {:spacing 8 :margin 12}
308- [:hbox {:spacing 8}
309- [:button {:label "← Back" :on-click s/close-image-picker!}]
310- [:title {:label "Send a picture"}]]
311- [:dim-label {:label (str dir)}]
312- [error-note]
313- ;; The places worth starting from, always in reach: browsing into a corner
314- ;; of the filesystem should not cost the way back to the pictures folder.
315- [:hbox {:key :roots :spacing 6}
316- (for [root (s/picker-roots)]
317- [:button {:key root
318- :label (or (last (str/split root #"/")) root)
319- :kind (if (= root dir) :primary :default)
320- :on-click #(s/browse! root)}])]
321- [:separator {}]
322- [:scroll {:scroll-key "image-picker"
323- :orientation :vertical
324- :reserve 40}
325- [:vbox {:spacing 6}
326- [:vbox {:key :up}
327- (when up
328- [:button {:label "⬆ Up" :on-click #(s/browse! up)}])]
329- [:vbox {:key :dirs :spacing 4}
330- (for [d dirs]
331- [:button {:key d
332- :label (str "📁 " (last (str/split d #"/")))
333- :on-click #(s/browse! d)}])]
334- ;; The picture itself is the button: a filename is not what anyone is
335- ;; choosing between, and a thumbnail answers "is this the one" in a way
336- ;; no name does.
337- [:vbox {:key :files :spacing 6}
338- (for [f files]
339- [:hbox {:key f :spacing 8}
340- [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}]
341- [:button {:label (last (str/split f #"/"))
342- :on-click #(s/pick-image! f)}]])]
343- [:vbox {:key :empty}
344- (when (and (empty? dirs) (empty? files))
345- [:dim-label {:label "No pictures here that this app can read."}])]]]]))
346-
347-;; What the people panel asks for, and what the conversation beside it has to
348-;; be told to leave. A column with `:fill-height` and no width takes the whole
349-;; row — so the panel is only ever on screen if the message list is given a
350-;; width that stops short of it.
351 ;; ---------------------------------------------------------------- split116 ;; ---------------------------------------------------------------- split
352 117
353-(defn- no-chat-pane
354- "What fills the second pane before a conversation has been picked. The pane
355- is there either way — a list that widened into two columns and back again as
356- channels were opened would be a worse answer than an empty half."
357- []
358- [:vbox {:spacing 8 :margin 12}
359- [:title {:label "frq"}]
360- ;; With the list folded away there is nothing on the left to pick from, and
361- ;; no conversation here carrying the switch that would bring it back — so
362- ;; this pane carries it instead. Without that the reader who hid the list
363- ;; and then closed the last room has put the app away, not the list.
364- (if @s/hide-chat-list?
365- [:card {}
366- [:dim-label {:label "The chats list is hidden."}]
367- [:button {:label "☰ Chats" :kind :primary
368- :on-click s/toggle-chat-list!}]]
369- [:card {} [:dim-label {:label "Pick a conversation on the left."}]])])
370-
371-(defn split-screen
372- "The chats list and the conversation side by side, for a window wide enough
373- to hold both.
374-
375- Same components as the narrow layout, in a row instead of one at a time: the
376- list keeps its own scroll and its own tab bar, and the conversation keeps the
377- compose bar pinned under a backlog that scrolls on its own.
378-
379- Both panes take `:fill-height`: a column in a row is otherwise as tall as the
380- row, which at the moment it is placed is one button — and the list and the
381- message backlog both size themselves against the height they are handed.
382-
383- Only the list is given a width. The conversation takes what is left, rather
384- than the window's width minus the list's: the list costs a little more than
385- its 320 (its page has padding of its own), and a second column asking for
386- more than remains is wrapped onto a row below — painting the whole
387- conversation off the bottom of the window, which reads exactly like a
388- conversation that has gone missing."
389- []
390- ;; And the row itself takes the window, for the reason each narrow root
391- ;; gives: a backend that sizes a box by what is in it hands two panes that
392- ;; both asked for the rest of the screen the height of the taller one's
393- ;; contents. Marking the children alone is not enough — what they fill is
394- ;; whatever the row got.
395- [:hbox {:spacing 0 :wrap false :fill-height true}
396- ;; :expand :cross so the width-request is the width. Both panes fill the
397- ;; height, and a pane that fills the height is also told to take a share
398- ;; of the row's spare WIDTH — which two panes split between them, so the
399- ;; list came out half the window with its cards clipped at the fold and
400- ;; the conversation squeezed beside it. Down a row, :cross is the vertical
401- ;; axis alone: the height without the share. The conversation keeps
402- ;; :fill-height and so keeps the slack, which is what the note above says
403- ;; it takes.
404- ;; The list pane, when the reader has not put it away. Hidden, the wrapper
405- ;; stays and empties: a child that vanished would renumber the row for the
406- ;; reconciler and take the conversation's scroll position with it every
407- ;; time the list was toggled — the same trick the people panel plays.
408- ;; Empty, it asks for nothing: no width and no `:fill-height`, because a
409- ;; column that fills the height is also a column that is there, and a
410- ;; 320-point hole where the list was is not hiding it.
411- (if @s/hide-chat-list?
412- [:vbox {:key :list}]
413- [:vbox {:key :list :width-request sidebar-width :fill-height true
414- :expand :cross}
415- [chats-screen]])
416- [:vbox {:key :chat :fill-height true}
417- (if @s/current
418- [chat-screen]
419- [no-chat-pane])]])
420-
421 ;; ---------------------------------------------------------------- discover118 ;; ---------------------------------------------------------------- discover
422 119
423 ;; ---------------------------------------------------------------- settings120 ;; ---------------------------------------------------------------- settings
424 121
425 ;; ---------------------------------------------------------------- shell122 ;; ---------------------------------------------------------------- shell
426 123
427-(defn app []
428- ;; Every branch in its own keyed wrapper, and every key different.
429- ;;
430- ;; Without them the screens are all one position in the tree, and moving
431- ;; between two of them is a diff of one screen's children against another's:
432- ;; the reconciler matches what it can by position and patches the rest, which
433- ;; leaves nodes from the screen you left standing in the one you arrived at —
434- ;; the chats screen's join box turning up under its tab bar, an error box
435- ;; from the conversation you were in a moment ago. A key that changes with
436- ;; the screen makes the swap a swap: the old tree comes out whole and the new
437- ;; one goes in whole.
438- ;; And the dialog beside them all rather than instead of one of them. A
439- ;; `dialog` node is not painted where it stands — the window backend hands
440- ;; it to libcosmic, which puts it over the middle of the window with what is
441- ;; behind it dimmed — so the screen under it keeps its place in the tree,
442- ;; and its scroll position with it. The wrapper is always here and only its
443- ;; child comes and goes, for the reason every other wrapper in this file
444- ;; gives: a child that appeared and vanished would renumber the root.
445- ;;
446- ;; The terminal has no such thing, and a `dialog` tag it has not grown would
447- ;; paint its contents inline at the bottom of the screen. So there it stays
448- ;; a screen you go to and come back from, which is `profile-screen`.
449- [:vbox {:key :root :fill-height true}
450- ;; One dialog at a time, and a pinned profile outranks both pointers: it is
451- ;; the only one of the three that was asked for by a press rather than by
452- ;; where the pointer happens to be resting.
453- [:vbox {:key :dialog}
454- (when-not @terminal?
455- (cond
456- (or @profile/viewing @profile/hovering) [profile-dialog]
457- @s/reaction-hover [reactor-dialog]))]
458- (cond
459- @s/lightbox [:vbox {:key :screen-lightbox} [lightbox-screen]]
460- (and @terminal? @profile/viewing)
461- [:vbox {:key :screen-profile} [profile-screen]]
462- @s/image-picker [:vbox {:key :screen-picker} [image-picker-screen]]
463- ;; Wide enough for both, and on one of the two screens that are halves of
464- ;; the same thing: the list and the conversation it opens. Discover and
465- ;; settings stay whole screens — they are somewhere else, not the other
466- ;; half of here.
467- (and (s/wide?) (contains? #{:chats :chat} @s/screen))
468- [:vbox {:key :screen-split} [split-screen]]
469- :else (case @s/screen
470- :connect [:vbox {:key :screen-connect} [connect-screen]]
471- :chat [:vbox {:key :screen-chat} [chat-screen]]
472- :discover [:vbox {:key :screen-discover} [discover-screen]]
473- :settings [:vbox {:key :screen-settings} [settings-screen]]
474- [:vbox {:key :screen-chats} [chats-screen]]))])
475-
476 (defn start!124 (defn start!
477 "Everything a launch does before the loop starts, for whichever backend is125 "Everything a launch does before the loop starts, for whichever backend is
478 about to run it.126 about to run it.
@@ -549,6 +197,10 @@
549 ;; place in this namespace that uses glimmer's own API — and a phone has197 ;; place in this namespace that uses glimmer's own API — and a phone has
550 ;; neither the reaction nor the cache. It installs nothing and the screens198 ;; neither the reaction nor the cache. It installs nothing and the screens
551 ;; draw what they draw before a face arrives.199 ;; draw what they draw before a face arrives.
200+;; The root, re-defined here: `frq.tui` and `frq.cosmic` both say `app/app`
201+;; when they mount, and the namespace they say it to is this one.
202+(def app screens/app)
203+
552 (actions/install!204 (actions/install!
553 ;; The value rather than the cell: a platform without a cache answers nil,205 ;; The value rather than the cell: a platform without a cache answers nil,
554 ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's206 ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's
@@ -562,4 +214,13 @@
562 :viewing (fn [] @profile/viewing)214 :viewing (fn [] @profile/viewing)
563 :profile-hover! profile/hover!215 :profile-hover! profile/hover!
564 :profile-unhover! profile/unhover!216 :profile-unhover! profile/unhover!
565- :profile-open! profile/open!})217+ :profile-open! profile/open!
218+ :profile-close! profile/close!
219+ :profile-dismiss! profile/dismiss!
220+ :profile-enter-dialog! profile/enter-dialog!
221+ :profile-entry profile/entry
222+ :profile-leave-dialog! profile/leave-dialog!
223+ :profile-stats-line profile/stats-line
224+ :profile-tick (fn [] @profile/tick)
225+ :profile-truncate profile/truncate
226+ :profile-web-url profile/web-url})
modified src/frq/state.clj +9 -3
@@ -1360,9 +1360,7 @@
13601360
13611361 ;; ------------------------------------------------------------------ picking
13621362
1363-;; Where the picker is looking, or nil when it is closed. A path, so the
1364-;; browsing is just this cell moving.
1365-(defonce image-picker (atom nil))
1363+(def image-picker cells/image-picker)
13661364
13671365 (defn- readable-dir? [path]
13681366 (try (and (host/file-exists? path) (host/directory? path))
@@ -1950,6 +1948,14 @@
19501948 :open-channel! open-channel!
19511949 :leave-channel! leave-channel!
19521950 :toggle-hide-join-part! toggle-hide-join-part!
1951+ :browse! browse!
1952+ :close-image-picker! close-image-picker!
1953+ :pick-image! pick-image!
1954+ :parent-dir parent-dir
1955+ :picker-entries picker-entries
1956+ :picker-roots picker-roots
1957+ :media-tick (fn [] @media-tick)
1958+ :avatar-ready (fn [actor] (avatars/path-when-ready actor))
19531959 :send-draft! send-draft!
19541960 :cancel-edit! cancel-edit!
19551961 :cancel-reply! cancel-reply!
@@ -1360,9 +1360,7 @@
1360 1360
1361 ;; ------------------------------------------------------------------ picking1361 ;; ------------------------------------------------------------------ picking
1362 1362
1363-;; Where the picker is looking, or nil when it is closed. A path, so the1363+(def image-picker cells/image-picker)
1364-;; browsing is just this cell moving.
1365-(defonce image-picker (atom nil))
1366 1364
1367 (defn- readable-dir? [path]1365 (defn- readable-dir? [path]
1368 (try (and (host/file-exists? path) (host/directory? path))1366 (try (and (host/file-exists? path) (host/directory? path))
@@ -1950,6 +1948,14 @@
1950 :open-channel! open-channel!1948 :open-channel! open-channel!
1951 :leave-channel! leave-channel!1949 :leave-channel! leave-channel!
1952 :toggle-hide-join-part! toggle-hide-join-part!1950 :toggle-hide-join-part! toggle-hide-join-part!
1951+ :browse! browse!
1952+ :close-image-picker! close-image-picker!
1953+ :pick-image! pick-image!
1954+ :parent-dir parent-dir
1955+ :picker-entries picker-entries
1956+ :picker-roots picker-roots
1957+ :media-tick (fn [] @media-tick)
1958+ :avatar-ready (fn [actor] (avatars/path-when-ready actor))
1953 :send-draft! send-draft!1959 :send-draft! send-draft!
1954 :cancel-edit! cancel-edit!1960 :cancel-edit! cancel-edit!
1955 :cancel-reply! cancel-reply!1961 :cancel-reply! cancel-reply!