nandi/frqpublic Fork 0
5485559
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 chat screen, the largest of them

frq.app is 621 lines now and was 1,744. The conversation went the way the
other two did — the same hiccup, reading frq.cells and frq.rooms, calling
frq.actions — and it took the message list with it: replies, edits,
reactions, the emoji picker, the people panel, the overview, the call wall
and the compose bar. Forty-six definitions, verified against the originals
line by line after normalising the namespace prefixes; the seven that differ
do so because a constant is local now, sidebar-width is public, or a trailing
comment moved.

Two seams are new, and both are the same observation: not everything a screen
reads is state.

`frq.actions` grows the twenty reducers behind the reactions and the picker,
the two window queries, the call bar's three, and the two assets — where a
face or a picture is on disk once it is. A platform that installs none of the
call ones gets the bar's unavailable shape, which is what it already drew when
the media plane was missing.

`frq.cells/derived-value` is the other. `derived` was the one place in
frq.app that used glimmer's own API: a reaction per question, kept for the
session, so a highlight moving wakes the two rows it moved between rather
than the whole backlog. None of that applies where Flutter rebuilds and diffs
its own element tree, so under ClojureDart the question is simply asked —
which is why it is a value rather than a cell. `@(derived k f)` could not be
written once.

Verified: the TUI renders the conversation identically to HEAD, compared in a
throwaway worktree with the timestamps normalised, and the split view still
draws the list beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T22:19:50-07:00 Browse files
5485559 parent: 0addab3
modified common/frq/actions.cljc +38 -7
@@ -58,7 +58,7 @@
5858 (defn toggle-overview! [] (call :toggle-overview! []))
5959
6060 (defn wide? [] (boolean (call :wide? [])))
61-(defn member-count [name] (call :member-count [name]))
61+(defn member-count [& args] (call :member-count args))
6262
6363 ;; ------------------------------------------------------------------ calls
6464 ;;
@@ -66,10 +66,10 @@
6666 ;; the desktop and nothing installs these here, so the call bar renders its
6767 ;; unavailable shape rather than being special-cased in the screen.
6868
69-(defn start-call! [] (call :start-call! []))
70-(defn in-call? [] (boolean (call :in-call? [])))
71-(defn call-in [name] (call :call-in [name]))
72-(defn call-available? [] (boolean (call :call-available? [])))
69+(defn start-call! [& args] (call :start-call! args))
70+(defn in-call? [& args] (boolean (call :in-call? args)))
71+(defn call-in [& args] (call :call-in args))
72+(defn call-available? [& args] (boolean (call :call-available? args)))
7373
7474 ;; ----------------------------------------------------------------- assets
7575 ;;
@@ -78,6 +78,37 @@
7878 ;; the rows that read it; the phone has neither yet and answers nil, which the
7979 ;; screens already treat as "not here".
8080
81-(defn avatar-path [actor] (call :avatar-path [actor]))
82-(defn image-path [url] (call :image-path [url]))
81+(defn avatar-path [& args] (call :avatar-path args))
82+(defn image-path [& args] (call :image-path args))
8383
84+;; ------------------------------------------------------- the rest of chat
85+;;
86+;; Reactions, the picker, jumping to a message, the overview and the two
87+;; dialogs. All of them reducers in `frq.state` on the desktop; a platform
88+;; that installs none of them gets a screen whose buttons do nothing rather
89+;; than a screen that will not draw.
90+
91+(defn desktop? [] (boolean (call :desktop? [])))
92+(defn quit! [] (call :quit! []))
93+(defn hovering [] (call :hovering []))
94+(defn viewing [] (call :viewing []))
95+(defn accept-policy! [& args] (call :accept-policy! args))
96+(defn close-picker! [& args] (call :close-picker! args))
97+(defn hover-reaction! [& args] (call :hover-reaction! args))
98+(defn join-call! [& args] (call :join-call! args))
99+(defn leave-call! [& args] (call :leave-call! args))
100+(defn leaving-for-overview! [& args] (call :leaving-for-overview! args))
101+(defn member-list [& args] (call :member-list args))
102+(defn message-by-id [& args] (call :message-by-id args))
103+(defn mine? [& args] (call :mine? args))
104+(defn my-reaction? [& args] (call :my-reaction? args))
105+(defn open-dm! [& args] (call :open-dm! args))
106+(defn open-picker! [& args] (call :open-picker! args))
107+(defn overview-back! [& args] (call :overview-back! args))
108+(defn picker-emoji [& args] (call :picker-emoji args))
109+(defn react-from-picker! [& args] (call :react-from-picker! args))
110+(defn recent-everywhere [& args] (call :recent-everywhere args))
111+(defn reply-to! [& args] (call :reply-to! args))
112+(defn start-edit! [& args] (call :start-edit! args))
113+(defn toggle-reaction! [& args] (call :toggle-reaction! args))
114+(defn unhover-reaction! [& args] (call :unhover-reaction! args))
@@ -58,7 +58,7 @@
58 (defn toggle-overview! [] (call :toggle-overview! []))58 (defn toggle-overview! [] (call :toggle-overview! []))
59 59
60 (defn wide? [] (boolean (call :wide? [])))60 (defn wide? [] (boolean (call :wide? [])))
61-(defn member-count [name] (call :member-count [name]))61+(defn member-count [& args] (call :member-count args))
62 62
63 ;; ------------------------------------------------------------------ calls63 ;; ------------------------------------------------------------------ calls
64 ;;64 ;;
@@ -66,10 +66,10 @@
66 ;; the desktop and nothing installs these here, so the call bar renders its66 ;; the desktop and nothing installs these here, so the call bar renders its
67 ;; unavailable shape rather than being special-cased in the screen.67 ;; unavailable shape rather than being special-cased in the screen.
68 68
69-(defn start-call! [] (call :start-call! []))69+(defn start-call! [& args] (call :start-call! args))
70-(defn in-call? [] (boolean (call :in-call? [])))70+(defn in-call? [& args] (boolean (call :in-call? args)))
71-(defn call-in [name] (call :call-in [name]))71+(defn call-in [& args] (call :call-in args))
72-(defn call-available? [] (boolean (call :call-available? [])))72+(defn call-available? [& args] (boolean (call :call-available? args)))
73 73
74 ;; ----------------------------------------------------------------- assets74 ;; ----------------------------------------------------------------- assets
75 ;;75 ;;
@@ -78,6 +78,37 @@
78 ;; the rows that read it; the phone has neither yet and answers nil, which the78 ;; the rows that read it; the phone has neither yet and answers nil, which the
79 ;; screens already treat as "not here".79 ;; screens already treat as "not here".
80 80
81-(defn avatar-path [actor] (call :avatar-path [actor]))81+(defn avatar-path [& args] (call :avatar-path args))
82-(defn image-path [url] (call :image-path [url]))82+(defn image-path [& args] (call :image-path args))
83 83
84+;; ------------------------------------------------------- the rest of chat
85+;;
86+;; Reactions, the picker, jumping to a message, the overview and the two
87+;; dialogs. All of them reducers in `frq.state` on the desktop; a platform
88+;; that installs none of them gets a screen whose buttons do nothing rather
89+;; than a screen that will not draw.
90+
91+(defn desktop? [] (boolean (call :desktop? [])))
92+(defn quit! [] (call :quit! []))
93+(defn hovering [] (call :hovering []))
94+(defn viewing [] (call :viewing []))
95+(defn accept-policy! [& args] (call :accept-policy! args))
96+(defn close-picker! [& args] (call :close-picker! args))
97+(defn hover-reaction! [& args] (call :hover-reaction! args))
98+(defn join-call! [& args] (call :join-call! args))
99+(defn leave-call! [& args] (call :leave-call! args))
100+(defn leaving-for-overview! [& args] (call :leaving-for-overview! args))
101+(defn member-list [& args] (call :member-list args))
102+(defn message-by-id [& args] (call :message-by-id args))
103+(defn mine? [& args] (call :mine? args))
104+(defn my-reaction? [& args] (call :my-reaction? args))
105+(defn open-dm! [& args] (call :open-dm! args))
106+(defn open-picker! [& args] (call :open-picker! args))
107+(defn overview-back! [& args] (call :overview-back! args))
108+(defn picker-emoji [& args] (call :picker-emoji args))
109+(defn react-from-picker! [& args] (call :react-from-picker! args))
110+(defn recent-everywhere [& args] (call :recent-everywhere args))
111+(defn reply-to! [& args] (call :reply-to! args))
112+(defn start-edit! [& args] (call :start-edit! args))
113+(defn toggle-reaction! [& args] (call :toggle-reaction! args))
114+(defn unhover-reaction! [& args] (call :unhover-reaction! args))
modified common/frq/cells.cljc +78 -1
@@ -16,7 +16,7 @@
1616 jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
1717 `:clj` always on, which is why neither branch is spelled that way."
1818 (:require #?@(:cljd []
19- :jolt [[glimmer.ratom :refer [atom]]])))
19+ :jolt [[glimmer.ratom :refer [atom reaction]]])))
2020
2121 (def default-host "irc.freeq.at")
2222 (def default-port "6697")
@@ -114,3 +114,80 @@
114114 ;; watches to be told to go back to it. A counter rather than a flag: a flag
115115 ;; would need clearing, and there is no frame in which to clear it.
116116 (defonce at-present? (atom true))
117+
118+;; --------------------------------------------------- picker, jump, window
119+
120+(defonce emoji-group (atom nil))
121+
122+;; What the picker is showing: the search box, and which group is on screen
123+;; when nothing has been typed. `nil` is the popular row it opens on.
124+(defonce emoji-search (atom ""))
125+
126+;; The message a jump has just landed on. It outlives the scroll: arriving at a
127+;; screenful of messages says nothing about which one was asked for, so the one
128+;; that was answers for itself until the reader has had time to see it.
129+(defonce highlight (atom nil))
130+
131+;; The message a "go to" is currently aiming at. Set for the frame that scrolls
132+;; to it and taken off again — a scroll target that stays set would pin the
133+;; view there and take scrolling away from the reader.
134+(defonce jump-to (atom nil))
135+
136+;; The picture being looked at full size, or nil. Vidya's tree has no overlay,
137+;; so this is a screen of its own rather than a layer over the chat.
138+(defonce lightbox (atom nil)) ; {:path :url}
139+
140+;; The room the reader was in when a line in the overview took them somewhere
141+;; else, or nil. The strip is the one place in the app that moves you without
142+;; you having asked to leave where you were — everything else is a room you
143+;; chose — so it is the one place that owes you the way back.
144+(defonce overview-return (atom nil))
145+
146+;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
147+;; when it is closed. The picker is a panel over the compose bar rather than a
148+;; screen: what is being reacted to has to stay in sight.
149+(defonce reacting (atom nil))
150+
151+;; The window's content height, polled beside the width and for the same
152+;; reason. What it is for is the pictures in the conversation: a preview sized
153+;; against the window is a picture on a laptop and a thumbnail on a phone,
154+;; where one fixed height is only ever right on one of them.
155+(defonce window-height (atom 0))
156+
157+;; The window's content width in points, polled from the backend a few times a
158+;; second. The app is laid out for a phone-width window, and this is what lets
159+;; a wide one be more than a phone with margins: past `wide-width` the channel
160+;; list and the conversation are both on screen instead of taking turns.
161+(defonce window-width (atom 0))
162+
163+;; ------------------------------------------------------------- derivation
164+
165+(defonce ^:private derived-cells
166+ ;; One cell per question, kept for the session: a cell made afresh on every
167+ ;; render would add a watch to its source each time and never take it off.
168+ (atom {}))
169+
170+(defn derived-value
171+ "The answer to one question about shared state, under `k`.
172+
173+ A message row that read `highlight` itself was re-rendered whenever the
174+ highlight moved anywhere every row in the backlog, for one jump. A
175+ reaction is recomputed on each such change, which is a comparison, but it
176+ wakes the rows that read it only when its answer changes.
177+
178+ None of which applies under ClojureDart, where Flutter rebuilds the screen
179+ and diffs its own element tree: there is no subtree to wake, so the question
180+ is simply asked. That is the whole of the difference, and it is why this is
181+ a value rather than a cell `@(derived k f)` could not be written once."
182+ [k f]
183+ #?(:cljd (f)
184+ :jolt (deref (or (get @derived-cells k)
185+ (let [cell (reaction (f))]
186+ (swap! derived-cells assoc k cell)
187+ cell)))))
188+
189+;; The pill the pointer is resting on, or nil — `{:id msgid :emoji glyph}`.
190+;; One at a time, and named by the message as well as the glyph: the same emoji
191+;; is a pill under many messages, and only the one under the pointer carries a
192+;; card.
193+(defonce reaction-hover (atom nil))
@@ -16,7 +16,7 @@
16 jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has16 jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
17 `:clj` always on, which is why neither branch is spelled that way."17 `:clj` always on, which is why neither branch is spelled that way."
18 (:require #?@(:cljd []18 (:require #?@(:cljd []
19- :jolt [[glimmer.ratom :refer [atom]]])))19+ :jolt [[glimmer.ratom :refer [atom reaction]]])))
20 20
21 (def default-host "irc.freeq.at")21 (def default-host "irc.freeq.at")
22 (def default-port "6697")22 (def default-port "6697")
@@ -114,3 +114,80 @@
114 ;; watches to be told to go back to it. A counter rather than a flag: a flag114 ;; watches to be told to go back to it. A counter rather than a flag: a flag
115 ;; would need clearing, and there is no frame in which to clear it.115 ;; would need clearing, and there is no frame in which to clear it.
116 (defonce at-present? (atom true))116 (defonce at-present? (atom true))
117+
118+;; --------------------------------------------------- picker, jump, window
119+
120+(defonce emoji-group (atom nil))
121+
122+;; What the picker is showing: the search box, and which group is on screen
123+;; when nothing has been typed. `nil` is the popular row it opens on.
124+(defonce emoji-search (atom ""))
125+
126+;; The message a jump has just landed on. It outlives the scroll: arriving at a
127+;; screenful of messages says nothing about which one was asked for, so the one
128+;; that was answers for itself until the reader has had time to see it.
129+(defonce highlight (atom nil))
130+
131+;; The message a "go to" is currently aiming at. Set for the frame that scrolls
132+;; to it and taken off again — a scroll target that stays set would pin the
133+;; view there and take scrolling away from the reader.
134+(defonce jump-to (atom nil))
135+
136+;; The picture being looked at full size, or nil. Vidya's tree has no overlay,
137+;; so this is a screen of its own rather than a layer over the chat.
138+(defonce lightbox (atom nil)) ; {:path :url}
139+
140+;; The room the reader was in when a line in the overview took them somewhere
141+;; else, or nil. The strip is the one place in the app that moves you without
142+;; you having asked to leave where you were — everything else is a room you
143+;; chose — so it is the one place that owes you the way back.
144+(defonce overview-return (atom nil))
145+
146+;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
147+;; when it is closed. The picker is a panel over the compose bar rather than a
148+;; screen: what is being reacted to has to stay in sight.
149+(defonce reacting (atom nil))
150+
151+;; The window's content height, polled beside the width and for the same
152+;; reason. What it is for is the pictures in the conversation: a preview sized
153+;; against the window is a picture on a laptop and a thumbnail on a phone,
154+;; where one fixed height is only ever right on one of them.
155+(defonce window-height (atom 0))
156+
157+;; The window's content width in points, polled from the backend a few times a
158+;; second. The app is laid out for a phone-width window, and this is what lets
159+;; a wide one be more than a phone with margins: past `wide-width` the channel
160+;; list and the conversation are both on screen instead of taking turns.
161+(defonce window-width (atom 0))
162+
163+;; ------------------------------------------------------------- derivation
164+
165+(defonce ^:private derived-cells
166+ ;; One cell per question, kept for the session: a cell made afresh on every
167+ ;; render would add a watch to its source each time and never take it off.
168+ (atom {}))
169+
170+(defn derived-value
171+ "The answer to one question about shared state, under `k`.
172+
173+ A message row that read `highlight` itself was re-rendered whenever the
174+ highlight moved anywhere every row in the backlog, for one jump. A
175+ reaction is recomputed on each such change, which is a comparison, but it
176+ wakes the rows that read it only when its answer changes.
177+
178+ None of which applies under ClojureDart, where Flutter rebuilds the screen
179+ and diffs its own element tree: there is no subtree to wake, so the question
180+ is simply asked. That is the whole of the difference, and it is why this is
181+ a value rather than a cell `@(derived k f)` could not be written once."
182+ [k f]
183+ #?(:cljd (f)
184+ :jolt (deref (or (get @derived-cells k)
185+ (let [cell (reaction (f))]
186+ (swap! derived-cells assoc k cell)
187+ cell)))))
188+
189+;; The pill the pointer is resting on, or nil — `{:id msgid :emoji glyph}`.
190+;; One at a time, and named by the message as well as the glyph: the same emoji
191+;; is a pill under many messages, and only the one under the pointer carries a
192+;; card.
193+(defonce reaction-hover (atom nil))
modified common/frq/metrics.cljc +9 -0
@@ -32,3 +32,12 @@
3232 ;; And whether that terminal draws pictures over its cells — Kitty's graphics
3333 ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.
3434 (defonce terminal-graphics? (atom false))
35+
36+(defn terminal-face?
37+ "Whether a message carries a picture of its sender in a terminal.
38+
39+ Not the same question as `terminal?`: the face is drawn where the protocol
40+ for one is, and where it is not an `:image` is `[ picture ]` printed beside
41+ every nick — worse than the nothing that is there now."
42+ []
43+ (and @terminal? @terminal-graphics?))
@@ -32,3 +32,12 @@
32 ;; And whether that terminal draws pictures over its cells — Kitty's graphics32 ;; And whether that terminal draws pictures over its cells — Kitty's graphics
33 ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.33 ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.
34 (defonce terminal-graphics? (atom false))34 (defonce terminal-graphics? (atom false))
35+
36+(defn terminal-face?
37+ "Whether a message carries a picture of its sender in a terminal.
38+
39+ Not the same question as `terminal?`: the face is drawn where the protocol
40+ for one is, and where it is not an `:image` is `[ picture ]` printed beside
41+ every nick — worse than the nothing that is there now."
42+ []
43+ (and @terminal? @terminal-graphics?))
added common/frq/screens/chat.cljc +1282 -0
new file mode 100644
@@ -0,0 +1,1282 @@
1+(ns frq.screens.chat
2+ "The conversation, shared.
3+
4+ The largest of frq.app's screens and the last of its three big ones: the
5+ message list with its replies, edits and reactions, the emoji picker, the
6+ people panel, the overview, the call wall and the compose bar.
7+
8+ It moved the way the other two did the same hiccup, reading `frq.cells`
9+ and `frq.rooms`, calling `frq.actions`. What is different here is how much
10+ of it is not state at all: where a face or a picture is on disk is a fetch
11+ and a cache, and a call is a media plane, so those are actions rather than
12+ cells and a platform that has neither simply does not install them. The
13+ screen then draws what it already draws when a face has not arrived yet."
14+ (:require [clojure.string :as str]
15+ [frq.actions :as actions]
16+ [frq.cells :as cells]
17+ [frq.clock :as clock]
18+ [frq.emoji :as emoji]
19+ [frq.glyphs :as glyphs]
20+ [frq.metrics :refer [chrome-row chrome-scale terminal? terminal-face?]]
21+ [frq.rooms :as rooms]
22+ [frq.screens.chats :refer [preview-line tab-bar]]
23+ [frq.screens.connect :refer [error-note]]))
24+
25+(def ^:private text-emoji-size
26+ "An emoji in a message, at the size of the words around it. Bigger and the
27+ line it sits in grows to make room for it; smaller and it reads as a
28+ footnote on the sentence rather than a word of it."
29+ 14)
30+
31+(def ^:private pill-size
32+ "How big a reaction is under a message. Small: it is a footnote on what was
33+ said, not a second thing said."
34+ 14)
35+
36+(def ^:private picker-columns
37+ "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
38+ phone-width window, which is the width this app is laid out for."
39+ 9)
40+
41+(defonce ^:private draft-rows (atom 1))
42+
43+;; What the rows under the conversation need left to them: the jump button's
44+;; row, the separator, the compose bar and the air around it. The columns of
45+;; the row reserve it, and so nothing inside them has to a `:scroll` that
46+;; subtracted it again inside a column already stopped short would take the
47+;; same points off the list twice.
48+;;
49+;; Plus the reply banner's row when there is one, and the pasted picture when
50+;; there is one. Those rows do move the compose bar, and should: each appears
51+;; because the reader just asked for something unlike the jump button, which
52+;; appears on its own and must not shift what is under it.
53+;; What the overview strip costs the conversation above it, in points: a row
54+;; for its heading, a row a line, and the separator and gaps around them. The
55+;; backlog's `:reserve` takes this too, so the strip is paid for out of the
56+;; conversation rather than pushing the compose bar off the bottom which is
57+;; what every other row under the message list has had to say for itself.
58+;;
59+;; Counted from the lines there actually are, not from the ceiling: the strip
60+;; holds at most `overview-lines` of them there, and a room with two lines in
61+;; it should not have a screenful reserved against it.
62+;;
63+;; The window reserves nothing for the overview. Its pane scrolls and fills,
64+;; so it takes its half of the column by being in it there is no number to
65+;; subtract, and subtracting one would take the room twice.
66+
67+(def emoji-groups
68+ "Unicode's own grouping, which is what the picker's tabs are."
69+ emoji/groups)
70+(def picker-limit
71+ "How many glyphs the picker will lay out at once — twelve rows of the nine it
72+ fits across. It stands inside the message list, so what it shows pushes the
73+ conversation down; a group that has more says so, and the search box is how
74+ you reach the rest."
75+ 108)
76+(def overview-lines 8)
77+
78+(defn- overview-height []
79+ (* (chrome-scale)
80+ (+ 24 (* 20 (min overview-lines (count (actions/recent-everywhere)))) 16)))
81+
82+(defn- below-messages []
83+ ;; 115 is that, counted: the gap under the row of columns, the jump button's
84+ ;; 34pt row, the banners in their one wrapper with one gap, then the compose
85+ ;; row and the air under it its own and the window's. Short by any of it
86+ ;; and the column runs past the bottom edge, which does not show as a list
87+ ;; that is too long: it shows as a compose bar sitting flat on the bottom of
88+ ;; the window with its margin cut off.
89+ ;;
90+ ;; It was 140 when a rule was drawn above the compose bar and the three
91+ ;; banners each cost the column a gap of their own. The rule went, and they
92+ ;; went into one wrapper: twenty-five points of nothing, given back.
93+ (+ (* (chrome-scale)
94+ (+ 115
95+ (if @cells/replying-to 34 0)
96+ (if @cells/attachment 76 0)))
97+ ;; The two extra rows the terminal's compose field wraps into, in points:
98+ ;; a row down the page is two cells' worth of the scale.
99+ (if @terminal? (* 4 (chrome-scale)) 0)
100+ ;; And the lines a window's compose field has GROWN by. Unscaled and
101+ ;; outside the terminal's arm on purpose: the field only reports its
102+ ;; height where it can change the terminal's is the fixed block of
103+ ;; three rows reserved just above so `draft-rows` is one there and
104+ ;; this term is zero. Twenty points a line is a 16-point face's line
105+ ;; box rounded up: the reserve has to be at least what the field took,
106+ ;; because a point too few does not crop the list, it slides the
107+ ;; compose bar off the bottom of the window.
108+ (* 20 (dec @draft-rows))
109+ ;; And the overview strip, when it is up. Reserved here rather than
110+ ;; anywhere else because this is the number the backlog is laid out
111+ ;; against: without it the strip is drawn past the bottom of the window
112+ ;; and takes the compose bar with it.
113+ (if (and @terminal? @cells/overview?) (overview-height) 0)))
114+
115+(defn- call-controls
116+ "What the person in a call can do about it.
117+
118+ Mute and deafen are separate buttons because they are separate things: a
119+ deafened microphone still carries your voice, and one control for both would
120+ make the quieter of the two a surprise."
121+ []
122+ (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
123+ [:vbox {:key :call-controls :spacing 6}
124+ [:hbox {:spacing 8}
125+ [:label {:label (case media
126+ :waiting "Asking to join…"
127+ :dialling "Connecting…"
128+ :live "In call"
129+ :failed "Call failed"
130+ "In call")}]
131+ ;; A microphone that is not there is worth saying so: the call works,
132+ ;; and the person is listening rather than silent by choice.
133+ (when (and (= :live media) (not has-mic?))
134+ [:dim-label {:label "· listening only"}])]
135+ [:hbox {:spacing 8}
136+ [:button {:label (if muted? "Unmute" "Mute")
137+ :on-click #(av/set-muted! (not muted?))}]
138+ [:button {:label (if speaker-muted? "Undeafen" "Deafen")
139+ :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
140+ ;; Only offered when there is a camera to turn on. Nothing is more
141+ ;; annoying than a control that does nothing and does not say why.
142+ (when has-camera?
143+ [:button {:label (if camera? "Stop video" "Start video")
144+ :on-click #(av/set-camera! (not camera?))}])
145+ [:button {:label "Leave" :on-click #(actions/leave-call!)}]]
146+ (when-let [e @av/media-error]
147+ [:dim-label {:label (str "" e)}])]))
148+
149+(defn- call-tile
150+ "One participant's picture, at the width the row worked out for it.
151+
152+ `:feed` rather than `:src`: these pixels never touch the disk and never
153+ become a value here the media plane hands the decoder's own buffer to
154+ Vidya as a pointer, and the tag paints whatever arrived last under that name.
155+
156+ The height is three quarters of the width, which is the shape a camera hands
157+ over. Naming both keeps a portrait phone from making its tile tall enough to
158+ push the row off the screen the picture is fitted inside, never stretched."
159+ [width key]
160+ (let [mine? (= av/local-feed key)]
161+ [:vbox {:key key :spacing 2}
162+ ;; `:upscale` because a tile is a slot the layout sized, not a picture
163+ ;; sitting at whatever the camera happened to send. Without it a 480-wide
164+ ;; stream draws 480 wide in a 900-point slot and the wall looks broken
165+ ;; which is exactly what it did.
166+ [:image {:feed key
167+ :upscale true
168+ :max-width width
169+ :max-height (long (* width 0.75))}]
170+ [:dim-label {:label (if mine? "You" key)}]]))
171+
172+(defn- call-wall
173+ "Everyone with a camera on, sized to the window they are being watched in.
174+
175+ A call with no video is the normal case and should look like one a row of
176+ empty frames would suggest something had failed to load.
177+
178+ Both cells this reads are what subscribe it: `av/tiles` for who is on
179+ screen, and the window width so the tiles follow a window being dragged.
180+ Without either it would lay itself out once, on the first frame, and keep
181+ that shape for the rest of the call."
182+ []
183+ (let [[width rows] (av/tile-rows)]
184+ [:vbox {:key :call-wall :spacing 6}
185+ ;; A seq, not a vector: children splice, and a vector would be read as one
186+ ;; more hiccup element which an empty one is not.
187+ (for [[i keys] (map-indexed vector rows)]
188+ [:hbox {:key i :spacing 8}
189+ (for [key keys]
190+ [call-tile width key])])]))
191+
192+(defn call-bar
193+ "The call in this channel, whatever state it is in. Always a node.
194+
195+ Three cases, and the empty one matters as much as the others: a channel with
196+ no call must render *something* here, because the reconciler matches children
197+ by position and a banner that came and went would patch the message list into
198+ a button."
199+ [channel]
200+ [:vbox {:key :call-bar :spacing 6}
201+ (cond
202+ (actions/in-call? channel)
203+ [:card {}
204+ [:vbox {:spacing 6}
205+ [call-controls]
206+ [call-wall]]]
207+
208+ ;; A call is open in this room and we are not in it.
209+ (actions/call-in channel)
210+ (let [{:keys [session-id participants title]} (actions/call-in channel)]
211+ [:card {}
212+ [:hbox {:spacing 8}
213+ [:label {:label (str "📞 " (or title "Call in progress")
214+ (if (and participants (pos? participants))
215+ (str " · " participants)
216+ ""))}]
217+ [:button {:label "Join"
218+ :on-click #(actions/join-call! channel session-id)}]]])
219+
220+ ;; We are in a call, but in a different room. Say which, since the
221+ ;; controls are not on this screen to be found by looking.
222+ (actions/in-call?)
223+ [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
224+
225+ :else nil)])
226+
227+(defn- day-separator [day-key label]
228+ [:vbox {:key day-key :spacing 4 :margin 0}
229+ [:separator {}]
230+ [:dim-label {:label label}]])
231+
232+(def ^:private face-size 32)
233+
234+(defn- chip-gap
235+ "The air between two chips on a row.
236+
237+ Four points is half a column, which rounds to none: in a window that is the
238+ gap a pair of lozenges want, and in a terminal it puts two emoji hard against
239+ each other and they read as one wide glyph. A column, where a column is the
240+ smallest thing there is."
241+ []
242+ (if @terminal? 8 4))
243+
244+(defn- action-chips
245+ "Answering and reacting, on the sender's row above the message.
246+
247+ Both are things done *to* a message rather than parts of it, so they ride
248+ the sender's row against its right edge, at the size a reaction is. In the
249+ line with the text they took width off every line under them and wrapped a
250+ message that had the room to sit on one.
251+
252+ Chips rather than buttons: a button is sized for a label and these carry a
253+ glyph, which made two lozenges taller than the line they belonged to.
254+ `:reaction` with no count is the same pill the picker offers, so what you
255+ press to react and what appears once you have look like one family.
256+
257+ A row laid out from the right lays its first child furthest right, so
258+ reacting comes first in the source and this reads then then 🙂 on
259+ screen where there is a pencil at all. Only our own lines carry one: the
260+ server refuses an edit of somebody else's, and a chip that always fails is a
261+ chip that lies about what can be done.
262+
263+ The pencil is a chip beside the other two rather than a box holding one: a
264+ wrapper is laid out as a child in its own right, which put the pencil on the
265+ row at a remove from the pair it belongs with. It keys itself, so the line
266+ that has no pencil is a row of two chips and not a row with a hole in it."
267+ [channel m]
268+ (into [:hbox {:key :actions :align :end :spacing (chip-gap)}
269+ [:reaction {:key :react
270+ :emoji "🙂"
271+ :size pill-size
272+ :on-click #(actions/open-picker! channel m)}]
273+ [:reaction {:key :reply
274+ :emoji "↩️"
275+ :size pill-size
276+ :on-click #(actions/reply-to! m)}]]
277+ (when (actions/mine? m)
278+ [[:reaction {:key :edit
279+ :emoji "✏️"
280+ :size pill-size
281+ :on-click #(actions/start-edit! channel m)}]])))
282+
283+(def ^:private picker-size 20)
284+
285+;; A row of chips, and the most the grid may take before it scrolls.
286+
287+(defn emoji-picker
288+ "The whole set to choose from, under the message it is for.
289+
290+ It opens in the list itself, directly under the line it was opened on:
291+ choosing a reaction is something done *to* a message, and the message has to
292+ stay in front of the reader while it is chosen which is not something a
293+ panel at the bottom of the window, or a screen of its own, can promise.
294+
295+ It opens on the emoji people actually react with; the groups and the search
296+ box are for the rest."
297+ []
298+ (let [shown (actions/picker-emoji)
299+ over (max 0 (- (count shown) picker-limit))
300+ rows (partition-all picker-columns (take picker-limit shown))
301+ searching? (seq (str/trim @cells/emoji-search))]
302+ [:vbox {:key :picker :spacing 4}
303+ [:hbox {:spacing 6}
304+ [:entry {:text @cells/emoji-search
305+ :width-request 240
306+ :placeholder "Search emoji"
307+ :on-change #(reset! cells/emoji-search %)}]
308+ [:button {:label "" :on-click actions/close-picker!}]]
309+ ;; The groups are what the search box is not: a way in for someone who has
310+ ;; no word for what they want. Their first word is enough to tell them
311+ ;; apart, and is what keeps them to two rows. They give way to the
312+ ;; search's own answer while something is typed.
313+ [:vbox {:key :groups :spacing 4}
314+ (when-not searching?
315+ (for [[i row] (map-indexed vector (partition-all 5 (cons nil emoji-groups)))]
316+ [:hbox {:key i :spacing 4}
317+ (for [g row]
318+ [:button {:key (or g "popular")
319+ :label (if g (first (str/split g #" ")) "Popular")
320+ :kind (if (= g @cells/emoji-group) :primary :normal)
321+ :on-click #(reset! cells/emoji-group g)}])]))]
322+ ;; The grid itself, not a scroll around it: the picker sits inside the
323+ ;; message list, and a scroll within a scroll takes the wheel away from
324+ ;; the conversation it is standing in. What it shows is bounded instead,
325+ ;; and the search box is how you reach past that.
326+ [:vbox {:key :grid :spacing 2}
327+ (if (seq rows)
328+ (for [[i row] (map-indexed vector rows)]
329+ [:hbox {:key i :spacing 2}
330+ (for [[glyph] row]
331+ [:reaction {:key glyph
332+ :emoji glyph
333+ :size picker-size
334+ :count 0
335+ :on-click #(actions/react-from-picker! glyph)}])])
336+ [:dim-label {:label "No emoji by that name."}])]
337+ ;; What was left out, said rather than silently dropped.
338+ [:vbox {:key :more}
339+ (when (pos? over)
340+ [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
341+
342+(defn- indented
343+ "One of a message's columns, held in from the name above it — in a terminal.
344+
345+ The nick is the heading and the words hang under it, which is the shape a
346+ terminal has read a conversation in for forty years; in a window the column
347+ of faces already marks that edge, so there is nothing to hold in.
348+
349+ A cell of its own rather than a margin, because the backend insets a node by
350+ `:margin` on all four sides or not at all: there is no left margin in a cell
351+ grid, and a uniform one would spend a blank row above every message to buy
352+ two columns beside it.
353+
354+ The spacer asks for its width and no height. A `:size` is a length down the
355+ page, and a spacer two cells tall beside an empty column a message with no
356+ picture and no reactions is two blank rows in the middle of the backlog,
357+ which is a message and a half at this size.
358+
359+ Nothing to do where a face is drawn: the column that holds the portrait is
360+ already to the left of the whole message, and a second indent inside it would
361+ hold the words in from a name that is itself held in."
362+ [k node]
363+ (if (and @terminal? (not (terminal-face?)))
364+ [:hbox {:key k :spacing 0}
365+ [:spacer {:key :indent :size 0 :width-request 16}]
366+ node]
367+ node))
368+
369+(defn- preview-height
370+ "How tall a picture in the conversation may be.
371+
372+ Against the window rather than a fixed 260: on a phone-sized window a
373+ preview that tall is the whole screen, and on a laptop one it is a stamp of
374+ something worth looking at.
375+
376+ Four fifths of the window, because the pictures that arrive here are mostly
377+ phone screenshots 1080x2400, whose height is what binds, and any smaller
378+ share draws them as a strip too narrow to read a word of. A picture is what
379+ the message is; the line above it is enough context to place it, and the
380+ scroll is how the rest is reached.
381+
382+ Clamped at both ends, and falling back to the old fixed height until the
383+ first poll has landed: a preview is a preview either way, and the lightbox
384+ is what full size is for.
385+
386+ A terminal gets less of its screen, and a hard ceiling on top of that. Four
387+ fifths of a window is a picture you look at; four fifths of a terminal is a
388+ conversation with one message in it, and the rows a picture takes are rows
389+ that cannot hold a line. Twelve of them is enough to see what a picture is
390+ of, and to decide whether to open it."
391+ []
392+ (let [h @cells/window-height]
393+ (cond
394+ (not (pos? h)) 260
395+ @terminal? (max 80 (min 192 (long (* 0.4 h))))
396+ :else (min 900 (max 240 (long (* 0.8 h)))))))
397+
398+(defn- preview-width
399+ "How wide a picture in the conversation may be.
400+
401+ The height alone leaves a wide, short picture to take the column and push
402+ the words around it; this holds it inside the conversation the same way.
403+ Fitted, never stretched `:image` scales to whichever bound it meets first."
404+ []
405+ (let [w @cells/window-width]
406+ (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))
407+
408+(defn- reactor-card
409+ "Who is on a reaction, beside the pointer resting on it.
410+
411+ The pill carries a number, and a number is the one thing about a reaction
412+ nobody wants: who it was is what a room is read for. Names, one to a line,
413+ with your own said as \"you\" — you are the one name in the list you cannot
414+ learn anything from."
415+ [emoji nicks]
416+ [:vbox {:spacing 4}
417+ [:hbox {:spacing 6}
418+ [:reaction {:key :glyph :emoji emoji :size pill-size}]
419+ [:dim-label {:label (str (count nicks)
420+ (if (= 1 (count nicks)) " reaction" " reactions"))}]]
421+ [:vbox {:key :who :spacing 2}
422+ (for [nick (sort nicks)]
423+ [:label {:key nick
424+ :label (if (= nick @cells/form-nick) "you" nick)}])]])
425+
426+(defn reactor-dialog
427+ "Who is on the reaction the pointer is resting on.
428+
429+ The same dialog a face gets, for the same reason: two hover cards in two
430+ shapes was two things to look at and two things to keep working. Non-modal
431+ and no buttons, because the pointer is what is holding it open a modal one
432+ would go deaf to the pill leaving and never close, and a button in it could
433+ never be reached.
434+
435+ The message is looked up rather than carried: a pill says which message and
436+ which emoji it is, and the room it is in is the one being read a pill in
437+ any other room is not under a pointer."
438+ []
439+ (let [{:keys [id emoji]} @cells/reaction-hover
440+ nicks (get (:reactions (actions/message-by-id @cells/current id)) emoji)]
441+ (when (seq nicks)
442+ [:dialog {:label "Reactions" :max-width 320 :modal false}
443+ [reactor-card emoji nicks]])))
444+
445+(defn- reaction-row
446+ "What people have put on a message, under it.
447+
448+ A pill carries its count and toggles: clicking one you are already on takes
449+ yours off, which is the same gesture that put it there. `:reaction` rather
450+ than a button with the emoji as its label the chip draws the glyph from the
451+ Twemoji pack, in colour, where a label gets whatever the text font has."
452+ [channel m]
453+ (let [reactions (:reactions m)]
454+ ;; `into` and not a lazy `for` inside the vector. The pills read
455+ ;; `my-reaction?` a ratom and a ratom read while a lazy seq is being
456+ ;; realised somewhere other than the render is a read the component never
457+ ;; records, so the row went on showing what it showed before. The pictures
458+ ;; in `message-body` are the other place a ratom is read inside a `for`,
459+ ;; and are made eager for the same reason.
460+ (into
461+ [:hbox {:key :pills :spacing (chip-gap)}]
462+ (for [emoji (sort (keys reactions))]
463+ [:reaction (cond-> {:key emoji
464+ :emoji emoji
465+ :size pill-size
466+ :count (count (get reactions emoji))
467+ :mine (actions/my-reaction? m emoji)
468+ :on-click #(actions/toggle-reaction! channel m emoji)}
469+ ;; Where there is a pointer to ask with, resting on a pill
470+ ;; says who put it there. On a phone the pill is a button
471+ ;; and nothing more: there is no hover to answer.
472+ (actions/desktop?)
473+ ;; Nothing is hung under the pill any more: who is on a
474+ ;; reaction is shown the way everything else the pointer
475+ ;; asks for is shown now the dialog at the root of the
476+ ;; tree, which `reactor-dialog` builds out of
477+ ;; `reaction-hover`. The pill's only job is to say where
478+ ;; the pointer is.
479+ (assoc :on-hover #(actions/hover-reaction! (:id m) emoji)
480+ :on-unhover #(actions/unhover-reaction! (:id m) emoji)))]))))
481+
482+(defn- goto-message!
483+ "Show message `id`, in `channel`, and say which one it was.
484+
485+ Three things in a fixed order: be in the room, aim the scroll at the line,
486+ and mark it once it is there. `jump-to` comes off again as soon as the frame
487+ that scrolled has been painted a scroll target that stays set pins the
488+ view and takes scrolling away from the reader while the highlight outlives
489+ it, because arriving at a screenful of messages says nothing about which one
490+ was asked for.
491+
492+ `settle` is how long that first frame is given, and `linger` how long the
493+ mark stays. Within a room the scrolling frame is the next one and the line
494+ is somewhere the reader was already looking. Crossing a room is a slower
495+ and a stranger arrival the screen has to be built before there is
496+ anything to scroll, and what it opens on is a conversation the reader was
497+ not in a moment ago so a caller that crosses one asks for more of both."
498+ [channel id settle linger]
499+ (when (and channel (not= channel @cells/current))
500+ (actions/open-channel! channel))
501+ (reset! cells/jump-to id)
502+ (reset! cells/highlight id)
503+ (platform/after! settle (fn [] (reset! cells/jump-to nil)))
504+ ;; The highlight only clears itself: a later jump elsewhere owns the
505+ ;; highlight from then on.
506+ (platform/after! linger (fn [] (when (= id @cells/highlight)
507+ (reset! cells/highlight nil)))))
508+
509+(defn- summarise
510+ "A message in one line's worth of words."
511+ [m limit]
512+ (let [text (str/replace (or (:text m) "") #"\s+" " ")]
513+ (if (> (count text) limit)
514+ (str (subs text 0 (dec limit)) "")
515+ text)))
516+
517+(defn- reply-chip
518+ "What a message is replying to, above it, and a way back to it.
519+
520+ The chip carries the quote rather than only a marker: a reply is unreadable
521+ without knowing what it answers, and the message it answers is usually off
522+ the top of the screen. Clicking takes you there."
523+ [channel id]
524+ [:vbox {:key :reply}
525+ (if-let [target (actions/message-by-id channel id)]
526+ ;; A link, not a button: the chip is a pointer back to a line, not an
527+ ;; action, and a filled pill above every answer was the loudest thing in
528+ ;; the column.
529+ [:link {:label (str "" (:from target) ": " (summarise target 48))
530+ ;; The line being answered is in the room already open, so the
531+ ;; next frame is the one that scrolls.
532+ :on-click #(goto-message! channel id 120 2000)}]
533+ ;; The message it answers is older than this buffer goes.
534+ [:dim-label {:label "↩ replying to an earlier message"}])])
535+
536+(defn- word-node
537+ "Body text, or dim text for the lines the client writes itself."
538+ [k value system?]
539+ (if system?
540+ [:dim-label {:key k :label value}]
541+ [:label {:key k :label value}]))
542+
543+(defn- run-node
544+ "One run as a widget. A link is accent-coloured and opens on click; the rest
545+ is body text.
546+
547+ Except for the emoji in it. A label is set in the text font, and that font
548+ Ubuntu with a DejaVu subset behind it has no emoji in it at all, so every
549+ one anybody typed was drawn as tofu. The Twemoji pack has the picture, so
550+ the emoji come out of the text and are drawn from the pack, and the words
551+ either side stay text.
552+
553+ `:emoji` and not `:reaction`, though both draw the same picture. A reaction
554+ is a tally: it wears a pill, it answers the pointer, and it names the people
555+ in it on hover. An emoji in a sentence is a character so it is drawn bare,
556+ and a message that ends in one no longer reads as a message someone has
557+ reacted to.
558+
559+ The pieces go in a wrapping row rather than the column the runs themselves
560+ are stacked in: a row is what puts an emoji *in* a sentence instead of
561+ breaking the sentence around it, and `:hbox` wraps its children by default,
562+ so a long line still folds at the column's edge. A run with no emoji in it
563+ is still one plain label the row is only paid for where it is needed."
564+ [j [kind value] system?]
565+ (if (= :link kind)
566+ [:link {:key j :label value :on-click #(platform/open-url! value)}]
567+ (let [pieces (glyphs/runs (str/trim value))]
568+ (if (glyphs/emoji? pieces)
569+ ;; Runs alternate text and picture, so this gap only ever falls either
570+ ;; side of an emoji never between two words, whose spacing is the
571+ ;; spaces the message was typed with. A bare glyph carries no margin of
572+ ;; its own the way the pill did, and a picture set flush against a word
573+ ;; reads as part of it.
574+ [:hbox {:key j :spacing 2}
575+ (map-indexed (fn [k [pkind pvalue]]
576+ (if (= :emoji pkind)
577+ [:emoji {:key k :emoji pvalue :size text-emoji-size}]
578+ (word-node k pvalue system?)))
579+ pieces)]
580+ (word-node j (str/trim value) system?)))))
581+
582+(defn- trim-trailing-punctuation
583+ "A URL at the end of a sentence would otherwise keep the sentence's
584+ punctuation. A closing bracket only counts as trailing when the URL does not
585+ open one itself, which is what keeps a wikipedia-style path intact."
586+ [url]
587+ (loop [u url]
588+ (let [c (last u)]
589+ (cond
590+ (nil? c) u
591+ (contains? #{\. \, \; \: \! \?} c) (recur (subs u 0 (dec (count u))))
592+ (and (= \) c) (not (str/includes? u "("))) (recur (subs u 0 (dec (count u))))
593+ :else u))))
594+
595+(def ^:private url-pattern #"https?://[^\s<>\"]+")
596+
597+(defn text-runs
598+ "Message text as alternating [:text s] and [:link url] runs.
599+
600+ Runs because a link has to be its own widget to be clickable, and stacked
601+ rather than laid out in a row because a wrapping label inside a horizontal
602+ row lays out against the row's width, not the column's which is what drags
603+ long URLs off the left edge."
604+ [text]
605+ (let [text (or text "")]
606+ (loop [pos 0 acc []]
607+ (if-let [raw (re-find url-pattern (subs text pos))]
608+ (let [url (trim-trailing-punctuation raw)
609+ at (+ pos (str/index-of (subs text pos) raw))
610+ before (subs text pos at)
611+ acc (cond-> acc (seq before) (conj [:text before]))]
612+ (recur (+ at (count url)) (conj acc [:link url])))
613+ (let [tail (subs text pos)]
614+ (cond-> acc (seq tail) (conj [:text tail])))))))
615+
616+(defn- message-body
617+ "A message without its face: the sender's line, the words, and what hangs
618+ under them.
619+
620+ Its own component because a terminal that draws pictures puts the face
621+ beside all of this rather than on the sender's line — the shape the window
622+ has always had, and the one that spends no row on the half of a portrait
623+ that is taller than a line of text."
624+ [m highlit?]
625+ ;; A key each: the surface a jump leaves behind is a different node from the
626+ ;; plain column, not the same node wearing another tag.
627+ [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
628+ :spacing 2 :margin 0}
629+ ;; Who, when, and what can be done about it, on one line above the
630+ ;; words: the name and the time say where the message came from, and the
631+ ;; chips at the far end are the two answers to it.
632+ [:vbox {:key :who}
633+ ;; An event has no sender to head it, but it still happened at a time,
634+ ;; and a column of joins and quits with no clock on it says nothing
635+ ;; about how long the room was quiet. The time alone is that heading.
636+ (when (and (:system? m) (:at m))
637+ [:dim-label {:label (clock/clock-time (:at m))}])
638+ (when-not (:system? m)
639+ ;; Everything that is about the person rather than the line: their
640+ ;; picture, their name, and when they started saying this.
641+ [:hbox {:spacing 6}
642+ ;; Always an avatar, picture or not: the initial stands in until the
643+ ;; fetch lands, and for the guests who have no profile at all, which
644+ ;; is what keeps the column of faces straight down the left.
645+ ;; `avatar-path` is what wakes this row when that fetch lands.
646+ ;; The face is also the way to the person behind it: Vidya's plain
647+ ;; label does not answer the pointer, so the tap sleek puts on the
648+ ;; nick lives here, on the one thing in the row that does.
649+ ;; And, where there is a pointer to ask with, resting on the face
650+ ;; answers before the click does: the card under it is the profile
651+ ;; screen's first few lines, painted beside the pointer.
652+ ;; And no face on this line in a terminal: the initial that stands in
653+ ;; for a portrait in a window is a letter printed twice in a cell grid,
654+ ;; which only pushed every nick in past the words it heads. Where the
655+ ;; terminal can draw a picture there is a face after all, hung beside
656+ ;; the whole message rather than off its heading `message-row` has it.
657+ (when-not @terminal?
658+ (let [src @(actions/avatar-path (:actor m))]
659+ ;; One profile, two gestures, and no card hung under the face: the
660+ ;; pointer opens the dialog and the press pins it. What makes that
661+ ;; work is the dialog being non-modal while the pointer is what is
662+ ;; holding it open see `profile-dialog`.
663+ [:avatar (cond-> {:label (:from m)
664+ :src (or src "")
665+ :size face-size
666+ :on-click #(profile/open! (:from m) (:actor m))}
667+ (actions/desktop?)
668+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
669+ :on-unhover #(profile/unhover! (:from m))))]))
670+ ;; The name carries the row, so it is set at body size in the plain
671+ ;; text colour: dimmed caption made the one thing you scan a column
672+ ;; for the faintest thing on it.
673+ [:label {:label (:from m)}]
674+ (when-let [at (:at m)]
675+ [:dim-label {:label (clock/clock-time at)}])
676+ ;; Beside the clock, because it is the same kind of fact about the
677+ ;; line: what is on screen is not what was first said.
678+ (when (:edited? m)
679+ [:dim-label {:label "(edited)"}])
680+ ;; And the two things you can do to the message, at the far end of
681+ ;; its heading: a nested row laid out from the right takes what is
682+ ;; left of the width and puts the chips against the edge of it, so
683+ ;; the words below keep the whole column.
684+ ;;
685+ ;; A key of its own for each branch, rather than one key whose node
686+ ;; changes tag under it: a line this client sent has no msgid until
687+ ;; the server echoes it back, and swapping a `:spacer` for a chip in
688+ ;; place is what left the row rebuilt in the wrong order.
689+ (if (:id m)
690+ [action-chips {:key :actions} @cells/current m]
691+ [:spacer {:key :actions-gap :size 0}])])]
692+ ;; A little air under the sender's row: the name and face are a heading
693+ ;; for what follows, and a heading that touches its text is not one.
694+ ;; The text keeps a column of its own: a wrapping label directly in a
695+ ;; row wraps against the row.
696+ (indented
697+ :text
698+ [:vbox {:key :text :spacing 2 :margin-top 4}
699+ ;; What this answers, in the column the answer itself is in: the chip
700+ ;; is a quote of a line, so it wraps to the width a line has. Outside
701+ ;; this column it wrapped to the whole row instead wider than any
702+ ;; message, and out to the window's edge on a narrow screen.
703+ [:vbox {:key :reply-chip}
704+ (when-let [reply-to (:reply-to m)]
705+ [reply-chip @cells/current reply-to])]
706+ (map-indexed (fn [j run] (run-node j run (:system? m)))
707+ (text-runs (:text m)))])
708+ ;; And the picker, when this is the message it was opened on: under the
709+ ;; line it is about, where the reader is already looking.
710+ ;;
711+ ;; The id has to exist, not merely match: a line this client sent itself
712+ ;; has no msgid, and neither does a closed picker so `nil = nil` was
713+ ;; every one of those messages opening a picker of its own at startup.
714+ [:vbox {:key :picker :margin-bottom 4}
715+ (when (and (:id m) (cells/derived-value [:reacting (:id m)] #(= (:id m) (:id @cells/reacting))))
716+ [emoji-picker])]
717+ ;; Pictures under the line that linked them. The link stays: it is what a
718+ ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
719+ (indented
720+ :images
721+ [:vbox {:key :images :spacing 4}
722+ (when (seq (:images m))
723+ ;; Eager, so each read happens during the render and is recorded see
724+ ;; `reaction-row`. Each picture is read through `image-path`, which
725+ ;; wakes this row for its own pictures landing and not for everyone's.
726+ (doall
727+ (for [url (:images m)]
728+ (when-let [path @(actions/image-path url)]
729+ [:image {:key url
730+ :src path
731+ :max-height (preview-height)
732+ :max-width (preview-width)
733+ :on-click #(reset! cells/lightbox {:path path :url url})}]))))])
734+ ;; Reactions go last, under whatever the message turned out to be: a
735+ ;; line with a picture on it is the picture, and pills between the words
736+ ;; and the image they introduce read as reactions to the words alone.
737+ ;; Nothing moves for a message with no picture the box above it is
738+ ;; empty, and the pills still sit under the last line of text.
739+ (indented
740+ :reactions-row
741+ [:vbox {:key :reactions-row :margin-top 6}
742+ (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
743+ [reaction-row @cells/current m])])])
744+
745+(defn message-row
746+ "One message: who said it, when, what you can do to it, and the words.
747+
748+ Every line names its sender, rather than the first of a run only. A run
749+ collapsed to one heading reads well until you answer the fourth line of it,
750+ and then the line quoted back has no name on it; and the actions live on the
751+ sender's row, which a headerless line has nowhere to put.
752+
753+ Sender above the text, not beside it: a wrapping label in a horizontal row
754+ lays out against the row's width rather than the column's, so one long URL
755+ drags every line that follows it off the left edge."
756+ [i m]
757+ (let [;; What a jump landed on wears a surface of its own for a moment, so
758+ ;; the answer to "which one was I sent to" is on the screen rather
759+ ;; than in the reader's count of rows.
760+ ;; Through `derived`, as is everything below that asks about shared
761+ ;; state: a row that read the highlight itself re-rendered for every
762+ ;; jump anywhere in the backlog.
763+ highlit? (boolean (and (:id m)
764+ (cells/derived-value [:highlit (:id m)]
765+ #(= (:id m) @cells/highlight))))]
766+ [:vbox {:key i :spacing 2 :margin 0
767+ ;; Clear of the right edge: the actions ride that edge, and the
768+ ;; list's scrollbar rides it too — without this the ↩ is what the
769+ ;; scrollbar is drawn over.
770+ :margin-right 10
771+ ;; One gap between messages: every line carries its own heading
772+ ;; now, so there are no runs to hold together and nothing for a
773+ ;; closed-up line to belong to.
774+ :margin-top 10
775+ ;; The jump target is what a "go to message" click scrolls to.
776+ :scroll-here (boolean (and (:id m)
777+ (cells/derived-value [:jump (:id m)]
778+ #(= (:id m) @cells/jump-to))))}
779+ ;; The gap above, where the margin cannot be one. `:margin-top 10` is
780+ ;; most of a row in a window and nothing at all in a terminal ten points
781+ ;; against a row of sixteen, rounded down, because a gap that thin is what
782+ ;; it looks like at this size. So the terminal buys the row outright:
783+ ;; without it every message in the backlog touches the one above it, and a
784+ ;; conversation reads as one paragraph with names scattered through it.
785+ (when @terminal? [:spacer {:key :gap :size 16}])
786+ ;; The face beside the message, where the terminal can draw one: a
787+ ;; portrait is two rows tall and a heading is one, so hanging it off the
788+ ;; sender's line would leave its lower half beside a blank row. Here the
789+ ;; words take that row, and the column the face makes is the indent every
790+ ;; other row of the message is held in by `indented` steps aside for it.
791+ (if (terminal-face?)
792+ [:hbox {:key :faced :spacing 8}
793+ [:vbox {:key :face :width-request face-size}
794+ (when-let [path @(actions/avatar-path (:actor m))]
795+ [:image {:key :picture
796+ :src path
797+ :max-width face-size
798+ :max-height face-size}])]
799+ [message-body m highlit?]]
800+ [message-body m highlit?])]))
801+
802+(defn- message-rows
803+ "The messages, with a heading wherever the day changes.
804+
805+ A backlog can reach back weeks, and `11:04 AM` says nothing about which day
806+ it was. The heading is what makes the time above it mean something."
807+ [messages]
808+ (mapcat (fn [i m]
809+ (let [prev (when (pos? i) (nth messages (dec i)))
810+ day (some-> (:at m) clock/day)
811+ new-day? (and day (not= day (some-> (:at prev) clock/day)))]
812+ (cond-> []
813+ new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m))))
814+ ;; A new day breaks the run: the first line under a heading
815+ ;; names its sender and its time, whoever spoke last night.
816+ ;; Keyed by the line itself, not by where it sits: the list is
817+ ;; reconciled by position when its children are not all keyed,
818+ ;; and a line that arrives anywhere but the end a backlog
819+ ;; replay, an echo taking the place of what was sent shifts
820+ ;; every row after it onto the widgets of its neighbour.
821+ true (conj ^{:key (or (:id m) (str "row-" i))}
822+ [message-row i m]))))
823+ (range (count messages))
824+ messages))
825+
826+(defn- messages-scroll-key
827+ "What the backlog's scroll position is remembered under.
828+
829+ One name in a window: `:scroll-to-bottom` is how the jump button is answered
830+ there, and the position under that name is the one the reader left behind.
831+
832+ The terminal backend has no `:scroll-to-bottom` a viewport there is moved
833+ by the wheel and the page keys and by nothing else but it does open a
834+ sticky viewport it has never seen at the bottom, which is the same thing
835+ said differently. So a jump renames the viewport: the tick that asks the
836+ window to scroll gives the terminal a name with no position saved under it,
837+ and the newest line is what it opens on.
838+
839+ Only on a jump, so scrolling and every message that arrives between two
840+ jumps still find the position where they left it."
841+ []
842+ (if @terminal?
843+ (str "chat-messages-" @cells/jump-tick)
844+ "chat-messages"))
845+
846+(def sidebar-width 320)
847+
848+;; How tall a row of chrome is a button, the compose bar, a line of tabs.
849+;;
850+;; The two reserves below are counted in points against this: a window's row is
851+;; 34 of them, and every gap around it was chosen at that size. A terminal's
852+;; row is one cell, and the same count then reserves two or three times the
853+;; room the strip under the list actually needs which costs a message a row,
854+;; and a conversation is measured in how many of those fit.
855+;;
856+;; So the counts stay as they are, written where the reasoning is, and a
857+;; backend whose rows are a different height says so here. Nothing else in the
858+;; tree needs it: every other number is a length, and a length scales on the
859+;; way across.
860+
861+(def ^:private users-width 150)
862+
863+;; How many lines the compose field is currently drawn as. The window backend
864+;; says so when it changes a field with room to grow wraps a long message
865+;; onto a second and third line instead of sliding it sideways and the
866+;; reserve below has to hear about it, since nothing in this tree is laid out
867+;; by anything but these counts.
868+
869+(defn- messages-width
870+ "How wide the message list may be with the people panel beside it.
871+
872+ Measured from the window rather than from what egui has left: the row is
873+ painted left to right, and by the time the panel is placed the list has
874+ already taken everything. On a wide window the chats list is holding the
875+ first `sidebar-width` of the window; the rest is the margins and the gap
876+ between the two columns."
877+ []
878+ (let [pane (- @cells/window-width (if (and (actions/wide?) (not @cells/hide-chat-list?))
879+ sidebar-width
880+ 0))]
881+ (max 240 (- pane users-width 8 28))))
882+
883+(defn- overview-row
884+ "One line from somewhere else: which room, who, and what — on one row.
885+
886+ The room leads, because that is the whole question the strip answers. The
887+ name is a button for the same reason it is one in the people panel: the
888+ room is somewhere to go, and a line you want more of is a line you want the
889+ conversation behind it."
890+ [i m]
891+ [:hbox {:key i :spacing 8 :wrap false}
892+ ;; The room is the button, and pressing it goes to this line rather than to
893+ ;; the room's end: the line is what was read here and what the press was
894+ ;; about, and a jump that landed on the newest message instead would answer
895+ ;; a question nobody asked from a strip that was showing the answer.
896+ ;;
897+ ;; Unless the line has no id to aim at one this client has sent and the
898+ ;; server has not echoed back yet and then the room is all there is to
899+ ;; offer.
900+ ;;
901+ ;; 600ms to land and five seconds marked: the room is a room away, so the
902+ ;; screen it scrolls in has to be built before there is anything to scroll,
903+ ;; and what the reader arrives at is a conversation they were not in a
904+ ;; moment ago. The mark is the whole answer to "which of these was the line
905+ ;; I pressed", and it has to still be there when they have finished
906+ ;; recognising where they are.
907+ [:button {:label (:channel m)
908+ :on-click (if (:id m)
909+ #(do (actions/leaving-for-overview!)
910+ (goto-message! (:channel m) (:id m) 600 5000))
911+ #(do (actions/leaving-for-overview!)
912+ (actions/open-channel! (:channel m))))}]
913+ ;; And the line itself, as something to read rather than to press. It was a
914+ ;; link for a moment, which made the strip two things at once: a line the
915+ ;; server has echoed back has an id to aim at and a line this client has
916+ ;; just sent does not, so half the rows came out accent-coloured and half
917+ ;; plain, down the same list. The chip beside it is the way to the message
918+ ;; and is on every row either way.
919+ [:vbox {:key :said}
920+ [:dim-label {:label (str (when-let [at (:at m)] (str (clock/clock-time at) " "))
921+ (:from m) ": "
922+ (preview-line (:text m)))}]]])
923+
924+(defn- overview-pane
925+ "Every other room's recent lines in one list, newest at the top.
926+
927+ A strip under the conversation rather than a screen of its own: the question
928+ it answers is anything happening anywhere else is one you ask while
929+ reading something, and an answer you have to leave the room for is one you
930+ stop asking for.
931+
932+ In a window it scrolls, and the column it is in fills the height: two
933+ children of a column that both fill it are two halves of it, which is the
934+ horizontal split this is. The backlog above keeps its own scroll and its own
935+ place in it, so reading down here does not move the conversation.
936+
937+ A terminal gets a handful of lines and no scroll. Its rows are cells rather
938+ than points, and a second scrolling pane in a screen that is already a
939+ conversation, a compose bar and a tab bar leaves neither half enough rows to
940+ be worth reading so there it stays the strip it was, and `below-messages`
941+ reserves it by the row."
942+ []
943+ (let [lines (actions/recent-everywhere)
944+ ;; The way back, and only while there is somewhere to go: the strip
945+ ;; is the one thing here that moves you without your having chosen a
946+ ;; room, so it is the one thing that owes you an undo. Gone once you
947+ ;; are back in that room, where it would offer to take you where you
948+ ;; already are.
949+ back (let [room @cells/overview-return]
950+ (when (and room (not= room @cells/current))
951+ [:button {:label (str "← Back to " room)
952+ :on-click actions/overview-back!}]))
953+ ;; The row keeps its height whether or not the way back is in it. A
954+ ;; button is 34 points and a caption is not, so a heading that grew
955+ ;; one when you jumped and lost it when you came back moved every
956+ ;; line under it the strip rendering two different ways depending
957+ ;; on where you had been.
958+ heading (fn []
959+ [:hbox {:key :heading :spacing 8 :wrap false :align :center}
960+ [:dim-label {:label "Everywhere else"}]
961+ [:vbox {:key :back}
962+ (or back [:spacer {:size @chrome-row}])]])
963+ ;; The same heading on the same rule the conversation puts between two
964+ ;; days, and needed more here than there: these lines come from rooms
965+ ;; that were last spoken in at their own times, so a strip of them can
966+ ;; cross a day twice in ten rows where a single room's backlog crosses
967+ ;; one once a day. A clock alone would then be the only thing saying
968+ ;; which, and `9:40` says nothing about how long ago it was.
969+ rows (fn [ms]
970+ (if (seq ms)
971+ (mapcat
972+ (fn [i m]
973+ (let [prev (when (pos? i) (nth ms (dec i)))
974+ day (some-> (:at m) clock/day)
975+ new-day? (and day (not= day (some-> (:at prev) clock/day)))]
976+ (cond-> []
977+ new-day?
978+ (conj (day-separator (str "overview-day-" i)
979+ (clock/day-label (:at m))))
980+ true
981+ (conj ^{:key (str (:channel m) "-" (or (:id m) i))}
982+ [overview-row i m]))))
983+ (range (count ms))
984+ ms)
985+ [[:dim-label {:label "Nothing has been said in any other room yet."}]]))]
986+ (if @terminal?
987+ (into [:vbox {:key :overview :spacing 4 :margin-top 4}
988+ [:separator {}]
989+ [heading]]
990+ (rows (take overview-lines lines)))
991+ [:vbox {:key :overview :spacing 4 :margin-top 4 :fill-height true}
992+ [:separator {}]
993+ [heading]
994+ ;; Named, so coming back to a conversation does not throw away where
995+ ;; the reader had got to in here.
996+ (into [:scroll {:scroll-key "overview-list" :orientation :vertical
997+ :spacing 4}]
998+ (rows lines))])))
999+
1000+(defn- policy-note
1001+ "What this channel wants agreed to, and the one button that agrees to it.
1002+
1003+ Above the backlog rather than in it: the refusal is already a line in the
1004+ buffer, and a button scrolled away with last week's messages is a button
1005+ nobody finds. The rules come from the server; when it has none to give, its
1006+ own words about that are what shows."
1007+ [name buffer]
1008+ (when (:policy-required? buffer)
1009+ [:vbox {:spacing 4}
1010+ [:dim-label {:label (str name " asks you to accept its policy before joining.")}]
1011+ (for [[i line] (map-indexed vector (:policy-text buffer))]
1012+ ^{:key (str "policy-" i)} [:dim-label {:label line}])
1013+ [:button {:label "Accept policy" :kind :primary
1014+ :on-click #(actions/accept-policy! name)}]]))
1015+
1016+(defn- member-row [{:keys [nick prefix]}]
1017+ ;; The mode where there is one, dim: it says how someone is listed, not who
1018+ ;; they are, and the name is what the eye is scanning for. A space where
1019+ ;; there is none, rather than nothing at all: the label is always in the row,
1020+ ;; so every name starts at the same place and a column wrapped around a
1021+ ;; label that comes and goes is a column, which sits its text against the top
1022+ ;; of the row rather than on the line the name is on.
1023+ ;;
1024+ ;; The name is a button because a person is somewhere to go: pressing one
1025+ ;; opens a conversation with them.
1026+ [:hbox {:key nick :spacing 6 :wrap false}
1027+ ;; In a slot of its own width, so an "@" and a space take the same room and
1028+ ;; the names line up whatever mode is in front of them.
1029+ [:vbox {:key :mode :width-request 14}
1030+ [:dim-label {:label (if (seq prefix) prefix " ")}]]
1031+ [:button {:label nick :on-click #(actions/open-dm! nick)}]])
1032+
1033+(defn users-panel
1034+ "Who is in the channel, beside the conversation.
1035+
1036+ The list is the server's — NAMES on the way in, kept up by the joins and
1037+ parts after it so a channel this client has never been in has nothing to
1038+ show, and says so rather than showing an empty column."
1039+ [name]
1040+ (let [people (actions/member-list name)]
1041+ [:vbox {:key :users :width-request users-width :fill-height true
1042+ :reserve (below-messages) :spacing 8}
1043+ [:title-2 {:label (str "People " (count people))}]
1044+ [:scroll {:scroll-key (str "users-" name)
1045+ :orientation :vertical}
1046+ (if (seq people)
1047+ ;; Keyed, for the reason the conversation list is: this list reorders
1048+ ;; as people come and go, and a name is a button someone can be
1049+ ;; standing on.
1050+ (for [p people] ^{:key (:nick p)} [member-row p])
1051+ [:dim-label {:label "Nobody listed yet."}])]]))
1052+
1053+(defn chat-screen []
1054+ (let [name @cells/current
1055+ buffer (get @cells/channels name)
1056+ show-users? (and @cells/show-users? name (str/starts-with? name "#"))]
1057+ ;; Not a :page a page scrolls everything, which would carry the compose
1058+ ;; bar off the bottom with the backlog. The message list is the only thing
1059+ ;; that scrolls, bounded so what follows it keeps its room.
1060+ ;; Same margin all round: the compose row's own air is what centres it in
1061+ ;; the strip below the separator, and it is measured from this edge.
1062+ ;; Ctrl-End is the jump without the button, for a reader whose hands are
1063+ ;; on the compose bar. It is unhandled everywhere below the entry takes
1064+ ;; plain End for its own caret and leaves this one alone so it arrives
1065+ ;; here by bubbling up from whatever had the focus. The window backend
1066+ ;; registers the handler and never calls it: keys there belong to egui.
1067+ ;; `:fill-height` for the reason the chats screen gives on its own root:
1068+ ;; without it this column is as tall as what is in it, and the compose bar
1069+ ;; sits wherever the backlog happens to end rather than at the bottom.
1070+ [:vbox {:spacing 8 :margin 12 :fill-height true
1071+ :on-key (fn [k]
1072+ (when (= k "ctrl+end") (actions/jump-to-present!)))}
1073+ [:hbox {:spacing 8}
1074+ ;; The way back to the list, on a window with room for one thing at a
1075+ ;; time. Beside the list there is nothing to go back to, so the button
1076+ ;; goes in a wrapper of its own, since a child that comes and goes
1077+ ;; would otherwise renumber the row for the reconciler.
1078+ [:vbox {:key :back}
1079+ (when-not (actions/wide?)
1080+ [:button {:label "← Chats" :on-click #(reset! cells/screen :chats)}])]
1081+ ;; And the same room's other way of appearing, on a window wide enough
1082+ ;; to have been showing both: the list folds away and the conversation
1083+ ;; takes the whole row. Its own wrapper, since it is only offered where
1084+ ;; there are two panes to choose between.
1085+ [:vbox {:key :fold}
1086+ (when (actions/wide?)
1087+ [:button {:label (if @cells/hide-chat-list? "☰ Chats" "")
1088+ :on-click actions/toggle-chat-list!}])]
1089+ [:title {:label (or name "Chat")}]
1090+ ;; Same wrapper trick: only in a channel, and only when there is no call
1091+ ;; to join already the bar below offers Join in that case, and two ways
1092+ ;; into the same call is one more than anybody needs.
1093+ [:vbox {:key :call}
1094+ (when (and name
1095+ (str/starts-with? name "#")
1096+ (actions/call-available?)
1097+ (not (actions/call-in name))
1098+ (not (actions/in-call?)))
1099+ [:button {:label "Call" :on-click #(actions/start-call! name)}])]
1100+ ;; The people panel's switch, in a wrapper of its own for the same
1101+ ;; reason: it is only offered in a channel, where there is a membership
1102+ ;; to show.
1103+ [:vbox {:key :people}
1104+ (when (and name (str/starts-with? name "#"))
1105+ [:button {:label (str "People " (actions/member-count name))
1106+ :kind (when @cells/show-users? :primary)
1107+ :on-click actions/toggle-users!}])]
1108+ ;; The overview's switch. Not in a wrapper conditioned on anything: it
1109+ ;; is about every room rather than this one, so it is offered in a DM
1110+ ;; and in a channel alike.
1111+ [:button {:key :overview-toggle
1112+ :label "Overview"
1113+ :kind (when @cells/overview? :primary)
1114+ :on-click actions/toggle-overview!}]]
1115+ [error-note]
1116+ ;; In a wrapper of its own, for the reconciler's sake: it comes and goes.
1117+ [:vbox {:key :policy}
1118+ (policy-note name buffer)]
1119+ [call-bar name]
1120+ ;; :reserve leaves room for everything below: the jump button's row, the
1121+ ;; separator and the compose bar. It does not vary with whether the button
1122+ ;; is showing, and neither does that row a reserve that changed would
1123+ ;; move the compose bar under the reader every time the button came and
1124+ ;; went.
1125+ ;; Named, so the list is the same list when the reader comes back to it.
1126+ ;; The lightbox is a screen rather than a layer, so looking at a picture
1127+ ;; unmounts the backlog behind it; without a name of its own the position
1128+ ;; would come back as a fresh one, and Back would answer a click on a
1129+ ;; message halfway up a week of history with the top of the buffer.
1130+ ;; The backlog and, when it is asked for, who is in the room beside it.
1131+ ;; The row is always there and the panel comes and goes inside a wrapper
1132+ ;; of its own: a child that appeared and vanished would renumber the row
1133+ ;; for the reconciler, and take the message list's scroll position with
1134+ ;; it every time the panel was toggled.
1135+ [:hbox {:spacing 8 :wrap false}
1136+ [:vbox {:key :messages :fill-height true
1137+ :width-request (if show-users? (messages-width) 0)}
1138+ [:scroll {:scroll-key (messages-scroll-key)
1139+ :orientation :vertical
1140+ :reserve (below-messages)
1141+ :stick-to-bottom true
1142+ :scroll-to-bottom @cells/jump-tick
1143+ :on-change #(reset! cells/at-present? (= "end" %))
1144+ ;; The terminal's half of the same question, which arrives
1145+ ;; as the offset the list moved to rather than as a place.
1146+ ;; `scrolled!` is what turns one into the other.
1147+ :on-scroll actions/scrolled!}
1148+ (if (seq (:messages buffer))
1149+ (message-rows (:messages buffer))
1150+ [:dim-label {:label "Nothing here yet."}])]]
1151+ ;; The wrapper takes no height of its own: the panel inside it is the
1152+ ;; column, and a fill-height wrapper around it would claim the strip the
1153+ ;; compose bar sits in whether or not the panel was showing.
1154+ [:vbox {:key :people-pane}
1155+ (when show-users? [users-panel name])]]
1156+ ;; Only while it is needed, and directly under the backlog: the way back
1157+ ;; to the present belongs next to the thing that puts you there, which is
1158+ ;; the conversation and not the overview.
1159+ ;;
1160+ ;; The row used to be held open by a spacer when the button was not in
1161+ ;; it, so that the compose bar did not move when it came and went. It sat
1162+ ;; under the backlog then, where the empty row read as the end of the
1163+ ;; conversation. Above the strip it reads as a hole between two lists,
1164+ ;; and there is no arguing a reader out of that so the row is only
1165+ ;; there when the button is, and what moves is a strip below it moving by
1166+ ;; one row on the rare frame the reader has scrolled away from the end.
1167+ [:vbox {:key :jump}
1168+ (when-not @cells/at-present?
1169+ ;; With the key beside it where there is a key: a terminal is where a
1170+ ;; reader is least likely to reach for the mouse, and most likely to
1171+ ;; have paged up here with the keyboard in the first place.
1172+ [:button {:label (if @terminal?
1173+ "↓ Jump to present (Ctrl-End)"
1174+ "↓ Jump to present")
1175+ :on-click actions/jump-to-present!}])]
1176+ ;; The strip, under the conversation and the way back to it: it is a
1177+ ;; second list of messages, so it belongs with the first rather than down
1178+ ;; among the compose bar's rows. In a wrapper of its own, since it comes
1179+ ;; and goes.
1180+ [:vbox {:key :overview-pane :fill-height (boolean (and @cells/overview?
1181+ (not @terminal?)))}
1182+ (when @cells/overview? [overview-pane])]
1183+ ;; No rule above the compose bar. The bar is already told apart from the
1184+ ;; conversation by what it is a framed box and a button on a strip of
1185+ ;; air and a line drawn over it as well was the app underlining the one
1186+ ;; edge nobody was going to miss.
1187+ ;; The three banners in one wrapper, at no spacing of its own. Each is a
1188+ ;; row that is usually empty, and an empty child still costs the column
1189+ ;; its gap three of them stacked under the overview was two dozen
1190+ ;; points of nothing between the strip and the compose bar. Inside, they
1191+ ;; carry their own air only when they have something in them.
1192+ [:vbox {:key :banners :spacing 0}
1193+ ;; What the draft is answering, directly above where it is being typed.
1194+ [:vbox {:key :replying}
1195+ (when-let [target @cells/replying-to]
1196+ [:hbox {:spacing 8}
1197+ [:dim-label {:label (str "" (:from target) ": " (summarise target 36))}]
1198+ [:button {:label "" :on-click actions/cancel-reply!}]])]
1199+ ;; And, in the same place, that the box holds a rewrite rather than
1200+ ;; something new: the text in it is a copy of a line already on screen,
1201+ ;; and without this Send would look like it was about to say it twice.
1202+ [:vbox {:key :editing}
1203+ (when @cells/editing
1204+ [:hbox {:spacing 8}
1205+ [:dim-label {:label "✏️ Editing your message"}]
1206+ [:button {:label "" :on-click actions/cancel-edit!}]])]
1207+ ;; The pasted picture, above the line it will go out with. Shown rather
1208+ ;; than written into the draft: what is being sent is a picture, and a URL
1209+ ;; dropped into the entry would be an unreadable line of text sitting in
1210+ ;; the middle of whatever the reader was in the middle of typing.
1211+ [:vbox {:key :attachment}
1212+ (when-let [att @cells/attachment]
1213+ [:hbox {:spacing 8}
1214+ ;; Small: it is a reminder of what is attached, not the picture
1215+ ;; itself, and the backlog above it is what the reader is here for.
1216+ [:image {:src (:path att) :max-height 64}]
1217+ [:dim-label {:label (if (= :uploading (:status att))
1218+ "Uploading…"
1219+ "Picture attached")}]
1220+ [:button {:label "" :on-click actions/clear-attachment!}]])]]
1221+ ;; The line and its buttons on the middle of the width rather than
1222+ ;; against its left edge: the entry asks for a fixed width, and on a
1223+ ;; window wider than that a left-aligned row leaves it stranded in the
1224+ ;; corner of an otherwise empty bar. On a phone the row is as wide as the
1225+ ;; window and centring costs nothing.
1226+ ;; Equal air above and below, so the row sits on the middle of the strip
1227+ ;; between the separator and the bottom edge rather than flat against it.
1228+ ;; Above it is one of the column's gaps, where it used to be four: the
1229+ ;; reply banner, the edit banner and the attachment share a wrapper now
1230+ ;; and cost one between them whether or not they have anything in them.
1231+ ;; This margin plus the window's own is what answers it underneath.
1232+ [:hbox {:spacing 8 :align :center :margin-bottom 12}
1233+ ;; narrow enough that Send keeps its place on a phone-width row
1234+ ;; A picture is pasted where everything else is typed: Ctrl+V. The field
1235+ ;; answers a paste of text with the text, and one of a picture reaches
1236+ ;; `:on-paste-empty` a keystroke the field had nothing to put in
1237+ ;; itself, which is exactly the one that means "the clipboard has
1238+ ;; something else on it".
1239+ ;; And the same picture chosen rather than pasted, for a phone which has
1240+ ;; no Ctrl+V, and no clipboard of pictures to read if it had.
1241+ ;; In a window, a picture on a Delight 2 tile (src/frq/icons, on the
1242+ ;; geometry delight-icons generates its tiles with): grey frame, dark
1243+ ;; square, cream glyph where the emoji was a colour photo that matched
1244+ ;; nothing else in the bar. Its path is read from the working directory,
1245+ ;; which is the tree under `just run` and `just cosmic` and the store
1246+ ;; copy under the flake's launcher. A terminal has no pixels to put
1247+ ;; it in, and an APK carries no src/ to read it from both keep the glyph.
1248+ (if (and (actions/desktop?) (not @terminal?))
1249+ [:image {:src "src/frq/icons/insert-image.png"
1250+ :size [36 36]
1251+ ;; for a backend that sizes a picture by its bounds instead
1252+ :max-width 36
1253+ :max-height 36
1254+ :on-click actions/open-image-picker!}]
1255+ [:button {:label "🖼" :on-click actions/open-image-picker!}])
1256+ ;; In a terminal the row is the width of the screen and a message is
1257+ ;; longer than 260 points of it: the field takes the surplus and wraps
1258+ ;; into three rows rather than scrolling one line sideways.
1259+ [:entry {:text @cells/draft
1260+ :width-request 260
1261+ ;; Always, not only in a terminal. A window is the case that
1262+ ;; needs it more: 260 points is most of a phone's row and a
1263+ ;; third of a pane's, and the field sat stranded in the middle
1264+ ;; of the bar with the rest of it empty. The number stays as
1265+ ;; the minimum it always was.
1266+ :hexpand true
1267+ :rows (if @terminal? 3 1)
1268+ ;; In a window the field starts as one line and takes another
1269+ ;; every time the message stops fitting, up to five past
1270+ ;; which it scrolls, keeping the caret in view. A paragraph
1271+ ;; typed into a one-line box was readable a dozen characters
1272+ ;; at a time, which is not how anybody writes one.
1273+ :max-rows 5
1274+ :on-rows #(reset! draft-rows %)
1275+ ;; The break is worth saying out loud where it is new: Enter
1276+ ;; sends, as it always has, and the box under it takes a
1277+ ;; paragraph now which nobody would think to try unasked.
1278+ :placeholder (if @terminal? "Message — Shift+Enter for a new line" "Message")
1279+ :on-change #(reset! cells/draft %)
1280+ :on-paste-empty actions/paste-image!
1281+ :on-activate actions/send-draft!}]
1282+ [:button {:label "Send" :kind :primary :on-click actions/send-draft!}]]]))
new file mode 100644
@@ -0,0 +1,1282 @@
1+(ns frq.screens.chat
2+ "The conversation, shared.
3+
4+ The largest of frq.app's screens and the last of its three big ones: the
5+ message list with its replies, edits and reactions, the emoji picker, the
6+ people panel, the overview, the call wall and the compose bar.
7+
8+ It moved the way the other two did the same hiccup, reading `frq.cells`
9+ and `frq.rooms`, calling `frq.actions`. What is different here is how much
10+ of it is not state at all: where a face or a picture is on disk is a fetch
11+ and a cache, and a call is a media plane, so those are actions rather than
12+ cells and a platform that has neither simply does not install them. The
13+ screen then draws what it already draws when a face has not arrived yet."
14+ (:require [clojure.string :as str]
15+ [frq.actions :as actions]
16+ [frq.cells :as cells]
17+ [frq.clock :as clock]
18+ [frq.emoji :as emoji]
19+ [frq.glyphs :as glyphs]
20+ [frq.metrics :refer [chrome-row chrome-scale terminal? terminal-face?]]
21+ [frq.rooms :as rooms]
22+ [frq.screens.chats :refer [preview-line tab-bar]]
23+ [frq.screens.connect :refer [error-note]]))
24+
25+(def ^:private text-emoji-size
26+ "An emoji in a message, at the size of the words around it. Bigger and the
27+ line it sits in grows to make room for it; smaller and it reads as a
28+ footnote on the sentence rather than a word of it."
29+ 14)
30+
31+(def ^:private pill-size
32+ "How big a reaction is under a message. Small: it is a footnote on what was
33+ said, not a second thing said."
34+ 14)
35+
36+(def ^:private picker-columns
37+ "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
38+ phone-width window, which is the width this app is laid out for."
39+ 9)
40+
41+(defonce ^:private draft-rows (atom 1))
42+
43+;; What the rows under the conversation need left to them: the jump button's
44+;; row, the separator, the compose bar and the air around it. The columns of
45+;; the row reserve it, and so nothing inside them has to a `:scroll` that
46+;; subtracted it again inside a column already stopped short would take the
47+;; same points off the list twice.
48+;;
49+;; Plus the reply banner's row when there is one, and the pasted picture when
50+;; there is one. Those rows do move the compose bar, and should: each appears
51+;; because the reader just asked for something unlike the jump button, which
52+;; appears on its own and must not shift what is under it.
53+;; What the overview strip costs the conversation above it, in points: a row
54+;; for its heading, a row a line, and the separator and gaps around them. The
55+;; backlog's `:reserve` takes this too, so the strip is paid for out of the
56+;; conversation rather than pushing the compose bar off the bottom which is
57+;; what every other row under the message list has had to say for itself.
58+;;
59+;; Counted from the lines there actually are, not from the ceiling: the strip
60+;; holds at most `overview-lines` of them there, and a room with two lines in
61+;; it should not have a screenful reserved against it.
62+;;
63+;; The window reserves nothing for the overview. Its pane scrolls and fills,
64+;; so it takes its half of the column by being in it there is no number to
65+;; subtract, and subtracting one would take the room twice.
66+
67+(def emoji-groups
68+ "Unicode's own grouping, which is what the picker's tabs are."
69+ emoji/groups)
70+(def picker-limit
71+ "How many glyphs the picker will lay out at once — twelve rows of the nine it
72+ fits across. It stands inside the message list, so what it shows pushes the
73+ conversation down; a group that has more says so, and the search box is how
74+ you reach the rest."
75+ 108)
76+(def overview-lines 8)
77+
78+(defn- overview-height []
79+ (* (chrome-scale)
80+ (+ 24 (* 20 (min overview-lines (count (actions/recent-everywhere)))) 16)))
81+
82+(defn- below-messages []
83+ ;; 115 is that, counted: the gap under the row of columns, the jump button's
84+ ;; 34pt row, the banners in their one wrapper with one gap, then the compose
85+ ;; row and the air under it its own and the window's. Short by any of it
86+ ;; and the column runs past the bottom edge, which does not show as a list
87+ ;; that is too long: it shows as a compose bar sitting flat on the bottom of
88+ ;; the window with its margin cut off.
89+ ;;
90+ ;; It was 140 when a rule was drawn above the compose bar and the three
91+ ;; banners each cost the column a gap of their own. The rule went, and they
92+ ;; went into one wrapper: twenty-five points of nothing, given back.
93+ (+ (* (chrome-scale)
94+ (+ 115
95+ (if @cells/replying-to 34 0)
96+ (if @cells/attachment 76 0)))
97+ ;; The two extra rows the terminal's compose field wraps into, in points:
98+ ;; a row down the page is two cells' worth of the scale.
99+ (if @terminal? (* 4 (chrome-scale)) 0)
100+ ;; And the lines a window's compose field has GROWN by. Unscaled and
101+ ;; outside the terminal's arm on purpose: the field only reports its
102+ ;; height where it can change the terminal's is the fixed block of
103+ ;; three rows reserved just above so `draft-rows` is one there and
104+ ;; this term is zero. Twenty points a line is a 16-point face's line
105+ ;; box rounded up: the reserve has to be at least what the field took,
106+ ;; because a point too few does not crop the list, it slides the
107+ ;; compose bar off the bottom of the window.
108+ (* 20 (dec @draft-rows))
109+ ;; And the overview strip, when it is up. Reserved here rather than
110+ ;; anywhere else because this is the number the backlog is laid out
111+ ;; against: without it the strip is drawn past the bottom of the window
112+ ;; and takes the compose bar with it.
113+ (if (and @terminal? @cells/overview?) (overview-height) 0)))
114+
115+(defn- call-controls
116+ "What the person in a call can do about it.
117+
118+ Mute and deafen are separate buttons because they are separate things: a
119+ deafened microphone still carries your voice, and one control for both would
120+ make the quieter of the two a surprise."
121+ []
122+ (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
123+ [:vbox {:key :call-controls :spacing 6}
124+ [:hbox {:spacing 8}
125+ [:label {:label (case media
126+ :waiting "Asking to join…"
127+ :dialling "Connecting…"
128+ :live "In call"
129+ :failed "Call failed"
130+ "In call")}]
131+ ;; A microphone that is not there is worth saying so: the call works,
132+ ;; and the person is listening rather than silent by choice.
133+ (when (and (= :live media) (not has-mic?))
134+ [:dim-label {:label "· listening only"}])]
135+ [:hbox {:spacing 8}
136+ [:button {:label (if muted? "Unmute" "Mute")
137+ :on-click #(av/set-muted! (not muted?))}]
138+ [:button {:label (if speaker-muted? "Undeafen" "Deafen")
139+ :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
140+ ;; Only offered when there is a camera to turn on. Nothing is more
141+ ;; annoying than a control that does nothing and does not say why.
142+ (when has-camera?
143+ [:button {:label (if camera? "Stop video" "Start video")
144+ :on-click #(av/set-camera! (not camera?))}])
145+ [:button {:label "Leave" :on-click #(actions/leave-call!)}]]
146+ (when-let [e @av/media-error]
147+ [:dim-label {:label (str "" e)}])]))
148+
149+(defn- call-tile
150+ "One participant's picture, at the width the row worked out for it.
151+
152+ `:feed` rather than `:src`: these pixels never touch the disk and never
153+ become a value here the media plane hands the decoder's own buffer to
154+ Vidya as a pointer, and the tag paints whatever arrived last under that name.
155+
156+ The height is three quarters of the width, which is the shape a camera hands
157+ over. Naming both keeps a portrait phone from making its tile tall enough to
158+ push the row off the screen the picture is fitted inside, never stretched."
159+ [width key]
160+ (let [mine? (= av/local-feed key)]
161+ [:vbox {:key key :spacing 2}
162+ ;; `:upscale` because a tile is a slot the layout sized, not a picture
163+ ;; sitting at whatever the camera happened to send. Without it a 480-wide
164+ ;; stream draws 480 wide in a 900-point slot and the wall looks broken
165+ ;; which is exactly what it did.
166+ [:image {:feed key
167+ :upscale true
168+ :max-width width
169+ :max-height (long (* width 0.75))}]
170+ [:dim-label {:label (if mine? "You" key)}]]))
171+
172+(defn- call-wall
173+ "Everyone with a camera on, sized to the window they are being watched in.
174+
175+ A call with no video is the normal case and should look like one a row of
176+ empty frames would suggest something had failed to load.
177+
178+ Both cells this reads are what subscribe it: `av/tiles` for who is on
179+ screen, and the window width so the tiles follow a window being dragged.
180+ Without either it would lay itself out once, on the first frame, and keep
181+ that shape for the rest of the call."
182+ []
183+ (let [[width rows] (av/tile-rows)]
184+ [:vbox {:key :call-wall :spacing 6}
185+ ;; A seq, not a vector: children splice, and a vector would be read as one
186+ ;; more hiccup element which an empty one is not.
187+ (for [[i keys] (map-indexed vector rows)]
188+ [:hbox {:key i :spacing 8}
189+ (for [key keys]
190+ [call-tile width key])])]))
191+
192+(defn call-bar
193+ "The call in this channel, whatever state it is in. Always a node.
194+
195+ Three cases, and the empty one matters as much as the others: a channel with
196+ no call must render *something* here, because the reconciler matches children
197+ by position and a banner that came and went would patch the message list into
198+ a button."
199+ [channel]
200+ [:vbox {:key :call-bar :spacing 6}
201+ (cond
202+ (actions/in-call? channel)
203+ [:card {}
204+ [:vbox {:spacing 6}
205+ [call-controls]
206+ [call-wall]]]
207+
208+ ;; A call is open in this room and we are not in it.
209+ (actions/call-in channel)
210+ (let [{:keys [session-id participants title]} (actions/call-in channel)]
211+ [:card {}
212+ [:hbox {:spacing 8}
213+ [:label {:label (str "📞 " (or title "Call in progress")
214+ (if (and participants (pos? participants))
215+ (str " · " participants)
216+ ""))}]
217+ [:button {:label "Join"
218+ :on-click #(actions/join-call! channel session-id)}]]])
219+
220+ ;; We are in a call, but in a different room. Say which, since the
221+ ;; controls are not on this screen to be found by looking.
222+ (actions/in-call?)
223+ [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
224+
225+ :else nil)])
226+
227+(defn- day-separator [day-key label]
228+ [:vbox {:key day-key :spacing 4 :margin 0}
229+ [:separator {}]
230+ [:dim-label {:label label}]])
231+
232+(def ^:private face-size 32)
233+
234+(defn- chip-gap
235+ "The air between two chips on a row.
236+
237+ Four points is half a column, which rounds to none: in a window that is the
238+ gap a pair of lozenges want, and in a terminal it puts two emoji hard against
239+ each other and they read as one wide glyph. A column, where a column is the
240+ smallest thing there is."
241+ []
242+ (if @terminal? 8 4))
243+
244+(defn- action-chips
245+ "Answering and reacting, on the sender's row above the message.
246+
247+ Both are things done *to* a message rather than parts of it, so they ride
248+ the sender's row against its right edge, at the size a reaction is. In the
249+ line with the text they took width off every line under them and wrapped a
250+ message that had the room to sit on one.
251+
252+ Chips rather than buttons: a button is sized for a label and these carry a
253+ glyph, which made two lozenges taller than the line they belonged to.
254+ `:reaction` with no count is the same pill the picker offers, so what you
255+ press to react and what appears once you have look like one family.
256+
257+ A row laid out from the right lays its first child furthest right, so
258+ reacting comes first in the source and this reads then then 🙂 on
259+ screen where there is a pencil at all. Only our own lines carry one: the
260+ server refuses an edit of somebody else's, and a chip that always fails is a
261+ chip that lies about what can be done.
262+
263+ The pencil is a chip beside the other two rather than a box holding one: a
264+ wrapper is laid out as a child in its own right, which put the pencil on the
265+ row at a remove from the pair it belongs with. It keys itself, so the line
266+ that has no pencil is a row of two chips and not a row with a hole in it."
267+ [channel m]
268+ (into [:hbox {:key :actions :align :end :spacing (chip-gap)}
269+ [:reaction {:key :react
270+ :emoji "🙂"
271+ :size pill-size
272+ :on-click #(actions/open-picker! channel m)}]
273+ [:reaction {:key :reply
274+ :emoji "↩️"
275+ :size pill-size
276+ :on-click #(actions/reply-to! m)}]]
277+ (when (actions/mine? m)
278+ [[:reaction {:key :edit
279+ :emoji "✏️"
280+ :size pill-size
281+ :on-click #(actions/start-edit! channel m)}]])))
282+
283+(def ^:private picker-size 20)
284+
285+;; A row of chips, and the most the grid may take before it scrolls.
286+
287+(defn emoji-picker
288+ "The whole set to choose from, under the message it is for.
289+
290+ It opens in the list itself, directly under the line it was opened on:
291+ choosing a reaction is something done *to* a message, and the message has to
292+ stay in front of the reader while it is chosen which is not something a
293+ panel at the bottom of the window, or a screen of its own, can promise.
294+
295+ It opens on the emoji people actually react with; the groups and the search
296+ box are for the rest."
297+ []
298+ (let [shown (actions/picker-emoji)
299+ over (max 0 (- (count shown) picker-limit))
300+ rows (partition-all picker-columns (take picker-limit shown))
301+ searching? (seq (str/trim @cells/emoji-search))]
302+ [:vbox {:key :picker :spacing 4}
303+ [:hbox {:spacing 6}
304+ [:entry {:text @cells/emoji-search
305+ :width-request 240
306+ :placeholder "Search emoji"
307+ :on-change #(reset! cells/emoji-search %)}]
308+ [:button {:label "" :on-click actions/close-picker!}]]
309+ ;; The groups are what the search box is not: a way in for someone who has
310+ ;; no word for what they want. Their first word is enough to tell them
311+ ;; apart, and is what keeps them to two rows. They give way to the
312+ ;; search's own answer while something is typed.
313+ [:vbox {:key :groups :spacing 4}
314+ (when-not searching?
315+ (for [[i row] (map-indexed vector (partition-all 5 (cons nil emoji-groups)))]
316+ [:hbox {:key i :spacing 4}
317+ (for [g row]
318+ [:button {:key (or g "popular")
319+ :label (if g (first (str/split g #" ")) "Popular")
320+ :kind (if (= g @cells/emoji-group) :primary :normal)
321+ :on-click #(reset! cells/emoji-group g)}])]))]
322+ ;; The grid itself, not a scroll around it: the picker sits inside the
323+ ;; message list, and a scroll within a scroll takes the wheel away from
324+ ;; the conversation it is standing in. What it shows is bounded instead,
325+ ;; and the search box is how you reach past that.
326+ [:vbox {:key :grid :spacing 2}
327+ (if (seq rows)
328+ (for [[i row] (map-indexed vector rows)]
329+ [:hbox {:key i :spacing 2}
330+ (for [[glyph] row]
331+ [:reaction {:key glyph
332+ :emoji glyph
333+ :size picker-size
334+ :count 0
335+ :on-click #(actions/react-from-picker! glyph)}])])
336+ [:dim-label {:label "No emoji by that name."}])]
337+ ;; What was left out, said rather than silently dropped.
338+ [:vbox {:key :more}
339+ (when (pos? over)
340+ [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
341+
342+(defn- indented
343+ "One of a message's columns, held in from the name above it — in a terminal.
344+
345+ The nick is the heading and the words hang under it, which is the shape a
346+ terminal has read a conversation in for forty years; in a window the column
347+ of faces already marks that edge, so there is nothing to hold in.
348+
349+ A cell of its own rather than a margin, because the backend insets a node by
350+ `:margin` on all four sides or not at all: there is no left margin in a cell
351+ grid, and a uniform one would spend a blank row above every message to buy
352+ two columns beside it.
353+
354+ The spacer asks for its width and no height. A `:size` is a length down the
355+ page, and a spacer two cells tall beside an empty column a message with no
356+ picture and no reactions is two blank rows in the middle of the backlog,
357+ which is a message and a half at this size.
358+
359+ Nothing to do where a face is drawn: the column that holds the portrait is
360+ already to the left of the whole message, and a second indent inside it would
361+ hold the words in from a name that is itself held in."
362+ [k node]
363+ (if (and @terminal? (not (terminal-face?)))
364+ [:hbox {:key k :spacing 0}
365+ [:spacer {:key :indent :size 0 :width-request 16}]
366+ node]
367+ node))
368+
369+(defn- preview-height
370+ "How tall a picture in the conversation may be.
371+
372+ Against the window rather than a fixed 260: on a phone-sized window a
373+ preview that tall is the whole screen, and on a laptop one it is a stamp of
374+ something worth looking at.
375+
376+ Four fifths of the window, because the pictures that arrive here are mostly
377+ phone screenshots 1080x2400, whose height is what binds, and any smaller
378+ share draws them as a strip too narrow to read a word of. A picture is what
379+ the message is; the line above it is enough context to place it, and the
380+ scroll is how the rest is reached.
381+
382+ Clamped at both ends, and falling back to the old fixed height until the
383+ first poll has landed: a preview is a preview either way, and the lightbox
384+ is what full size is for.
385+
386+ A terminal gets less of its screen, and a hard ceiling on top of that. Four
387+ fifths of a window is a picture you look at; four fifths of a terminal is a
388+ conversation with one message in it, and the rows a picture takes are rows
389+ that cannot hold a line. Twelve of them is enough to see what a picture is
390+ of, and to decide whether to open it."
391+ []
392+ (let [h @cells/window-height]
393+ (cond
394+ (not (pos? h)) 260
395+ @terminal? (max 80 (min 192 (long (* 0.4 h))))
396+ :else (min 900 (max 240 (long (* 0.8 h)))))))
397+
398+(defn- preview-width
399+ "How wide a picture in the conversation may be.
400+
401+ The height alone leaves a wide, short picture to take the column and push
402+ the words around it; this holds it inside the conversation the same way.
403+ Fitted, never stretched `:image` scales to whichever bound it meets first."
404+ []
405+ (let [w @cells/window-width]
406+ (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))
407+
408+(defn- reactor-card
409+ "Who is on a reaction, beside the pointer resting on it.
410+
411+ The pill carries a number, and a number is the one thing about a reaction
412+ nobody wants: who it was is what a room is read for. Names, one to a line,
413+ with your own said as \"you\" — you are the one name in the list you cannot
414+ learn anything from."
415+ [emoji nicks]
416+ [:vbox {:spacing 4}
417+ [:hbox {:spacing 6}
418+ [:reaction {:key :glyph :emoji emoji :size pill-size}]
419+ [:dim-label {:label (str (count nicks)
420+ (if (= 1 (count nicks)) " reaction" " reactions"))}]]
421+ [:vbox {:key :who :spacing 2}
422+ (for [nick (sort nicks)]
423+ [:label {:key nick
424+ :label (if (= nick @cells/form-nick) "you" nick)}])]])
425+
426+(defn reactor-dialog
427+ "Who is on the reaction the pointer is resting on.
428+
429+ The same dialog a face gets, for the same reason: two hover cards in two
430+ shapes was two things to look at and two things to keep working. Non-modal
431+ and no buttons, because the pointer is what is holding it open a modal one
432+ would go deaf to the pill leaving and never close, and a button in it could
433+ never be reached.
434+
435+ The message is looked up rather than carried: a pill says which message and
436+ which emoji it is, and the room it is in is the one being read a pill in
437+ any other room is not under a pointer."
438+ []
439+ (let [{:keys [id emoji]} @cells/reaction-hover
440+ nicks (get (:reactions (actions/message-by-id @cells/current id)) emoji)]
441+ (when (seq nicks)
442+ [:dialog {:label "Reactions" :max-width 320 :modal false}
443+ [reactor-card emoji nicks]])))
444+
445+(defn- reaction-row
446+ "What people have put on a message, under it.
447+
448+ A pill carries its count and toggles: clicking one you are already on takes
449+ yours off, which is the same gesture that put it there. `:reaction` rather
450+ than a button with the emoji as its label the chip draws the glyph from the
451+ Twemoji pack, in colour, where a label gets whatever the text font has."
452+ [channel m]
453+ (let [reactions (:reactions m)]
454+ ;; `into` and not a lazy `for` inside the vector. The pills read
455+ ;; `my-reaction?` a ratom and a ratom read while a lazy seq is being
456+ ;; realised somewhere other than the render is a read the component never
457+ ;; records, so the row went on showing what it showed before. The pictures
458+ ;; in `message-body` are the other place a ratom is read inside a `for`,
459+ ;; and are made eager for the same reason.
460+ (into
461+ [:hbox {:key :pills :spacing (chip-gap)}]
462+ (for [emoji (sort (keys reactions))]
463+ [:reaction (cond-> {:key emoji
464+ :emoji emoji
465+ :size pill-size
466+ :count (count (get reactions emoji))
467+ :mine (actions/my-reaction? m emoji)
468+ :on-click #(actions/toggle-reaction! channel m emoji)}
469+ ;; Where there is a pointer to ask with, resting on a pill
470+ ;; says who put it there. On a phone the pill is a button
471+ ;; and nothing more: there is no hover to answer.
472+ (actions/desktop?)
473+ ;; Nothing is hung under the pill any more: who is on a
474+ ;; reaction is shown the way everything else the pointer
475+ ;; asks for is shown now the dialog at the root of the
476+ ;; tree, which `reactor-dialog` builds out of
477+ ;; `reaction-hover`. The pill's only job is to say where
478+ ;; the pointer is.
479+ (assoc :on-hover #(actions/hover-reaction! (:id m) emoji)
480+ :on-unhover #(actions/unhover-reaction! (:id m) emoji)))]))))
481+
482+(defn- goto-message!
483+ "Show message `id`, in `channel`, and say which one it was.
484+
485+ Three things in a fixed order: be in the room, aim the scroll at the line,
486+ and mark it once it is there. `jump-to` comes off again as soon as the frame
487+ that scrolled has been painted a scroll target that stays set pins the
488+ view and takes scrolling away from the reader while the highlight outlives
489+ it, because arriving at a screenful of messages says nothing about which one
490+ was asked for.
491+
492+ `settle` is how long that first frame is given, and `linger` how long the
493+ mark stays. Within a room the scrolling frame is the next one and the line
494+ is somewhere the reader was already looking. Crossing a room is a slower
495+ and a stranger arrival the screen has to be built before there is
496+ anything to scroll, and what it opens on is a conversation the reader was
497+ not in a moment ago so a caller that crosses one asks for more of both."
498+ [channel id settle linger]
499+ (when (and channel (not= channel @cells/current))
500+ (actions/open-channel! channel))
501+ (reset! cells/jump-to id)
502+ (reset! cells/highlight id)
503+ (platform/after! settle (fn [] (reset! cells/jump-to nil)))
504+ ;; The highlight only clears itself: a later jump elsewhere owns the
505+ ;; highlight from then on.
506+ (platform/after! linger (fn [] (when (= id @cells/highlight)
507+ (reset! cells/highlight nil)))))
508+
509+(defn- summarise
510+ "A message in one line's worth of words."
511+ [m limit]
512+ (let [text (str/replace (or (:text m) "") #"\s+" " ")]
513+ (if (> (count text) limit)
514+ (str (subs text 0 (dec limit)) "")
515+ text)))
516+
517+(defn- reply-chip
518+ "What a message is replying to, above it, and a way back to it.
519+
520+ The chip carries the quote rather than only a marker: a reply is unreadable
521+ without knowing what it answers, and the message it answers is usually off
522+ the top of the screen. Clicking takes you there."
523+ [channel id]
524+ [:vbox {:key :reply}
525+ (if-let [target (actions/message-by-id channel id)]
526+ ;; A link, not a button: the chip is a pointer back to a line, not an
527+ ;; action, and a filled pill above every answer was the loudest thing in
528+ ;; the column.
529+ [:link {:label (str "" (:from target) ": " (summarise target 48))
530+ ;; The line being answered is in the room already open, so the
531+ ;; next frame is the one that scrolls.
532+ :on-click #(goto-message! channel id 120 2000)}]
533+ ;; The message it answers is older than this buffer goes.
534+ [:dim-label {:label "↩ replying to an earlier message"}])])
535+
536+(defn- word-node
537+ "Body text, or dim text for the lines the client writes itself."
538+ [k value system?]
539+ (if system?
540+ [:dim-label {:key k :label value}]
541+ [:label {:key k :label value}]))
542+
543+(defn- run-node
544+ "One run as a widget. A link is accent-coloured and opens on click; the rest
545+ is body text.
546+
547+ Except for the emoji in it. A label is set in the text font, and that font
548+ Ubuntu with a DejaVu subset behind it has no emoji in it at all, so every
549+ one anybody typed was drawn as tofu. The Twemoji pack has the picture, so
550+ the emoji come out of the text and are drawn from the pack, and the words
551+ either side stay text.
552+
553+ `:emoji` and not `:reaction`, though both draw the same picture. A reaction
554+ is a tally: it wears a pill, it answers the pointer, and it names the people
555+ in it on hover. An emoji in a sentence is a character so it is drawn bare,
556+ and a message that ends in one no longer reads as a message someone has
557+ reacted to.
558+
559+ The pieces go in a wrapping row rather than the column the runs themselves
560+ are stacked in: a row is what puts an emoji *in* a sentence instead of
561+ breaking the sentence around it, and `:hbox` wraps its children by default,
562+ so a long line still folds at the column's edge. A run with no emoji in it
563+ is still one plain label the row is only paid for where it is needed."
564+ [j [kind value] system?]
565+ (if (= :link kind)
566+ [:link {:key j :label value :on-click #(platform/open-url! value)}]
567+ (let [pieces (glyphs/runs (str/trim value))]
568+ (if (glyphs/emoji? pieces)
569+ ;; Runs alternate text and picture, so this gap only ever falls either
570+ ;; side of an emoji never between two words, whose spacing is the
571+ ;; spaces the message was typed with. A bare glyph carries no margin of
572+ ;; its own the way the pill did, and a picture set flush against a word
573+ ;; reads as part of it.
574+ [:hbox {:key j :spacing 2}
575+ (map-indexed (fn [k [pkind pvalue]]
576+ (if (= :emoji pkind)
577+ [:emoji {:key k :emoji pvalue :size text-emoji-size}]
578+ (word-node k pvalue system?)))
579+ pieces)]
580+ (word-node j (str/trim value) system?)))))
581+
582+(defn- trim-trailing-punctuation
583+ "A URL at the end of a sentence would otherwise keep the sentence's
584+ punctuation. A closing bracket only counts as trailing when the URL does not
585+ open one itself, which is what keeps a wikipedia-style path intact."
586+ [url]
587+ (loop [u url]
588+ (let [c (last u)]
589+ (cond
590+ (nil? c) u
591+ (contains? #{\. \, \; \: \! \?} c) (recur (subs u 0 (dec (count u))))
592+ (and (= \) c) (not (str/includes? u "("))) (recur (subs u 0 (dec (count u))))
593+ :else u))))
594+
595+(def ^:private url-pattern #"https?://[^\s<>\"]+")
596+
597+(defn text-runs
598+ "Message text as alternating [:text s] and [:link url] runs.
599+
600+ Runs because a link has to be its own widget to be clickable, and stacked
601+ rather than laid out in a row because a wrapping label inside a horizontal
602+ row lays out against the row's width, not the column's which is what drags
603+ long URLs off the left edge."
604+ [text]
605+ (let [text (or text "")]
606+ (loop [pos 0 acc []]
607+ (if-let [raw (re-find url-pattern (subs text pos))]
608+ (let [url (trim-trailing-punctuation raw)
609+ at (+ pos (str/index-of (subs text pos) raw))
610+ before (subs text pos at)
611+ acc (cond-> acc (seq before) (conj [:text before]))]
612+ (recur (+ at (count url)) (conj acc [:link url])))
613+ (let [tail (subs text pos)]
614+ (cond-> acc (seq tail) (conj [:text tail])))))))
615+
616+(defn- message-body
617+ "A message without its face: the sender's line, the words, and what hangs
618+ under them.
619+
620+ Its own component because a terminal that draws pictures puts the face
621+ beside all of this rather than on the sender's line — the shape the window
622+ has always had, and the one that spends no row on the half of a portrait
623+ that is taller than a line of text."
624+ [m highlit?]
625+ ;; A key each: the surface a jump leaves behind is a different node from the
626+ ;; plain column, not the same node wearing another tag.
627+ [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
628+ :spacing 2 :margin 0}
629+ ;; Who, when, and what can be done about it, on one line above the
630+ ;; words: the name and the time say where the message came from, and the
631+ ;; chips at the far end are the two answers to it.
632+ [:vbox {:key :who}
633+ ;; An event has no sender to head it, but it still happened at a time,
634+ ;; and a column of joins and quits with no clock on it says nothing
635+ ;; about how long the room was quiet. The time alone is that heading.
636+ (when (and (:system? m) (:at m))
637+ [:dim-label {:label (clock/clock-time (:at m))}])
638+ (when-not (:system? m)
639+ ;; Everything that is about the person rather than the line: their
640+ ;; picture, their name, and when they started saying this.
641+ [:hbox {:spacing 6}
642+ ;; Always an avatar, picture or not: the initial stands in until the
643+ ;; fetch lands, and for the guests who have no profile at all, which
644+ ;; is what keeps the column of faces straight down the left.
645+ ;; `avatar-path` is what wakes this row when that fetch lands.
646+ ;; The face is also the way to the person behind it: Vidya's plain
647+ ;; label does not answer the pointer, so the tap sleek puts on the
648+ ;; nick lives here, on the one thing in the row that does.
649+ ;; And, where there is a pointer to ask with, resting on the face
650+ ;; answers before the click does: the card under it is the profile
651+ ;; screen's first few lines, painted beside the pointer.
652+ ;; And no face on this line in a terminal: the initial that stands in
653+ ;; for a portrait in a window is a letter printed twice in a cell grid,
654+ ;; which only pushed every nick in past the words it heads. Where the
655+ ;; terminal can draw a picture there is a face after all, hung beside
656+ ;; the whole message rather than off its heading `message-row` has it.
657+ (when-not @terminal?
658+ (let [src @(actions/avatar-path (:actor m))]
659+ ;; One profile, two gestures, and no card hung under the face: the
660+ ;; pointer opens the dialog and the press pins it. What makes that
661+ ;; work is the dialog being non-modal while the pointer is what is
662+ ;; holding it open see `profile-dialog`.
663+ [:avatar (cond-> {:label (:from m)
664+ :src (or src "")
665+ :size face-size
666+ :on-click #(profile/open! (:from m) (:actor m))}
667+ (actions/desktop?)
668+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
669+ :on-unhover #(profile/unhover! (:from m))))]))
670+ ;; The name carries the row, so it is set at body size in the plain
671+ ;; text colour: dimmed caption made the one thing you scan a column
672+ ;; for the faintest thing on it.
673+ [:label {:label (:from m)}]
674+ (when-let [at (:at m)]
675+ [:dim-label {:label (clock/clock-time at)}])
676+ ;; Beside the clock, because it is the same kind of fact about the
677+ ;; line: what is on screen is not what was first said.
678+ (when (:edited? m)
679+ [:dim-label {:label "(edited)"}])
680+ ;; And the two things you can do to the message, at the far end of
681+ ;; its heading: a nested row laid out from the right takes what is
682+ ;; left of the width and puts the chips against the edge of it, so
683+ ;; the words below keep the whole column.
684+ ;;
685+ ;; A key of its own for each branch, rather than one key whose node
686+ ;; changes tag under it: a line this client sent has no msgid until
687+ ;; the server echoes it back, and swapping a `:spacer` for a chip in
688+ ;; place is what left the row rebuilt in the wrong order.
689+ (if (:id m)
690+ [action-chips {:key :actions} @cells/current m]
691+ [:spacer {:key :actions-gap :size 0}])])]
692+ ;; A little air under the sender's row: the name and face are a heading
693+ ;; for what follows, and a heading that touches its text is not one.
694+ ;; The text keeps a column of its own: a wrapping label directly in a
695+ ;; row wraps against the row.
696+ (indented
697+ :text
698+ [:vbox {:key :text :spacing 2 :margin-top 4}
699+ ;; What this answers, in the column the answer itself is in: the chip
700+ ;; is a quote of a line, so it wraps to the width a line has. Outside
701+ ;; this column it wrapped to the whole row instead wider than any
702+ ;; message, and out to the window's edge on a narrow screen.
703+ [:vbox {:key :reply-chip}
704+ (when-let [reply-to (:reply-to m)]
705+ [reply-chip @cells/current reply-to])]
706+ (map-indexed (fn [j run] (run-node j run (:system? m)))
707+ (text-runs (:text m)))])
708+ ;; And the picker, when this is the message it was opened on: under the
709+ ;; line it is about, where the reader is already looking.
710+ ;;
711+ ;; The id has to exist, not merely match: a line this client sent itself
712+ ;; has no msgid, and neither does a closed picker so `nil = nil` was
713+ ;; every one of those messages opening a picker of its own at startup.
714+ [:vbox {:key :picker :margin-bottom 4}
715+ (when (and (:id m) (cells/derived-value [:reacting (:id m)] #(= (:id m) (:id @cells/reacting))))
716+ [emoji-picker])]
717+ ;; Pictures under the line that linked them. The link stays: it is what a
718+ ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
719+ (indented
720+ :images
721+ [:vbox {:key :images :spacing 4}
722+ (when (seq (:images m))
723+ ;; Eager, so each read happens during the render and is recorded see
724+ ;; `reaction-row`. Each picture is read through `image-path`, which
725+ ;; wakes this row for its own pictures landing and not for everyone's.
726+ (doall
727+ (for [url (:images m)]
728+ (when-let [path @(actions/image-path url)]
729+ [:image {:key url
730+ :src path
731+ :max-height (preview-height)
732+ :max-width (preview-width)
733+ :on-click #(reset! cells/lightbox {:path path :url url})}]))))])
734+ ;; Reactions go last, under whatever the message turned out to be: a
735+ ;; line with a picture on it is the picture, and pills between the words
736+ ;; and the image they introduce read as reactions to the words alone.
737+ ;; Nothing moves for a message with no picture the box above it is
738+ ;; empty, and the pills still sit under the last line of text.
739+ (indented
740+ :reactions-row
741+ [:vbox {:key :reactions-row :margin-top 6}
742+ (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
743+ [reaction-row @cells/current m])])])
744+
745+(defn message-row
746+ "One message: who said it, when, what you can do to it, and the words.
747+
748+ Every line names its sender, rather than the first of a run only. A run
749+ collapsed to one heading reads well until you answer the fourth line of it,
750+ and then the line quoted back has no name on it; and the actions live on the
751+ sender's row, which a headerless line has nowhere to put.
752+
753+ Sender above the text, not beside it: a wrapping label in a horizontal row
754+ lays out against the row's width rather than the column's, so one long URL
755+ drags every line that follows it off the left edge."
756+ [i m]
757+ (let [;; What a jump landed on wears a surface of its own for a moment, so
758+ ;; the answer to "which one was I sent to" is on the screen rather
759+ ;; than in the reader's count of rows.
760+ ;; Through `derived`, as is everything below that asks about shared
761+ ;; state: a row that read the highlight itself re-rendered for every
762+ ;; jump anywhere in the backlog.
763+ highlit? (boolean (and (:id m)
764+ (cells/derived-value [:highlit (:id m)]
765+ #(= (:id m) @cells/highlight))))]
766+ [:vbox {:key i :spacing 2 :margin 0
767+ ;; Clear of the right edge: the actions ride that edge, and the
768+ ;; list's scrollbar rides it too — without this the ↩ is what the
769+ ;; scrollbar is drawn over.
770+ :margin-right 10
771+ ;; One gap between messages: every line carries its own heading
772+ ;; now, so there are no runs to hold together and nothing for a
773+ ;; closed-up line to belong to.
774+ :margin-top 10
775+ ;; The jump target is what a "go to message" click scrolls to.
776+ :scroll-here (boolean (and (:id m)
777+ (cells/derived-value [:jump (:id m)]
778+ #(= (:id m) @cells/jump-to))))}
779+ ;; The gap above, where the margin cannot be one. `:margin-top 10` is
780+ ;; most of a row in a window and nothing at all in a terminal ten points
781+ ;; against a row of sixteen, rounded down, because a gap that thin is what
782+ ;; it looks like at this size. So the terminal buys the row outright:
783+ ;; without it every message in the backlog touches the one above it, and a
784+ ;; conversation reads as one paragraph with names scattered through it.
785+ (when @terminal? [:spacer {:key :gap :size 16}])
786+ ;; The face beside the message, where the terminal can draw one: a
787+ ;; portrait is two rows tall and a heading is one, so hanging it off the
788+ ;; sender's line would leave its lower half beside a blank row. Here the
789+ ;; words take that row, and the column the face makes is the indent every
790+ ;; other row of the message is held in by `indented` steps aside for it.
791+ (if (terminal-face?)
792+ [:hbox {:key :faced :spacing 8}
793+ [:vbox {:key :face :width-request face-size}
794+ (when-let [path @(actions/avatar-path (:actor m))]
795+ [:image {:key :picture
796+ :src path
797+ :max-width face-size
798+ :max-height face-size}])]
799+ [message-body m highlit?]]
800+ [message-body m highlit?])]))
801+
802+(defn- message-rows
803+ "The messages, with a heading wherever the day changes.
804+
805+ A backlog can reach back weeks, and `11:04 AM` says nothing about which day
806+ it was. The heading is what makes the time above it mean something."
807+ [messages]
808+ (mapcat (fn [i m]
809+ (let [prev (when (pos? i) (nth messages (dec i)))
810+ day (some-> (:at m) clock/day)
811+ new-day? (and day (not= day (some-> (:at prev) clock/day)))]
812+ (cond-> []
813+ new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m))))
814+ ;; A new day breaks the run: the first line under a heading
815+ ;; names its sender and its time, whoever spoke last night.
816+ ;; Keyed by the line itself, not by where it sits: the list is
817+ ;; reconciled by position when its children are not all keyed,
818+ ;; and a line that arrives anywhere but the end a backlog
819+ ;; replay, an echo taking the place of what was sent shifts
820+ ;; every row after it onto the widgets of its neighbour.
821+ true (conj ^{:key (or (:id m) (str "row-" i))}
822+ [message-row i m]))))
823+ (range (count messages))
824+ messages))
825+
826+(defn- messages-scroll-key
827+ "What the backlog's scroll position is remembered under.
828+
829+ One name in a window: `:scroll-to-bottom` is how the jump button is answered
830+ there, and the position under that name is the one the reader left behind.
831+
832+ The terminal backend has no `:scroll-to-bottom` a viewport there is moved
833+ by the wheel and the page keys and by nothing else but it does open a
834+ sticky viewport it has never seen at the bottom, which is the same thing
835+ said differently. So a jump renames the viewport: the tick that asks the
836+ window to scroll gives the terminal a name with no position saved under it,
837+ and the newest line is what it opens on.
838+
839+ Only on a jump, so scrolling and every message that arrives between two
840+ jumps still find the position where they left it."
841+ []
842+ (if @terminal?
843+ (str "chat-messages-" @cells/jump-tick)
844+ "chat-messages"))
845+
846+(def sidebar-width 320)
847+
848+;; How tall a row of chrome is a button, the compose bar, a line of tabs.
849+;;
850+;; The two reserves below are counted in points against this: a window's row is
851+;; 34 of them, and every gap around it was chosen at that size. A terminal's
852+;; row is one cell, and the same count then reserves two or three times the
853+;; room the strip under the list actually needs which costs a message a row,
854+;; and a conversation is measured in how many of those fit.
855+;;
856+;; So the counts stay as they are, written where the reasoning is, and a
857+;; backend whose rows are a different height says so here. Nothing else in the
858+;; tree needs it: every other number is a length, and a length scales on the
859+;; way across.
860+
861+(def ^:private users-width 150)
862+
863+;; How many lines the compose field is currently drawn as. The window backend
864+;; says so when it changes a field with room to grow wraps a long message
865+;; onto a second and third line instead of sliding it sideways and the
866+;; reserve below has to hear about it, since nothing in this tree is laid out
867+;; by anything but these counts.
868+
869+(defn- messages-width
870+ "How wide the message list may be with the people panel beside it.
871+
872+ Measured from the window rather than from what egui has left: the row is
873+ painted left to right, and by the time the panel is placed the list has
874+ already taken everything. On a wide window the chats list is holding the
875+ first `sidebar-width` of the window; the rest is the margins and the gap
876+ between the two columns."
877+ []
878+ (let [pane (- @cells/window-width (if (and (actions/wide?) (not @cells/hide-chat-list?))
879+ sidebar-width
880+ 0))]
881+ (max 240 (- pane users-width 8 28))))
882+
883+(defn- overview-row
884+ "One line from somewhere else: which room, who, and what — on one row.
885+
886+ The room leads, because that is the whole question the strip answers. The
887+ name is a button for the same reason it is one in the people panel: the
888+ room is somewhere to go, and a line you want more of is a line you want the
889+ conversation behind it."
890+ [i m]
891+ [:hbox {:key i :spacing 8 :wrap false}
892+ ;; The room is the button, and pressing it goes to this line rather than to
893+ ;; the room's end: the line is what was read here and what the press was
894+ ;; about, and a jump that landed on the newest message instead would answer
895+ ;; a question nobody asked from a strip that was showing the answer.
896+ ;;
897+ ;; Unless the line has no id to aim at one this client has sent and the
898+ ;; server has not echoed back yet and then the room is all there is to
899+ ;; offer.
900+ ;;
901+ ;; 600ms to land and five seconds marked: the room is a room away, so the
902+ ;; screen it scrolls in has to be built before there is anything to scroll,
903+ ;; and what the reader arrives at is a conversation they were not in a
904+ ;; moment ago. The mark is the whole answer to "which of these was the line
905+ ;; I pressed", and it has to still be there when they have finished
906+ ;; recognising where they are.
907+ [:button {:label (:channel m)
908+ :on-click (if (:id m)
909+ #(do (actions/leaving-for-overview!)
910+ (goto-message! (:channel m) (:id m) 600 5000))
911+ #(do (actions/leaving-for-overview!)
912+ (actions/open-channel! (:channel m))))}]
913+ ;; And the line itself, as something to read rather than to press. It was a
914+ ;; link for a moment, which made the strip two things at once: a line the
915+ ;; server has echoed back has an id to aim at and a line this client has
916+ ;; just sent does not, so half the rows came out accent-coloured and half
917+ ;; plain, down the same list. The chip beside it is the way to the message
918+ ;; and is on every row either way.
919+ [:vbox {:key :said}
920+ [:dim-label {:label (str (when-let [at (:at m)] (str (clock/clock-time at) " "))
921+ (:from m) ": "
922+ (preview-line (:text m)))}]]])
923+
924+(defn- overview-pane
925+ "Every other room's recent lines in one list, newest at the top.
926+
927+ A strip under the conversation rather than a screen of its own: the question
928+ it answers is anything happening anywhere else is one you ask while
929+ reading something, and an answer you have to leave the room for is one you
930+ stop asking for.
931+
932+ In a window it scrolls, and the column it is in fills the height: two
933+ children of a column that both fill it are two halves of it, which is the
934+ horizontal split this is. The backlog above keeps its own scroll and its own
935+ place in it, so reading down here does not move the conversation.
936+
937+ A terminal gets a handful of lines and no scroll. Its rows are cells rather
938+ than points, and a second scrolling pane in a screen that is already a
939+ conversation, a compose bar and a tab bar leaves neither half enough rows to
940+ be worth reading so there it stays the strip it was, and `below-messages`
941+ reserves it by the row."
942+ []
943+ (let [lines (actions/recent-everywhere)
944+ ;; The way back, and only while there is somewhere to go: the strip
945+ ;; is the one thing here that moves you without your having chosen a
946+ ;; room, so it is the one thing that owes you an undo. Gone once you
947+ ;; are back in that room, where it would offer to take you where you
948+ ;; already are.
949+ back (let [room @cells/overview-return]
950+ (when (and room (not= room @cells/current))
951+ [:button {:label (str "← Back to " room)
952+ :on-click actions/overview-back!}]))
953+ ;; The row keeps its height whether or not the way back is in it. A
954+ ;; button is 34 points and a caption is not, so a heading that grew
955+ ;; one when you jumped and lost it when you came back moved every
956+ ;; line under it the strip rendering two different ways depending
957+ ;; on where you had been.
958+ heading (fn []
959+ [:hbox {:key :heading :spacing 8 :wrap false :align :center}
960+ [:dim-label {:label "Everywhere else"}]
961+ [:vbox {:key :back}
962+ (or back [:spacer {:size @chrome-row}])]])
963+ ;; The same heading on the same rule the conversation puts between two
964+ ;; days, and needed more here than there: these lines come from rooms
965+ ;; that were last spoken in at their own times, so a strip of them can
966+ ;; cross a day twice in ten rows where a single room's backlog crosses
967+ ;; one once a day. A clock alone would then be the only thing saying
968+ ;; which, and `9:40` says nothing about how long ago it was.
969+ rows (fn [ms]
970+ (if (seq ms)
971+ (mapcat
972+ (fn [i m]
973+ (let [prev (when (pos? i) (nth ms (dec i)))
974+ day (some-> (:at m) clock/day)
975+ new-day? (and day (not= day (some-> (:at prev) clock/day)))]
976+ (cond-> []
977+ new-day?
978+ (conj (day-separator (str "overview-day-" i)
979+ (clock/day-label (:at m))))
980+ true
981+ (conj ^{:key (str (:channel m) "-" (or (:id m) i))}
982+ [overview-row i m]))))
983+ (range (count ms))
984+ ms)
985+ [[:dim-label {:label "Nothing has been said in any other room yet."}]]))]
986+ (if @terminal?
987+ (into [:vbox {:key :overview :spacing 4 :margin-top 4}
988+ [:separator {}]
989+ [heading]]
990+ (rows (take overview-lines lines)))
991+ [:vbox {:key :overview :spacing 4 :margin-top 4 :fill-height true}
992+ [:separator {}]
993+ [heading]
994+ ;; Named, so coming back to a conversation does not throw away where
995+ ;; the reader had got to in here.
996+ (into [:scroll {:scroll-key "overview-list" :orientation :vertical
997+ :spacing 4}]
998+ (rows lines))])))
999+
1000+(defn- policy-note
1001+ "What this channel wants agreed to, and the one button that agrees to it.
1002+
1003+ Above the backlog rather than in it: the refusal is already a line in the
1004+ buffer, and a button scrolled away with last week's messages is a button
1005+ nobody finds. The rules come from the server; when it has none to give, its
1006+ own words about that are what shows."
1007+ [name buffer]
1008+ (when (:policy-required? buffer)
1009+ [:vbox {:spacing 4}
1010+ [:dim-label {:label (str name " asks you to accept its policy before joining.")}]
1011+ (for [[i line] (map-indexed vector (:policy-text buffer))]
1012+ ^{:key (str "policy-" i)} [:dim-label {:label line}])
1013+ [:button {:label "Accept policy" :kind :primary
1014+ :on-click #(actions/accept-policy! name)}]]))
1015+
1016+(defn- member-row [{:keys [nick prefix]}]
1017+ ;; The mode where there is one, dim: it says how someone is listed, not who
1018+ ;; they are, and the name is what the eye is scanning for. A space where
1019+ ;; there is none, rather than nothing at all: the label is always in the row,
1020+ ;; so every name starts at the same place and a column wrapped around a
1021+ ;; label that comes and goes is a column, which sits its text against the top
1022+ ;; of the row rather than on the line the name is on.
1023+ ;;
1024+ ;; The name is a button because a person is somewhere to go: pressing one
1025+ ;; opens a conversation with them.
1026+ [:hbox {:key nick :spacing 6 :wrap false}
1027+ ;; In a slot of its own width, so an "@" and a space take the same room and
1028+ ;; the names line up whatever mode is in front of them.
1029+ [:vbox {:key :mode :width-request 14}
1030+ [:dim-label {:label (if (seq prefix) prefix " ")}]]
1031+ [:button {:label nick :on-click #(actions/open-dm! nick)}]])
1032+
1033+(defn users-panel
1034+ "Who is in the channel, beside the conversation.
1035+
1036+ The list is the server's — NAMES on the way in, kept up by the joins and
1037+ parts after it so a channel this client has never been in has nothing to
1038+ show, and says so rather than showing an empty column."
1039+ [name]
1040+ (let [people (actions/member-list name)]
1041+ [:vbox {:key :users :width-request users-width :fill-height true
1042+ :reserve (below-messages) :spacing 8}
1043+ [:title-2 {:label (str "People " (count people))}]
1044+ [:scroll {:scroll-key (str "users-" name)
1045+ :orientation :vertical}
1046+ (if (seq people)
1047+ ;; Keyed, for the reason the conversation list is: this list reorders
1048+ ;; as people come and go, and a name is a button someone can be
1049+ ;; standing on.
1050+ (for [p people] ^{:key (:nick p)} [member-row p])
1051+ [:dim-label {:label "Nobody listed yet."}])]]))
1052+
1053+(defn chat-screen []
1054+ (let [name @cells/current
1055+ buffer (get @cells/channels name)
1056+ show-users? (and @cells/show-users? name (str/starts-with? name "#"))]
1057+ ;; Not a :page a page scrolls everything, which would carry the compose
1058+ ;; bar off the bottom with the backlog. The message list is the only thing
1059+ ;; that scrolls, bounded so what follows it keeps its room.
1060+ ;; Same margin all round: the compose row's own air is what centres it in
1061+ ;; the strip below the separator, and it is measured from this edge.
1062+ ;; Ctrl-End is the jump without the button, for a reader whose hands are
1063+ ;; on the compose bar. It is unhandled everywhere below the entry takes
1064+ ;; plain End for its own caret and leaves this one alone so it arrives
1065+ ;; here by bubbling up from whatever had the focus. The window backend
1066+ ;; registers the handler and never calls it: keys there belong to egui.
1067+ ;; `:fill-height` for the reason the chats screen gives on its own root:
1068+ ;; without it this column is as tall as what is in it, and the compose bar
1069+ ;; sits wherever the backlog happens to end rather than at the bottom.
1070+ [:vbox {:spacing 8 :margin 12 :fill-height true
1071+ :on-key (fn [k]
1072+ (when (= k "ctrl+end") (actions/jump-to-present!)))}
1073+ [:hbox {:spacing 8}
1074+ ;; The way back to the list, on a window with room for one thing at a
1075+ ;; time. Beside the list there is nothing to go back to, so the button
1076+ ;; goes in a wrapper of its own, since a child that comes and goes
1077+ ;; would otherwise renumber the row for the reconciler.
1078+ [:vbox {:key :back}
1079+ (when-not (actions/wide?)
1080+ [:button {:label "← Chats" :on-click #(reset! cells/screen :chats)}])]
1081+ ;; And the same room's other way of appearing, on a window wide enough
1082+ ;; to have been showing both: the list folds away and the conversation
1083+ ;; takes the whole row. Its own wrapper, since it is only offered where
1084+ ;; there are two panes to choose between.
1085+ [:vbox {:key :fold}
1086+ (when (actions/wide?)
1087+ [:button {:label (if @cells/hide-chat-list? "☰ Chats" "")
1088+ :on-click actions/toggle-chat-list!}])]
1089+ [:title {:label (or name "Chat")}]
1090+ ;; Same wrapper trick: only in a channel, and only when there is no call
1091+ ;; to join already the bar below offers Join in that case, and two ways
1092+ ;; into the same call is one more than anybody needs.
1093+ [:vbox {:key :call}
1094+ (when (and name
1095+ (str/starts-with? name "#")
1096+ (actions/call-available?)
1097+ (not (actions/call-in name))
1098+ (not (actions/in-call?)))
1099+ [:button {:label "Call" :on-click #(actions/start-call! name)}])]
1100+ ;; The people panel's switch, in a wrapper of its own for the same
1101+ ;; reason: it is only offered in a channel, where there is a membership
1102+ ;; to show.
1103+ [:vbox {:key :people}
1104+ (when (and name (str/starts-with? name "#"))
1105+ [:button {:label (str "People " (actions/member-count name))
1106+ :kind (when @cells/show-users? :primary)
1107+ :on-click actions/toggle-users!}])]
1108+ ;; The overview's switch. Not in a wrapper conditioned on anything: it
1109+ ;; is about every room rather than this one, so it is offered in a DM
1110+ ;; and in a channel alike.
1111+ [:button {:key :overview-toggle
1112+ :label "Overview"
1113+ :kind (when @cells/overview? :primary)
1114+ :on-click actions/toggle-overview!}]]
1115+ [error-note]
1116+ ;; In a wrapper of its own, for the reconciler's sake: it comes and goes.
1117+ [:vbox {:key :policy}
1118+ (policy-note name buffer)]
1119+ [call-bar name]
1120+ ;; :reserve leaves room for everything below: the jump button's row, the
1121+ ;; separator and the compose bar. It does not vary with whether the button
1122+ ;; is showing, and neither does that row a reserve that changed would
1123+ ;; move the compose bar under the reader every time the button came and
1124+ ;; went.
1125+ ;; Named, so the list is the same list when the reader comes back to it.
1126+ ;; The lightbox is a screen rather than a layer, so looking at a picture
1127+ ;; unmounts the backlog behind it; without a name of its own the position
1128+ ;; would come back as a fresh one, and Back would answer a click on a
1129+ ;; message halfway up a week of history with the top of the buffer.
1130+ ;; The backlog and, when it is asked for, who is in the room beside it.
1131+ ;; The row is always there and the panel comes and goes inside a wrapper
1132+ ;; of its own: a child that appeared and vanished would renumber the row
1133+ ;; for the reconciler, and take the message list's scroll position with
1134+ ;; it every time the panel was toggled.
1135+ [:hbox {:spacing 8 :wrap false}
1136+ [:vbox {:key :messages :fill-height true
1137+ :width-request (if show-users? (messages-width) 0)}
1138+ [:scroll {:scroll-key (messages-scroll-key)
1139+ :orientation :vertical
1140+ :reserve (below-messages)
1141+ :stick-to-bottom true
1142+ :scroll-to-bottom @cells/jump-tick
1143+ :on-change #(reset! cells/at-present? (= "end" %))
1144+ ;; The terminal's half of the same question, which arrives
1145+ ;; as the offset the list moved to rather than as a place.
1146+ ;; `scrolled!` is what turns one into the other.
1147+ :on-scroll actions/scrolled!}
1148+ (if (seq (:messages buffer))
1149+ (message-rows (:messages buffer))
1150+ [:dim-label {:label "Nothing here yet."}])]]
1151+ ;; The wrapper takes no height of its own: the panel inside it is the
1152+ ;; column, and a fill-height wrapper around it would claim the strip the
1153+ ;; compose bar sits in whether or not the panel was showing.
1154+ [:vbox {:key :people-pane}
1155+ (when show-users? [users-panel name])]]
1156+ ;; Only while it is needed, and directly under the backlog: the way back
1157+ ;; to the present belongs next to the thing that puts you there, which is
1158+ ;; the conversation and not the overview.
1159+ ;;
1160+ ;; The row used to be held open by a spacer when the button was not in
1161+ ;; it, so that the compose bar did not move when it came and went. It sat
1162+ ;; under the backlog then, where the empty row read as the end of the
1163+ ;; conversation. Above the strip it reads as a hole between two lists,
1164+ ;; and there is no arguing a reader out of that so the row is only
1165+ ;; there when the button is, and what moves is a strip below it moving by
1166+ ;; one row on the rare frame the reader has scrolled away from the end.
1167+ [:vbox {:key :jump}
1168+ (when-not @cells/at-present?
1169+ ;; With the key beside it where there is a key: a terminal is where a
1170+ ;; reader is least likely to reach for the mouse, and most likely to
1171+ ;; have paged up here with the keyboard in the first place.
1172+ [:button {:label (if @terminal?
1173+ "↓ Jump to present (Ctrl-End)"
1174+ "↓ Jump to present")
1175+ :on-click actions/jump-to-present!}])]
1176+ ;; The strip, under the conversation and the way back to it: it is a
1177+ ;; second list of messages, so it belongs with the first rather than down
1178+ ;; among the compose bar's rows. In a wrapper of its own, since it comes
1179+ ;; and goes.
1180+ [:vbox {:key :overview-pane :fill-height (boolean (and @cells/overview?
1181+ (not @terminal?)))}
1182+ (when @cells/overview? [overview-pane])]
1183+ ;; No rule above the compose bar. The bar is already told apart from the
1184+ ;; conversation by what it is a framed box and a button on a strip of
1185+ ;; air and a line drawn over it as well was the app underlining the one
1186+ ;; edge nobody was going to miss.
1187+ ;; The three banners in one wrapper, at no spacing of its own. Each is a
1188+ ;; row that is usually empty, and an empty child still costs the column
1189+ ;; its gap three of them stacked under the overview was two dozen
1190+ ;; points of nothing between the strip and the compose bar. Inside, they
1191+ ;; carry their own air only when they have something in them.
1192+ [:vbox {:key :banners :spacing 0}
1193+ ;; What the draft is answering, directly above where it is being typed.
1194+ [:vbox {:key :replying}
1195+ (when-let [target @cells/replying-to]
1196+ [:hbox {:spacing 8}
1197+ [:dim-label {:label (str "" (:from target) ": " (summarise target 36))}]
1198+ [:button {:label "" :on-click actions/cancel-reply!}]])]
1199+ ;; And, in the same place, that the box holds a rewrite rather than
1200+ ;; something new: the text in it is a copy of a line already on screen,
1201+ ;; and without this Send would look like it was about to say it twice.
1202+ [:vbox {:key :editing}
1203+ (when @cells/editing
1204+ [:hbox {:spacing 8}
1205+ [:dim-label {:label "✏️ Editing your message"}]
1206+ [:button {:label "" :on-click actions/cancel-edit!}]])]
1207+ ;; The pasted picture, above the line it will go out with. Shown rather
1208+ ;; than written into the draft: what is being sent is a picture, and a URL
1209+ ;; dropped into the entry would be an unreadable line of text sitting in
1210+ ;; the middle of whatever the reader was in the middle of typing.
1211+ [:vbox {:key :attachment}
1212+ (when-let [att @cells/attachment]
1213+ [:hbox {:spacing 8}
1214+ ;; Small: it is a reminder of what is attached, not the picture
1215+ ;; itself, and the backlog above it is what the reader is here for.
1216+ [:image {:src (:path att) :max-height 64}]
1217+ [:dim-label {:label (if (= :uploading (:status att))
1218+ "Uploading…"
1219+ "Picture attached")}]
1220+ [:button {:label "" :on-click actions/clear-attachment!}]])]]
1221+ ;; The line and its buttons on the middle of the width rather than
1222+ ;; against its left edge: the entry asks for a fixed width, and on a
1223+ ;; window wider than that a left-aligned row leaves it stranded in the
1224+ ;; corner of an otherwise empty bar. On a phone the row is as wide as the
1225+ ;; window and centring costs nothing.
1226+ ;; Equal air above and below, so the row sits on the middle of the strip
1227+ ;; between the separator and the bottom edge rather than flat against it.
1228+ ;; Above it is one of the column's gaps, where it used to be four: the
1229+ ;; reply banner, the edit banner and the attachment share a wrapper now
1230+ ;; and cost one between them whether or not they have anything in them.
1231+ ;; This margin plus the window's own is what answers it underneath.
1232+ [:hbox {:spacing 8 :align :center :margin-bottom 12}
1233+ ;; narrow enough that Send keeps its place on a phone-width row
1234+ ;; A picture is pasted where everything else is typed: Ctrl+V. The field
1235+ ;; answers a paste of text with the text, and one of a picture reaches
1236+ ;; `:on-paste-empty` a keystroke the field had nothing to put in
1237+ ;; itself, which is exactly the one that means "the clipboard has
1238+ ;; something else on it".
1239+ ;; And the same picture chosen rather than pasted, for a phone which has
1240+ ;; no Ctrl+V, and no clipboard of pictures to read if it had.
1241+ ;; In a window, a picture on a Delight 2 tile (src/frq/icons, on the
1242+ ;; geometry delight-icons generates its tiles with): grey frame, dark
1243+ ;; square, cream glyph where the emoji was a colour photo that matched
1244+ ;; nothing else in the bar. Its path is read from the working directory,
1245+ ;; which is the tree under `just run` and `just cosmic` and the store
1246+ ;; copy under the flake's launcher. A terminal has no pixels to put
1247+ ;; it in, and an APK carries no src/ to read it from both keep the glyph.
1248+ (if (and (actions/desktop?) (not @terminal?))
1249+ [:image {:src "src/frq/icons/insert-image.png"
1250+ :size [36 36]
1251+ ;; for a backend that sizes a picture by its bounds instead
1252+ :max-width 36
1253+ :max-height 36
1254+ :on-click actions/open-image-picker!}]
1255+ [:button {:label "🖼" :on-click actions/open-image-picker!}])
1256+ ;; In a terminal the row is the width of the screen and a message is
1257+ ;; longer than 260 points of it: the field takes the surplus and wraps
1258+ ;; into three rows rather than scrolling one line sideways.
1259+ [:entry {:text @cells/draft
1260+ :width-request 260
1261+ ;; Always, not only in a terminal. A window is the case that
1262+ ;; needs it more: 260 points is most of a phone's row and a
1263+ ;; third of a pane's, and the field sat stranded in the middle
1264+ ;; of the bar with the rest of it empty. The number stays as
1265+ ;; the minimum it always was.
1266+ :hexpand true
1267+ :rows (if @terminal? 3 1)
1268+ ;; In a window the field starts as one line and takes another
1269+ ;; every time the message stops fitting, up to five past
1270+ ;; which it scrolls, keeping the caret in view. A paragraph
1271+ ;; typed into a one-line box was readable a dozen characters
1272+ ;; at a time, which is not how anybody writes one.
1273+ :max-rows 5
1274+ :on-rows #(reset! draft-rows %)
1275+ ;; The break is worth saying out loud where it is new: Enter
1276+ ;; sends, as it always has, and the box under it takes a
1277+ ;; paragraph now which nobody would think to try unasked.
1278+ :placeholder (if @terminal? "Message — Shift+Enter for a new line" "Message")
1279+ :on-change #(reset! cells/draft %)
1280+ :on-paste-empty actions/paste-image!
1281+ :on-activate actions/send-draft!}]
1282+ [:button {:label "Send" :kind :primary :on-click actions/send-draft!}]]]))
modified src/frq/app.clj +14 -1254
@@ -29,6 +29,7 @@
2929 ;; The connect screen lives in common/ now — the same file the
3030 ;; phone renders. It reads frq.cells and calls frq.actions, and
3131 ;; this requires it exactly where its own copy used to be.
32+ [frq.actions :as actions]
3233 [frq.metrics :as metrics]
3334 [frq.screens.connect :as connect :refer [connect-screen error-note]]
3435 ;; The conversation list moved out too, with the tab bar and the
@@ -36,6 +37,9 @@
3637 ;; phone renders this very file.
3738 [frq.screens.chats :refer [below-list chats-screen conversation-row
3839 preview-line tab-bar]]
40+ [frq.screens.chat :refer [chat-screen emoji-picker message-row
41+ reactor-dialog sidebar-width
42+ users-panel]]
3943 [frq.state :as s]))
4044
4145 ;; ---------------------------------------------------------------- pieces
@@ -49,14 +53,7 @@
4953 ;; of names with the words hanging under them.
5054 (def terminal-graphics? metrics/terminal-graphics?)
5155
52-(defn- terminal-face?
53- "Whether a message carries a picture of its sender in a terminal.
54-
55- Not the same question as `terminal?`: the face is drawn where the protocol
56- for one is, and where it is not an `:image` is `[ picture ]` printed beside
57- every nick — worse than the nothing that is there now."
58- []
59- (and @terminal? @terminal-graphics?))
56+(def ^:private terminal-face? metrics/terminal-face?)
6057
6158 (defonce ^:private derived-cells
6259 ;; One cell per question, kept for the session: a cell made afresh on every
@@ -96,8 +93,6 @@
9693 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so
9794 ;; the same number is four columns by two rows there: a cached 128-pixel
9895 ;; portrait at a quarter size, and about the smallest a face is still a face at.
99-(def ^:private face-size 32)
100-
10196 ;; ---------------------------------------------------------------- chats
10297
10398 ;; The way into a room. Standard rather than `:primary`: the theme paints a
@@ -106,20 +101,6 @@
106101 ;; rhythm down the list the one word that is the same on every row, with the
107102 ;; name that differs set quieter than it. Nothing moves; the button stops
108103 ;; shouting.
109-(def ^:private sidebar-width 320)
110-
111-;; How tall a row of chrome is — a button, the compose bar, a line of tabs.
112-;;
113-;; The two reserves below are counted in points against this: a window's row is
114-;; 34 of them, and every gap around it was chosen at that size. A terminal's
115-;; row is one cell, and the same count then reserves two or three times the
116-;; room the strip under the list actually needs — which costs a message a row,
117-;; and a conversation is measured in how many of those fit.
118-;;
119-;; So the counts stay as they are, written where the reasoning is, and a
120-;; backend whose rows are a different height says so here. Nothing else in the
121-;; tree needs it: every other number is a length, and a length scales on the
122-;; way across.
123104 (def ^:private window-row metrics/window-row)
124105 ;; Re-defined rather than moved out of reach: `frq.tui` resets this before its
125106 ;; first paint and says `app/chrome-row` when it does.
@@ -127,603 +108,8 @@
127108
128109 (def ^:private chrome-scale metrics/chrome-scale)
129110
130-(defn- chip-gap
131- "The air between two chips on a row.
132-
133- Four points is half a column, which rounds to none: in a window that is the
134- gap a pair of lozenges want, and in a terminal it puts two emoji hard against
135- each other and they read as one wide glyph. A column, where a column is the
136- smallest thing there is."
137- []
138- (if @terminal? 8 4))
139-
140-(defn- indented
141- "One of a message's columns, held in from the name above it — in a terminal.
142-
143- The nick is the heading and the words hang under it, which is the shape a
144- terminal has read a conversation in for forty years; in a window the column
145- of faces already marks that edge, so there is nothing to hold in.
146-
147- A cell of its own rather than a margin, because the backend insets a node by
148- `:margin` on all four sides or not at all: there is no left margin in a cell
149- grid, and a uniform one would spend a blank row above every message to buy
150- two columns beside it.
151-
152- The spacer asks for its width and no height. A `:size` is a length down the
153- page, and a spacer two cells tall beside an empty column — a message with no
154- picture and no reactions — is two blank rows in the middle of the backlog,
155- which is a message and a half at this size.
156-
157- Nothing to do where a face is drawn: the column that holds the portrait is
158- already to the left of the whole message, and a second indent inside it would
159- hold the words in from a name that is itself held in."
160- [k node]
161- (if (and @terminal? (not (terminal-face?)))
162- [:hbox {:key k :spacing 0}
163- [:spacer {:key :indent :size 0 :width-request 16}]
164- node]
165- node))
166-
167111 ;; ---------------------------------------------------------------- chat
168112
169-(def ^:private url-pattern #"https?://[^\s<>\"]+")
170-
171-(defn- trim-trailing-punctuation
172- "A URL at the end of a sentence would otherwise keep the sentence's
173- punctuation. A closing bracket only counts as trailing when the URL does not
174- open one itself, which is what keeps a wikipedia-style path intact."
175- [url]
176- (loop [u url]
177- (let [c (last u)]
178- (cond
179- (nil? c) u
180- (contains? #{\. \, \; \: \! \?} c) (recur (subs u 0 (dec (count u))))
181- (and (= \) c) (not (str/includes? u "("))) (recur (subs u 0 (dec (count u))))
182- :else u))))
183-
184-(defn text-runs
185- "Message text as alternating [:text s] and [:link url] runs.
186-
187- Runs because a link has to be its own widget to be clickable, and stacked
188- rather than laid out in a row because a wrapping label inside a horizontal
189- row lays out against the row's width, not the column's — which is what drags
190- long URLs off the left edge."
191- [text]
192- (let [text (or text "")]
193- (loop [pos 0 acc []]
194- (if-let [raw (re-find url-pattern (subs text pos))]
195- (let [url (trim-trailing-punctuation raw)
196- at (+ pos (str/index-of (subs text pos) raw))
197- before (subs text pos at)
198- acc (cond-> acc (seq before) (conj [:text before]))]
199- (recur (+ at (count url)) (conj acc [:link url])))
200- (let [tail (subs text pos)]
201- (cond-> acc (seq tail) (conj [:text tail])))))))
202-
203-(def ^:private text-emoji-size
204- "An emoji in a message, at the size of the words around it. Bigger and the
205- line it sits in grows to make room for it; smaller and it reads as a
206- footnote on the sentence rather than a word of it."
207- 14)
208-
209-(defn- word-node
210- "Body text, or dim text for the lines the client writes itself."
211- [k value system?]
212- (if system?
213- [:dim-label {:key k :label value}]
214- [:label {:key k :label value}]))
215-
216-(defn- run-node
217- "One run as a widget. A link is accent-coloured and opens on click; the rest
218- is body text.
219-
220- Except for the emoji in it. A label is set in the text font, and that font —
221- Ubuntu with a DejaVu subset behind it — has no emoji in it at all, so every
222- one anybody typed was drawn as tofu. The Twemoji pack has the picture, so
223- the emoji come out of the text and are drawn from the pack, and the words
224- either side stay text.
225-
226- `:emoji` and not `:reaction`, though both draw the same picture. A reaction
227- is a tally: it wears a pill, it answers the pointer, and it names the people
228- in it on hover. An emoji in a sentence is a character — so it is drawn bare,
229- and a message that ends in one no longer reads as a message someone has
230- reacted to.
231-
232- The pieces go in a wrapping row rather than the column the runs themselves
233- are stacked in: a row is what puts an emoji *in* a sentence instead of
234- breaking the sentence around it, and `:hbox` wraps its children by default,
235- so a long line still folds at the column's edge. A run with no emoji in it
236- is still one plain label — the row is only paid for where it is needed."
237- [j [kind value] system?]
238- (if (= :link kind)
239- [:link {:key j :label value :on-click #(platform/open-url! value)}]
240- (let [pieces (glyphs/runs (str/trim value))]
241- (if (glyphs/emoji? pieces)
242- ;; Runs alternate text and picture, so this gap only ever falls either
243- ;; side of an emoji — never between two words, whose spacing is the
244- ;; spaces the message was typed with. A bare glyph carries no margin of
245- ;; its own the way the pill did, and a picture set flush against a word
246- ;; reads as part of it.
247- [:hbox {:key j :spacing 2}
248- (map-indexed (fn [k [pkind pvalue]]
249- (if (= :emoji pkind)
250- [:emoji {:key k :emoji pvalue :size text-emoji-size}]
251- (word-node k pvalue system?)))
252- pieces)]
253- (word-node j (str/trim value) system?)))))
254-
255-(defn- summarise
256- "A message in one line's worth of words."
257- [m limit]
258- (let [text (str/replace (or (:text m) "") #"\s+" " ")]
259- (if (> (count text) limit)
260- (str (subs text 0 (dec limit)) "…")
261- text)))
262-
263-(defn- goto-message!
264- "Show message `id`, in `channel`, and say which one it was.
265-
266- Three things in a fixed order: be in the room, aim the scroll at the line,
267- and mark it once it is there. `jump-to` comes off again as soon as the frame
268- that scrolled has been painted — a scroll target that stays set pins the
269- view and takes scrolling away from the reader — while the highlight outlives
270- it, because arriving at a screenful of messages says nothing about which one
271- was asked for.
272-
273- `settle` is how long that first frame is given, and `linger` how long the
274- mark stays. Within a room the scrolling frame is the next one and the line
275- is somewhere the reader was already looking. Crossing a room is a slower
276- and a stranger arrival — the screen has to be built before there is
277- anything to scroll, and what it opens on is a conversation the reader was
278- not in a moment ago — so a caller that crosses one asks for more of both."
279- [channel id settle linger]
280- (when (and channel (not= channel @s/current))
281- (s/open-channel! channel))
282- (reset! s/jump-to id)
283- (reset! s/highlight id)
284- (platform/after! settle (fn [] (reset! s/jump-to nil)))
285- ;; The highlight only clears itself: a later jump elsewhere owns the
286- ;; highlight from then on.
287- (platform/after! linger (fn [] (when (= id @s/highlight)
288- (reset! s/highlight nil)))))
289-
290-(defn- reply-chip
291- "What a message is replying to, above it, and a way back to it.
292-
293- The chip carries the quote rather than only a marker: a reply is unreadable
294- without knowing what it answers, and the message it answers is usually off
295- the top of the screen. Clicking takes you there."
296- [channel id]
297- [:vbox {:key :reply}
298- (if-let [target (s/message-by-id channel id)]
299- ;; A link, not a button: the chip is a pointer back to a line, not an
300- ;; action, and a filled pill above every answer was the loudest thing in
301- ;; the column.
302- [:link {:label (str "↩ " (:from target) ": " (summarise target 48))
303- ;; The line being answered is in the room already open, so the
304- ;; next frame is the one that scrolls.
305- :on-click #(goto-message! channel id 120 2000)}]
306- ;; The message it answers is older than this buffer goes.
307- [:dim-label {:label "↩ replying to an earlier message"}])])
308-
309-(def ^:private pill-size
310- "How big a reaction is under a message. Small: it is a footnote on what was
311- said, not a second thing said."
312- 14)
313-
314-(defn- action-chips
315- "Answering and reacting, on the sender's row above the message.
316-
317- Both are things done *to* a message rather than parts of it, so they ride
318- the sender's row against its right edge, at the size a reaction is. In the
319- line with the text they took width off every line under them and wrapped a
320- message that had the room to sit on one.
321-
322- Chips rather than buttons: a button is sized for a label and these carry a
323- glyph, which made two lozenges taller than the line they belonged to.
324- `:reaction` with no count is the same pill the picker offers, so what you
325- press to react and what appears once you have look like one family.
326-
327- A row laid out from the right lays its first child furthest right, so
328- reacting comes first in the source and this reads ✏️ then ↩️ then 🙂 on
329- screen — where there is a pencil at all. Only our own lines carry one: the
330- server refuses an edit of somebody else's, and a chip that always fails is a
331- chip that lies about what can be done.
332-
333- The pencil is a chip beside the other two rather than a box holding one: a
334- wrapper is laid out as a child in its own right, which put the pencil on the
335- row at a remove from the pair it belongs with. It keys itself, so the line
336- that has no pencil is a row of two chips and not a row with a hole in it."
337- [channel m]
338- (into [:hbox {:key :actions :align :end :spacing (chip-gap)}
339- [:reaction {:key :react
340- :emoji "🙂"
341- :size pill-size
342- :on-click #(s/open-picker! channel m)}]
343- [:reaction {:key :reply
344- :emoji "↩️"
345- :size pill-size
346- :on-click #(s/reply-to! m)}]]
347- (when (s/mine? m)
348- [[:reaction {:key :edit
349- :emoji "✏️"
350- :size pill-size
351- :on-click #(s/start-edit! channel m)}]])))
352-
353-(defn- reactor-card
354- "Who is on a reaction, beside the pointer resting on it.
355-
356- The pill carries a number, and a number is the one thing about a reaction
357- nobody wants: who it was is what a room is read for. Names, one to a line,
358- with your own said as \"you\" — you are the one name in the list you cannot
359- learn anything from."
360- [emoji nicks]
361- [:vbox {:spacing 4}
362- [:hbox {:spacing 6}
363- [:reaction {:key :glyph :emoji emoji :size pill-size}]
364- [:dim-label {:label (str (count nicks)
365- (if (= 1 (count nicks)) " reaction" " reactions"))}]]
366- [:vbox {:key :who :spacing 2}
367- (for [nick (sort nicks)]
368- [:label {:key nick
369- :label (if (= nick @s/form-nick) "you" nick)}])]])
370-
371-(defn- reaction-row
372- "What people have put on a message, under it.
373-
374- A pill carries its count and toggles: clicking one you are already on takes
375- yours off, which is the same gesture that put it there. `:reaction` rather
376- than a button with the emoji as its label — the chip draws the glyph from the
377- Twemoji pack, in colour, where a label gets whatever the text font has."
378- [channel m]
379- (let [reactions (:reactions m)]
380- ;; `into` and not a lazy `for` inside the vector. The pills read
381- ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being
382- ;; realised somewhere other than the render is a read the component never
383- ;; records, so the row went on showing what it showed before. The pictures
384- ;; in `message-body` are the other place a ratom is read inside a `for`,
385- ;; and are made eager for the same reason.
386- (into
387- [:hbox {:key :pills :spacing (chip-gap)}]
388- (for [emoji (sort (keys reactions))]
389- [:reaction (cond-> {:key emoji
390- :emoji emoji
391- :size pill-size
392- :count (count (get reactions emoji))
393- :mine (s/my-reaction? m emoji)
394- :on-click #(s/toggle-reaction! channel m emoji)}
395- ;; Where there is a pointer to ask with, resting on a pill
396- ;; says who put it there. On a phone the pill is a button
397- ;; and nothing more: there is no hover to answer.
398- (platform/desktop?)
399- ;; Nothing is hung under the pill any more: who is on a
400- ;; reaction is shown the way everything else the pointer
401- ;; asks for is shown now — the dialog at the root of the
402- ;; tree, which `reactor-dialog` builds out of
403- ;; `reaction-hover`. The pill's only job is to say where
404- ;; the pointer is.
405- (assoc :on-hover #(s/hover-reaction! (:id m) emoji)
406- :on-unhover #(s/unhover-reaction! (:id m) emoji)))]))))
407-
408-(def ^:private picker-columns
409- "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
410- phone-width window, which is the width this app is laid out for."
411- 9)
412-
413-(def ^:private picker-size 20)
414-
415-;; A row of chips, and the most the grid may take before it scrolls.
416-
417-
418-(defn emoji-picker
419- "The whole set to choose from, under the message it is for.
420-
421- It opens in the list itself, directly under the line it was opened on:
422- choosing a reaction is something done *to* a message, and the message has to
423- stay in front of the reader while it is chosen — which is not something a
424- panel at the bottom of the window, or a screen of its own, can promise.
425-
426- It opens on the emoji people actually react with; the groups and the search
427- box are for the rest."
428- []
429- (let [shown (s/picker-emoji)
430- over (max 0 (- (count shown) s/picker-limit))
431- rows (partition-all picker-columns (take s/picker-limit shown))
432- searching? (seq (str/trim @s/emoji-search))]
433- [:vbox {:key :picker :spacing 4}
434- [:hbox {:spacing 6}
435- [:entry {:text @s/emoji-search
436- :width-request 240
437- :placeholder "Search emoji"
438- :on-change #(reset! s/emoji-search %)}]
439- [:button {:label "✕" :on-click s/close-picker!}]]
440- ;; The groups are what the search box is not: a way in for someone who has
441- ;; no word for what they want. Their first word is enough to tell them
442- ;; apart, and is what keeps them to two rows. They give way to the
443- ;; search's own answer while something is typed.
444- [:vbox {:key :groups :spacing 4}
445- (when-not searching?
446- (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
447- [:hbox {:key i :spacing 4}
448- (for [g row]
449- [:button {:key (or g "popular")
450- :label (if g (first (str/split g #" ")) "Popular")
451- :kind (if (= g @s/emoji-group) :primary :normal)
452- :on-click #(reset! s/emoji-group g)}])]))]
453- ;; The grid itself, not a scroll around it: the picker sits inside the
454- ;; message list, and a scroll within a scroll takes the wheel away from
455- ;; the conversation it is standing in. What it shows is bounded instead,
456- ;; and the search box is how you reach past that.
457- [:vbox {:key :grid :spacing 2}
458- (if (seq rows)
459- (for [[i row] (map-indexed vector rows)]
460- [:hbox {:key i :spacing 2}
461- (for [[glyph] row]
462- [:reaction {:key glyph
463- :emoji glyph
464- :size picker-size
465- :count 0
466- :on-click #(s/react-from-picker! glyph)}])])
467- [:dim-label {:label "No emoji by that name."}])]
468- ;; What was left out, said rather than silently dropped.
469- [:vbox {:key :more}
470- (when (pos? over)
471- [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
472-
473-(defn- preview-height
474- "How tall a picture in the conversation may be.
475-
476- Against the window rather than a fixed 260: on a phone-sized window a
477- preview that tall is the whole screen, and on a laptop one it is a stamp of
478- something worth looking at.
479-
480- Four fifths of the window, because the pictures that arrive here are mostly
481- phone screenshots — 1080x2400, whose height is what binds, and any smaller
482- share draws them as a strip too narrow to read a word of. A picture is what
483- the message is; the line above it is enough context to place it, and the
484- scroll is how the rest is reached.
485-
486- Clamped at both ends, and falling back to the old fixed height until the
487- first poll has landed: a preview is a preview either way, and the lightbox
488- is what full size is for.
489-
490- A terminal gets less of its screen, and a hard ceiling on top of that. Four
491- fifths of a window is a picture you look at; four fifths of a terminal is a
492- conversation with one message in it, and the rows a picture takes are rows
493- that cannot hold a line. Twelve of them is enough to see what a picture is
494- of, and to decide whether to open it."
495- []
496- (let [h @s/window-height]
497- (cond
498- (not (pos? h)) 260
499- @terminal? (max 80 (min 192 (long (* 0.4 h))))
500- :else (min 900 (max 240 (long (* 0.8 h)))))))
501-
502-(defn- preview-width
503- "How wide a picture in the conversation may be.
504-
505- The height alone leaves a wide, short picture to take the column and push
506- the words around it; this holds it inside the conversation the same way.
507- Fitted, never stretched — `:image` scales to whichever bound it meets first."
508- []
509- (let [w @s/window-width]
510- (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))
511-
512-(defn- message-body
513- "A message without its face: the sender's line, the words, and what hangs
514- under them.
515-
516- Its own component because a terminal that draws pictures puts the face
517- beside all of this rather than on the sender's line — the shape the window
518- has always had, and the one that spends no row on the half of a portrait
519- that is taller than a line of text."
520- [m highlit?]
521- ;; A key each: the surface a jump leaves behind is a different node from the
522- ;; plain column, not the same node wearing another tag.
523- [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
524- :spacing 2 :margin 0}
525- ;; Who, when, and what can be done about it, on one line above the
526- ;; words: the name and the time say where the message came from, and the
527- ;; chips at the far end are the two answers to it.
528- [:vbox {:key :who}
529- ;; An event has no sender to head it, but it still happened at a time,
530- ;; and a column of joins and quits with no clock on it says nothing
531- ;; about how long the room was quiet. The time alone is that heading.
532- (when (and (:system? m) (:at m))
533- [:dim-label {:label (clock/clock-time (:at m))}])
534- (when-not (:system? m)
535- ;; Everything that is about the person rather than the line: their
536- ;; picture, their name, and when they started saying this.
537- [:hbox {:spacing 6}
538- ;; Always an avatar, picture or not: the initial stands in until the
539- ;; fetch lands, and for the guests who have no profile at all, which
540- ;; is what keeps the column of faces straight down the left.
541- ;; `avatar-path` is what wakes this row when that fetch lands.
542- ;; The face is also the way to the person behind it: Vidya's plain
543- ;; label does not answer the pointer, so the tap sleek puts on the
544- ;; nick lives here, on the one thing in the row that does.
545- ;; And, where there is a pointer to ask with, resting on the face
546- ;; answers before the click does: the card under it is the profile
547- ;; screen's first few lines, painted beside the pointer.
548- ;; And no face on this line in a terminal: the initial that stands in
549- ;; for a portrait in a window is a letter printed twice in a cell grid,
550- ;; which only pushed every nick in past the words it heads. Where the
551- ;; terminal can draw a picture there is a face after all, hung beside
552- ;; the whole message rather than off its heading — `message-row` has it.
553- (when-not @terminal?
554- (let [src @(avatar-path (:actor m))]
555- ;; One profile, two gestures, and no card hung under the face: the
556- ;; pointer opens the dialog and the press pins it. What makes that
557- ;; work is the dialog being non-modal while the pointer is what is
558- ;; holding it open — see `profile-dialog`.
559- [:avatar (cond-> {:label (:from m)
560- :src (or src "")
561- :size face-size
562- :on-click #(profile/open! (:from m) (:actor m))}
563- (platform/desktop?)
564- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
565- :on-unhover #(profile/unhover! (:from m))))]))
566- ;; The name carries the row, so it is set at body size in the plain
567- ;; text colour: dimmed caption made the one thing you scan a column
568- ;; for the faintest thing on it.
569- [:label {:label (:from m)}]
570- (when-let [at (:at m)]
571- [:dim-label {:label (clock/clock-time at)}])
572- ;; Beside the clock, because it is the same kind of fact about the
573- ;; line: what is on screen is not what was first said.
574- (when (:edited? m)
575- [:dim-label {:label "(edited)"}])
576- ;; And the two things you can do to the message, at the far end of
577- ;; its heading: a nested row laid out from the right takes what is
578- ;; left of the width and puts the chips against the edge of it, so
579- ;; the words below keep the whole column.
580- ;;
581- ;; A key of its own for each branch, rather than one key whose node
582- ;; changes tag under it: a line this client sent has no msgid until
583- ;; the server echoes it back, and swapping a `:spacer` for a chip in
584- ;; place is what left the row rebuilt in the wrong order.
585- (if (:id m)
586- [action-chips {:key :actions} @s/current m]
587- [:spacer {:key :actions-gap :size 0}])])]
588- ;; A little air under the sender's row: the name and face are a heading
589- ;; for what follows, and a heading that touches its text is not one.
590- ;; The text keeps a column of its own: a wrapping label directly in a
591- ;; row wraps against the row.
592- (indented
593- :text
594- [:vbox {:key :text :spacing 2 :margin-top 4}
595- ;; What this answers, in the column the answer itself is in: the chip
596- ;; is a quote of a line, so it wraps to the width a line has. Outside
597- ;; this column it wrapped to the whole row instead — wider than any
598- ;; message, and out to the window's edge on a narrow screen.
599- [:vbox {:key :reply-chip}
600- (when-let [reply-to (:reply-to m)]
601- [reply-chip @s/current reply-to])]
602- (map-indexed (fn [j run] (run-node j run (:system? m)))
603- (text-runs (:text m)))])
604- ;; And the picker, when this is the message it was opened on: under the
605- ;; line it is about, where the reader is already looking.
606- ;;
607- ;; The id has to exist, not merely match: a line this client sent itself
608- ;; has no msgid, and neither does a closed picker — so `nil = nil` was
609- ;; every one of those messages opening a picker of its own at startup.
610- [:vbox {:key :picker :margin-bottom 4}
611- (when (and (:id m) @(derived [:reacting (:id m)] #(= (:id m) (:id @s/reacting))))
612- [emoji-picker])]
613- ;; Pictures under the line that linked them. The link stays: it is what a
614- ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
615- (indented
616- :images
617- [:vbox {:key :images :spacing 4}
618- (when (seq (:images m))
619- ;; Eager, so each read happens during the render and is recorded — see
620- ;; `reaction-row`. Each picture is read through `image-path`, which
621- ;; wakes this row for its own pictures landing and not for everyone's.
622- (doall
623- (for [url (:images m)]
624- (when-let [path @(image-path url)]
625- [:image {:key url
626- :src path
627- :max-height (preview-height)
628- :max-width (preview-width)
629- :on-click #(reset! s/lightbox {:path path :url url})}]))))])
630- ;; Reactions go last, under whatever the message turned out to be: a
631- ;; line with a picture on it is the picture, and pills between the words
632- ;; and the image they introduce read as reactions to the words alone.
633- ;; Nothing moves for a message with no picture — the box above it is
634- ;; empty, and the pills still sit under the last line of text.
635- (indented
636- :reactions-row
637- [:vbox {:key :reactions-row :margin-top 6}
638- (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
639- [reaction-row @s/current m])])])
640-
641-(defn message-row
642- "One message: who said it, when, what you can do to it, and the words.
643-
644- Every line names its sender, rather than the first of a run only. A run
645- collapsed to one heading reads well until you answer the fourth line of it,
646- and then the line quoted back has no name on it; and the actions live on the
647- sender's row, which a headerless line has nowhere to put.
648-
649- Sender above the text, not beside it: a wrapping label in a horizontal row
650- lays out against the row's width rather than the column's, so one long URL
651- drags every line that follows it off the left edge."
652- [i m]
653- (let [;; What a jump landed on wears a surface of its own for a moment, so
654- ;; the answer to "which one was I sent to" is on the screen rather
655- ;; than in the reader's count of rows.
656- ;; Through `derived`, as is everything below that asks about shared
657- ;; state: a row that read the highlight itself re-rendered for every
658- ;; jump anywhere in the backlog.
659- highlit? (boolean (and (:id m)
660- @(derived [:highlit (:id m)]
661- #(= (:id m) @s/highlight))))]
662- [:vbox {:key i :spacing 2 :margin 0
663- ;; Clear of the right edge: the actions ride that edge, and the
664- ;; list's scrollbar rides it too — without this the ↩ is what the
665- ;; scrollbar is drawn over.
666- :margin-right 10
667- ;; One gap between messages: every line carries its own heading
668- ;; now, so there are no runs to hold together and nothing for a
669- ;; closed-up line to belong to.
670- :margin-top 10
671- ;; The jump target is what a "go to message" click scrolls to.
672- :scroll-here (boolean (and (:id m)
673- @(derived [:jump (:id m)]
674- #(= (:id m) @s/jump-to))))}
675- ;; The gap above, where the margin cannot be one. `:margin-top 10` is
676- ;; most of a row in a window and nothing at all in a terminal — ten points
677- ;; against a row of sixteen, rounded down, because a gap that thin is what
678- ;; it looks like at this size. So the terminal buys the row outright:
679- ;; without it every message in the backlog touches the one above it, and a
680- ;; conversation reads as one paragraph with names scattered through it.
681- (when @terminal? [:spacer {:key :gap :size 16}])
682- ;; The face beside the message, where the terminal can draw one: a
683- ;; portrait is two rows tall and a heading is one, so hanging it off the
684- ;; sender's line would leave its lower half beside a blank row. Here the
685- ;; words take that row, and the column the face makes is the indent every
686- ;; other row of the message is held in by — `indented` steps aside for it.
687- (if (terminal-face?)
688- [:hbox {:key :faced :spacing 8}
689- [:vbox {:key :face :width-request face-size}
690- (when-let [path @(avatar-path (:actor m))]
691- [:image {:key :picture
692- :src path
693- :max-width face-size
694- :max-height face-size}])]
695- [message-body m highlit?]]
696- [message-body m highlit?])]))
697-
698-(defn- day-separator [day-key label]
699- [:vbox {:key day-key :spacing 4 :margin 0}
700- [:separator {}]
701- [:dim-label {:label label}]])
702-
703-(defn- message-rows
704- "The messages, with a heading wherever the day changes.
705-
706- A backlog can reach back weeks, and `11:04 AM` says nothing about which day
707- it was. The heading is what makes the time above it mean something."
708- [messages]
709- (mapcat (fn [i m]
710- (let [prev (when (pos? i) (nth messages (dec i)))
711- day (some-> (:at m) clock/day)
712- new-day? (and day (not= day (some-> (:at prev) clock/day)))]
713- (cond-> []
714- new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m))))
715- ;; A new day breaks the run: the first line under a heading
716- ;; names its sender and its time, whoever spoke last night.
717- ;; Keyed by the line itself, not by where it sits: the list is
718- ;; reconciled by position when its children are not all keyed,
719- ;; and a line that arrives anywhere but the end — a backlog
720- ;; replay, an echo taking the place of what was sent — shifts
721- ;; every row after it onto the widgets of its neighbour.
722- true (conj ^{:key (or (:id m) (str "row-" i))}
723- [message-row i m]))))
724- (range (count messages))
725- messages))
726-
727113 (defn- profile-dialog
728114 "Who someone is, as libcosmic's own dialog: centred over the window, with
729115 what you were reading dimmed behind it rather than replaced.
@@ -815,25 +201,6 @@
815201 :sensitive (boolean url)
816202 :on-click #(when url (platform/open-url! url))}]]))
817203
818-(defn- reactor-dialog
819- "Who is on the reaction the pointer is resting on.
820-
821- The same dialog a face gets, for the same reason: two hover cards in two
822- shapes was two things to look at and two things to keep working. Non-modal
823- and no buttons, because the pointer is what is holding it open a modal one
824- would go deaf to the pill leaving and never close, and a button in it could
825- never be reached.
826-
827- The message is looked up rather than carried: a pill says which message and
828- which emoji it is, and the room it is in is the one being read a pill in
829- any other room is not under a pointer."
830- []
831- (let [{:keys [id emoji]} @s/reaction-hover
832- nicks (get (:reactions (s/message-by-id @s/current id)) emoji)]
833- (when (seq nicks)
834- [:dialog {:label "Reactions" :max-width 320 :modal false}
835- [reactor-card emoji nicks]])))
836-
837204 (defn profile-screen
838205 "Who someone is: sleek's peer profile modal, as a screen.
839206
@@ -920,118 +287,6 @@
920287 [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]
921288 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
922289
923-(defn- call-tile
924- "One participant's picture, at the width the row worked out for it.
925-
926- `:feed` rather than `:src`: these pixels never touch the disk and never
927- become a value here — the media plane hands the decoder's own buffer to
928- Vidya as a pointer, and the tag paints whatever arrived last under that name.
929-
930- The height is three quarters of the width, which is the shape a camera hands
931- over. Naming both keeps a portrait phone from making its tile tall enough to
932- push the row off the screen — the picture is fitted inside, never stretched."
933- [width key]
934- (let [mine? (= av/local-feed key)]
935- [:vbox {:key key :spacing 2}
936- ;; `:upscale` because a tile is a slot the layout sized, not a picture
937- ;; sitting at whatever the camera happened to send. Without it a 480-wide
938- ;; stream draws 480 wide in a 900-point slot and the wall looks broken —
939- ;; which is exactly what it did.
940- [:image {:feed key
941- :upscale true
942- :max-width width
943- :max-height (long (* width 0.75))}]
944- [:dim-label {:label (if mine? "You" key)}]]))
945-
946-(defn- call-wall
947- "Everyone with a camera on, sized to the window they are being watched in.
948-
949- A call with no video is the normal case and should look like one — a row of
950- empty frames would suggest something had failed to load.
951-
952- Both cells this reads are what subscribe it: `av/tiles` for who is on
953- screen, and the window width so the tiles follow a window being dragged.
954- Without either it would lay itself out once, on the first frame, and keep
955- that shape for the rest of the call."
956- []
957- (let [[width rows] (av/tile-rows)]
958- [:vbox {:key :call-wall :spacing 6}
959- ;; A seq, not a vector: children splice, and a vector would be read as one
960- ;; more hiccup element — which an empty one is not.
961- (for [[i keys] (map-indexed vector rows)]
962- [:hbox {:key i :spacing 8}
963- (for [key keys]
964- [call-tile width key])])]))
965-
966-(defn- call-controls
967- "What the person in a call can do about it.
968-
969- Mute and deafen are separate buttons because they are separate things: a
970- deafened microphone still carries your voice, and one control for both would
971- make the quieter of the two a surprise."
972- []
973- (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
974- [:vbox {:key :call-controls :spacing 6}
975- [:hbox {:spacing 8}
976- [:label {:label (case media
977- :waiting "Asking to join…"
978- :dialling "Connecting…"
979- :live "In call"
980- :failed "Call failed"
981- "In call")}]
982- ;; A microphone that is not there is worth saying so: the call works,
983- ;; and the person is listening rather than silent by choice.
984- (when (and (= :live media) (not has-mic?))
985- [:dim-label {:label "· listening only"}])]
986- [:hbox {:spacing 8}
987- [:button {:label (if muted? "Unmute" "Mute")
988- :on-click #(av/set-muted! (not muted?))}]
989- [:button {:label (if speaker-muted? "Undeafen" "Deafen")
990- :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
991- ;; Only offered when there is a camera to turn on. Nothing is more
992- ;; annoying than a control that does nothing and does not say why.
993- (when has-camera?
994- [:button {:label (if camera? "Stop video" "Start video")
995- :on-click #(av/set-camera! (not camera?))}])
996- [:button {:label "Leave" :on-click #(s/leave-call!)}]]
997- (when-let [e @av/media-error]
998- [:dim-label {:label (str "⚠ " e)}])]))
999-
1000-(defn call-bar
1001- "The call in this channel, whatever state it is in. Always a node.
1002-
1003- Three cases, and the empty one matters as much as the others: a channel with
1004- no call must render *something* here, because the reconciler matches children
1005- by position and a banner that came and went would patch the message list into
1006- a button."
1007- [channel]
1008- [:vbox {:key :call-bar :spacing 6}
1009- (cond
1010- (av/in-call? channel)
1011- [:card {}
1012- [:vbox {:spacing 6}
1013- [call-controls]
1014- [call-wall]]]
1015-
1016- ;; A call is open in this room and we are not in it.
1017- (av/call-in channel)
1018- (let [{:keys [session-id participants title]} (av/call-in channel)]
1019- [:card {}
1020- [:hbox {:spacing 8}
1021- [:label {:label (str "📞 " (or title "Call in progress")
1022- (if (and participants (pos? participants))
1023- (str " · " participants)
1024- ""))}]
1025- [:button {:label "Join"
1026- :on-click #(s/join-call! channel session-id)}]]])
1027-
1028- ;; We are in a call, but in a different room. Say which, since the
1029- ;; controls are not on this screen to be found by looking.
1030- (av/in-call?)
1031- [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
1032-
1033- :else nil)])
1034-
1035290 (defn image-picker-screen
1036291 "The pictures on this device, to send one of.
1037292
@@ -1091,510 +346,6 @@
1091346 ;; be told to leave. A column with `:fill-height` and no width takes the whole
1092347 ;; row — so the panel is only ever on screen if the message list is given a
1093348 ;; width that stops short of it.
1094-(def ^:private users-width 150)
1095-
1096-;; How many lines the compose field is currently drawn as. The window backend
1097-;; says so when it changes — a field with room to grow wraps a long message
1098-;; onto a second and third line instead of sliding it sideways — and the
1099-;; reserve below has to hear about it, since nothing in this tree is laid out
1100-;; by anything but these counts.
1101-(defonce ^:private draft-rows (atom 1))
1102-
1103-;; What the rows under the conversation need left to them: the jump button's
1104-;; row, the separator, the compose bar and the air around it. The columns of
1105-;; the row reserve it, and so nothing inside them has to — a `:scroll` that
1106-;; subtracted it again inside a column already stopped short would take the
1107-;; same points off the list twice.
1108-;;
1109-;; Plus the reply banner's row when there is one, and the pasted picture when
1110-;; there is one. Those rows do move the compose bar, and should: each appears
1111-;; because the reader just asked for something — unlike the jump button, which
1112-;; appears on its own and must not shift what is under it.
1113-;; What the overview strip costs the conversation above it, in points: a row
1114-;; for its heading, a row a line, and the separator and gaps around them. The
1115-;; backlog's `:reserve` takes this too, so the strip is paid for out of the
1116-;; conversation rather than pushing the compose bar off the bottom — which is
1117-;; what every other row under the message list has had to say for itself.
1118-;;
1119-;; Counted from the lines there actually are, not from the ceiling: the strip
1120-;; holds at most `overview-lines` of them there, and a room with two lines in
1121-;; it should not have a screenful reserved against it.
1122-;;
1123-;; The window reserves nothing for the overview. Its pane scrolls and fills,
1124-;; so it takes its half of the column by being in it — there is no number to
1125-;; subtract, and subtracting one would take the room twice.
1126-(defn- overview-height []
1127- (* (chrome-scale)
1128- (+ 24 (* 20 (min s/overview-lines (count (s/recent-everywhere)))) 16)))
1129-
1130-(defn- below-messages []
1131- ;; 115 is that, counted: the gap under the row of columns, the jump button's
1132- ;; 34pt row, the banners in their one wrapper with one gap, then the compose
1133- ;; row and the air under it — its own and the window's. Short by any of it
1134- ;; and the column runs past the bottom edge, which does not show as a list
1135- ;; that is too long: it shows as a compose bar sitting flat on the bottom of
1136- ;; the window with its margin cut off.
1137- ;;
1138- ;; It was 140 when a rule was drawn above the compose bar and the three
1139- ;; banners each cost the column a gap of their own. The rule went, and they
1140- ;; went into one wrapper: twenty-five points of nothing, given back.
1141- (+ (* (chrome-scale)
1142- (+ 115
1143- (if @s/replying-to 34 0)
1144- (if @s/attachment 76 0)))
1145- ;; The two extra rows the terminal's compose field wraps into, in points:
1146- ;; a row down the page is two cells' worth of the scale.
1147- (if @terminal? (* 4 (chrome-scale)) 0)
1148- ;; And the lines a window's compose field has GROWN by. Unscaled and
1149- ;; outside the terminal's arm on purpose: the field only reports its
1150- ;; height where it can change — the terminal's is the fixed block of
1151- ;; three rows reserved just above — so `draft-rows` is one there and
1152- ;; this term is zero. Twenty points a line is a 16-point face's line
1153- ;; box rounded up: the reserve has to be at least what the field took,
1154- ;; because a point too few does not crop the list, it slides the
1155- ;; compose bar off the bottom of the window.
1156- (* 20 (dec @draft-rows))
1157- ;; And the overview strip, when it is up. Reserved here rather than
1158- ;; anywhere else because this is the number the backlog is laid out
1159- ;; against: without it the strip is drawn past the bottom of the window
1160- ;; and takes the compose bar with it.
1161- (if (and @terminal? @s/overview?) (overview-height) 0)))
1162-
1163-(defn- messages-width
1164- "How wide the message list may be with the people panel beside it.
1165-
1166- Measured from the window rather than from what egui has left: the row is
1167- painted left to right, and by the time the panel is placed the list has
1168- already taken everything. On a wide window the chats list is holding the
1169- first `sidebar-width` of the window; the rest is the margins and the gap
1170- between the two columns."
1171- []
1172- (let [pane (- @s/window-width (if (and (s/wide?) (not @s/hide-chat-list?))
1173- sidebar-width
1174- 0))]
1175- (max 240 (- pane users-width 8 28))))
1176-
1177-(defn- member-row [{:keys [nick prefix]}]
1178- ;; The mode where there is one, dim: it says how someone is listed, not who
1179- ;; they are, and the name is what the eye is scanning for. A space where
1180- ;; there is none, rather than nothing at all: the label is always in the row,
1181- ;; so every name starts at the same place — and a column wrapped around a
1182- ;; label that comes and goes is a column, which sits its text against the top
1183- ;; of the row rather than on the line the name is on.
1184- ;;
1185- ;; The name is a button because a person is somewhere to go: pressing one
1186- ;; opens a conversation with them.
1187- [:hbox {:key nick :spacing 6 :wrap false}
1188- ;; In a slot of its own width, so an "@" and a space take the same room and
1189- ;; the names line up whatever mode is in front of them.
1190- [:vbox {:key :mode :width-request 14}
1191- [:dim-label {:label (if (seq prefix) prefix " ")}]]
1192- [:button {:label nick :on-click #(s/open-dm! nick)}]])
1193-
1194-(defn users-panel
1195- "Who is in the channel, beside the conversation.
1196-
1197- The list is the server's NAMES on the way in, kept up by the joins and
1198- parts after it so a channel this client has never been in has nothing to
1199- show, and says so rather than showing an empty column."
1200- [name]
1201- (let [people (s/member-list name)]
1202- [:vbox {:key :users :width-request users-width :fill-height true
1203- :reserve (below-messages) :spacing 8}
1204- [:title-2 {:label (str "People " (count people))}]
1205- [:scroll {:scroll-key (str "users-" name)
1206- :orientation :vertical}
1207- (if (seq people)
1208- ;; Keyed, for the reason the conversation list is: this list reorders
1209- ;; as people come and go, and a name is a button someone can be
1210- ;; standing on.
1211- (for [p people] ^{:key (:nick p)} [member-row p])
1212- [:dim-label {:label "Nobody listed yet."}])]]))
1213-
1214-(defn- messages-scroll-key
1215- "What the backlog's scroll position is remembered under.
1216-
1217- One name in a window: `:scroll-to-bottom` is how the jump button is answered
1218- there, and the position under that name is the one the reader left behind.
1219-
1220- The terminal backend has no `:scroll-to-bottom` a viewport there is moved
1221- by the wheel and the page keys and by nothing else but it does open a
1222- sticky viewport it has never seen at the bottom, which is the same thing
1223- said differently. So a jump renames the viewport: the tick that asks the
1224- window to scroll gives the terminal a name with no position saved under it,
1225- and the newest line is what it opens on.
1226-
1227- Only on a jump, so scrolling and every message that arrives between two
1228- jumps still find the position where they left it."
1229- []
1230- (if @terminal?
1231- (str "chat-messages-" @s/jump-tick)
1232- "chat-messages"))
1233-
1234-(defn- policy-note
1235- "What this channel wants agreed to, and the one button that agrees to it.
1236-
1237- Above the backlog rather than in it: the refusal is already a line in the
1238- buffer, and a button scrolled away with last week's messages is a button
1239- nobody finds. The rules come from the server; when it has none to give, its
1240- own words about that are what shows."
1241- [name buffer]
1242- (when (:policy-required? buffer)
1243- [:vbox {:spacing 4}
1244- [:dim-label {:label (str name " asks you to accept its policy before joining.")}]
1245- (for [[i line] (map-indexed vector (:policy-text buffer))]
1246- ^{:key (str "policy-" i)} [:dim-label {:label line}])
1247- [:button {:label "Accept policy" :kind :primary
1248- :on-click #(s/accept-policy! name)}]]))
1249-
1250-(defn- overview-row
1251- "One line from somewhere else: which room, who, and what on one row.
1252-
1253- The room leads, because that is the whole question the strip answers. The
1254- name is a button for the same reason it is one in the people panel: the
1255- room is somewhere to go, and a line you want more of is a line you want the
1256- conversation behind it."
1257- [i m]
1258- [:hbox {:key i :spacing 8 :wrap false}
1259- ;; The room is the button, and pressing it goes to this line rather than to
1260- ;; the room's end: the line is what was read here and what the press was
1261- ;; about, and a jump that landed on the newest message instead would answer
1262- ;; a question nobody asked from a strip that was showing the answer.
1263- ;;
1264- ;; Unless the line has no id to aim at — one this client has sent and the
1265- ;; server has not echoed back yet — and then the room is all there is to
1266- ;; offer.
1267- ;;
1268- ;; 600ms to land and five seconds marked: the room is a room away, so the
1269- ;; screen it scrolls in has to be built before there is anything to scroll,
1270- ;; and what the reader arrives at is a conversation they were not in a
1271- ;; moment ago. The mark is the whole answer to "which of these was the line
1272- ;; I pressed", and it has to still be there when they have finished
1273- ;; recognising where they are.
1274- [:button {:label (:channel m)
1275- :on-click (if (:id m)
1276- #(do (s/leaving-for-overview!)
1277- (goto-message! (:channel m) (:id m) 600 5000))
1278- #(do (s/leaving-for-overview!)
1279- (s/open-channel! (:channel m))))}]
1280- ;; And the line itself, as something to read rather than to press. It was a
1281- ;; link for a moment, which made the strip two things at once: a line the
1282- ;; server has echoed back has an id to aim at and a line this client has
1283- ;; just sent does not, so half the rows came out accent-coloured and half
1284- ;; plain, down the same list. The chip beside it is the way to the message
1285- ;; and is on every row either way.
1286- [:vbox {:key :said}
1287- [:dim-label {:label (str (when-let [at (:at m)] (str (clock/clock-time at) " "))
1288- (:from m) ": "
1289- (preview-line (:text m)))}]]])
1290-
1291-(defn- overview-pane
1292- "Every other room's recent lines in one list, newest at the top.
1293-
1294- A strip under the conversation rather than a screen of its own: the question
1295- it answers — is anything happening anywhere else — is one you ask while
1296- reading something, and an answer you have to leave the room for is one you
1297- stop asking for.
1298-
1299- In a window it scrolls, and the column it is in fills the height: two
1300- children of a column that both fill it are two halves of it, which is the
1301- horizontal split this is. The backlog above keeps its own scroll and its own
1302- place in it, so reading down here does not move the conversation.
1303-
1304- A terminal gets a handful of lines and no scroll. Its rows are cells rather
1305- than points, and a second scrolling pane in a screen that is already a
1306- conversation, a compose bar and a tab bar leaves neither half enough rows to
1307- be worth reading — so there it stays the strip it was, and `below-messages`
1308- reserves it by the row."
1309- []
1310- (let [lines (s/recent-everywhere)
1311- ;; The way back, and only while there is somewhere to go: the strip
1312- ;; is the one thing here that moves you without your having chosen a
1313- ;; room, so it is the one thing that owes you an undo. Gone once you
1314- ;; are back in that room, where it would offer to take you where you
1315- ;; already are.
1316- back (let [room @s/overview-return]
1317- (when (and room (not= room @s/current))
1318- [:button {:label (str "← Back to " room)
1319- :on-click s/overview-back!}]))
1320- ;; The row keeps its height whether or not the way back is in it. A
1321- ;; button is 34 points and a caption is not, so a heading that grew
1322- ;; one when you jumped and lost it when you came back moved every
1323- ;; line under it — the strip rendering two different ways depending
1324- ;; on where you had been.
1325- heading (fn []
1326- [:hbox {:key :heading :spacing 8 :wrap false :align :center}
1327- [:dim-label {:label "Everywhere else"}]
1328- [:vbox {:key :back}
1329- (or back [:spacer {:size @chrome-row}])]])
1330- ;; The same heading on the same rule the conversation puts between two
1331- ;; days, and needed more here than there: these lines come from rooms
1332- ;; that were last spoken in at their own times, so a strip of them can
1333- ;; cross a day twice in ten rows where a single room's backlog crosses
1334- ;; one once a day. A clock alone would then be the only thing saying
1335- ;; which, and `9:40` says nothing about how long ago it was.
1336- rows (fn [ms]
1337- (if (seq ms)
1338- (mapcat
1339- (fn [i m]
1340- (let [prev (when (pos? i) (nth ms (dec i)))
1341- day (some-> (:at m) clock/day)
1342- new-day? (and day (not= day (some-> (:at prev) clock/day)))]
1343- (cond-> []
1344- new-day?
1345- (conj (day-separator (str "overview-day-" i)
1346- (clock/day-label (:at m))))
1347- true
1348- (conj ^{:key (str (:channel m) "-" (or (:id m) i))}
1349- [overview-row i m]))))
1350- (range (count ms))
1351- ms)
1352- [[:dim-label {:label "Nothing has been said in any other room yet."}]]))]
1353- (if @terminal?
1354- (into [:vbox {:key :overview :spacing 4 :margin-top 4}
1355- [:separator {}]
1356- [heading]]
1357- (rows (take s/overview-lines lines)))
1358- [:vbox {:key :overview :spacing 4 :margin-top 4 :fill-height true}
1359- [:separator {}]
1360- [heading]
1361- ;; Named, so coming back to a conversation does not throw away where
1362- ;; the reader had got to in here.
1363- (into [:scroll {:scroll-key "overview-list" :orientation :vertical
1364- :spacing 4}]
1365- (rows lines))])))
1366-
1367-(defn chat-screen []
1368- (let [name @s/current
1369- buffer (get @s/channels name)
1370- show-users? (and @s/show-users? name (str/starts-with? name "#"))]
1371- ;; Not a :page — a page scrolls everything, which would carry the compose
1372- ;; bar off the bottom with the backlog. The message list is the only thing
1373- ;; that scrolls, bounded so what follows it keeps its room.
1374- ;; Same margin all round: the compose row's own air is what centres it in
1375- ;; the strip below the separator, and it is measured from this edge.
1376- ;; Ctrl-End is the jump without the button, for a reader whose hands are
1377- ;; on the compose bar. It is unhandled everywhere below — the entry takes
1378- ;; plain End for its own caret and leaves this one alone — so it arrives
1379- ;; here by bubbling up from whatever had the focus. The window backend
1380- ;; registers the handler and never calls it: keys there belong to egui.
1381- ;; `:fill-height` for the reason the chats screen gives on its own root:
1382- ;; without it this column is as tall as what is in it, and the compose bar
1383- ;; sits wherever the backlog happens to end rather than at the bottom.
1384- [:vbox {:spacing 8 :margin 12 :fill-height true
1385- :on-key (fn [k]
1386- (when (= k "ctrl+end") (s/jump-to-present!)))}
1387- [:hbox {:spacing 8}
1388- ;; The way back to the list, on a window with room for one thing at a
1389- ;; time. Beside the list there is nothing to go back to, so the button
1390- ;; goes — in a wrapper of its own, since a child that comes and goes
1391- ;; would otherwise renumber the row for the reconciler.
1392- [:vbox {:key :back}
1393- (when-not (s/wide?)
1394- [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])]
1395- ;; And the same room's other way of appearing, on a window wide enough
1396- ;; to have been showing both: the list folds away and the conversation
1397- ;; takes the whole row. Its own wrapper, since it is only offered where
1398- ;; there are two panes to choose between.
1399- [:vbox {:key :fold}
1400- (when (s/wide?)
1401- [:button {:label (if @s/hide-chat-list? "☰ Chats" "☰")
1402- :on-click s/toggle-chat-list!}])]
1403- [:title {:label (or name "Chat")}]
1404- ;; Same wrapper trick: only in a channel, and only when there is no call
1405- ;; to join already — the bar below offers Join in that case, and two ways
1406- ;; into the same call is one more than anybody needs.
1407- [:vbox {:key :call}
1408- (when (and name
1409- (str/starts-with? name "#")
1410- (av/available?)
1411- (not (av/call-in name))
1412- (not (av/in-call?)))
1413- [:button {:label "Call" :on-click #(s/start-call! name)}])]
1414- ;; The people panel's switch, in a wrapper of its own for the same
1415- ;; reason: it is only offered in a channel, where there is a membership
1416- ;; to show.
1417- [:vbox {:key :people}
1418- (when (and name (str/starts-with? name "#"))
1419- [:button {:label (str "People " (s/member-count name))
1420- :kind (when @s/show-users? :primary)
1421- :on-click s/toggle-users!}])]
1422- ;; The overview's switch. Not in a wrapper conditioned on anything: it
1423- ;; is about every room rather than this one, so it is offered in a DM
1424- ;; and in a channel alike.
1425- [:button {:key :overview-toggle
1426- :label "Overview"
1427- :kind (when @s/overview? :primary)
1428- :on-click s/toggle-overview!}]]
1429- [error-note]
1430- ;; In a wrapper of its own, for the reconciler's sake: it comes and goes.
1431- [:vbox {:key :policy}
1432- (policy-note name buffer)]
1433- [call-bar name]
1434- ;; :reserve leaves room for everything below: the jump button's row, the
1435- ;; separator and the compose bar. It does not vary with whether the button
1436- ;; is showing, and neither does that row — a reserve that changed would
1437- ;; move the compose bar under the reader every time the button came and
1438- ;; went.
1439- ;; Named, so the list is the same list when the reader comes back to it.
1440- ;; The lightbox is a screen rather than a layer, so looking at a picture
1441- ;; unmounts the backlog behind it; without a name of its own the position
1442- ;; would come back as a fresh one, and ← Back would answer a click on a
1443- ;; message halfway up a week of history with the top of the buffer.
1444- ;; The backlog and, when it is asked for, who is in the room beside it.
1445- ;; The row is always there and the panel comes and goes inside a wrapper
1446- ;; of its own: a child that appeared and vanished would renumber the row
1447- ;; for the reconciler, and take the message list's scroll position with
1448- ;; it every time the panel was toggled.
1449- [:hbox {:spacing 8 :wrap false}
1450- [:vbox {:key :messages :fill-height true
1451- :width-request (if show-users? (messages-width) 0)}
1452- [:scroll {:scroll-key (messages-scroll-key)
1453- :orientation :vertical
1454- :reserve (below-messages)
1455- :stick-to-bottom true
1456- :scroll-to-bottom @s/jump-tick
1457- :on-change #(reset! s/at-present? (= "end" %))
1458- ;; The terminal's half of the same question, which arrives
1459- ;; as the offset the list moved to rather than as a place.
1460- ;; `scrolled!` is what turns one into the other.
1461- :on-scroll s/scrolled!}
1462- (if (seq (:messages buffer))
1463- (message-rows (:messages buffer))
1464- [:dim-label {:label "Nothing here yet."}])]]
1465- ;; The wrapper takes no height of its own: the panel inside it is the
1466- ;; column, and a fill-height wrapper around it would claim the strip the
1467- ;; compose bar sits in whether or not the panel was showing.
1468- [:vbox {:key :people-pane}
1469- (when show-users? [users-panel name])]]
1470- ;; Only while it is needed, and directly under the backlog: the way back
1471- ;; to the present belongs next to the thing that puts you there, which is
1472- ;; the conversation and not the overview.
1473- ;;
1474- ;; The row used to be held open by a spacer when the button was not in
1475- ;; it, so that the compose bar did not move when it came and went. It sat
1476- ;; under the backlog then, where the empty row read as the end of the
1477- ;; conversation. Above the strip it reads as a hole between two lists,
1478- ;; and there is no arguing a reader out of that — so the row is only
1479- ;; there when the button is, and what moves is a strip below it moving by
1480- ;; one row on the rare frame the reader has scrolled away from the end.
1481- [:vbox {:key :jump}
1482- (when-not @s/at-present?
1483- ;; With the key beside it where there is a key: a terminal is where a
1484- ;; reader is least likely to reach for the mouse, and most likely to
1485- ;; have paged up here with the keyboard in the first place.
1486- [:button {:label (if @terminal?
1487- "↓ Jump to present (Ctrl-End)"
1488- "↓ Jump to present")
1489- :on-click s/jump-to-present!}])]
1490- ;; The strip, under the conversation and the way back to it: it is a
1491- ;; second list of messages, so it belongs with the first rather than down
1492- ;; among the compose bar's rows. In a wrapper of its own, since it comes
1493- ;; and goes.
1494- [:vbox {:key :overview-pane :fill-height (boolean (and @s/overview?
1495- (not @terminal?)))}
1496- (when @s/overview? [overview-pane])]
1497- ;; No rule above the compose bar. The bar is already told apart from the
1498- ;; conversation by what it is — a framed box and a button on a strip of
1499- ;; air — and a line drawn over it as well was the app underlining the one
1500- ;; edge nobody was going to miss.
1501- ;; The three banners in one wrapper, at no spacing of its own. Each is a
1502- ;; row that is usually empty, and an empty child still costs the column
1503- ;; its gap — three of them stacked under the overview was two dozen
1504- ;; points of nothing between the strip and the compose bar. Inside, they
1505- ;; carry their own air only when they have something in them.
1506- [:vbox {:key :banners :spacing 0}
1507- ;; What the draft is answering, directly above where it is being typed.
1508- [:vbox {:key :replying}
1509- (when-let [target @s/replying-to]
1510- [:hbox {:spacing 8}
1511- [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}]
1512- [:button {:label "✕" :on-click s/cancel-reply!}]])]
1513- ;; And, in the same place, that the box holds a rewrite rather than
1514- ;; something new: the text in it is a copy of a line already on screen,
1515- ;; and without this Send would look like it was about to say it twice.
1516- [:vbox {:key :editing}
1517- (when @s/editing
1518- [:hbox {:spacing 8}
1519- [:dim-label {:label "✏️ Editing your message"}]
1520- [:button {:label "✕" :on-click s/cancel-edit!}]])]
1521- ;; The pasted picture, above the line it will go out with. Shown rather
1522- ;; than written into the draft: what is being sent is a picture, and a URL
1523- ;; dropped into the entry would be an unreadable line of text sitting in
1524- ;; the middle of whatever the reader was in the middle of typing.
1525- [:vbox {:key :attachment}
1526- (when-let [att @s/attachment]
1527- [:hbox {:spacing 8}
1528- ;; Small: it is a reminder of what is attached, not the picture
1529- ;; itself, and the backlog above it is what the reader is here for.
1530- [:image {:src (:path att) :max-height 64}]
1531- [:dim-label {:label (if (= :uploading (:status att))
1532- "Uploading…"
1533- "Picture attached")}]
1534- [:button {:label "✕" :on-click s/clear-attachment!}]])]]
1535- ;; The line and its buttons on the middle of the width rather than
1536- ;; against its left edge: the entry asks for a fixed width, and on a
1537- ;; window wider than that a left-aligned row leaves it stranded in the
1538- ;; corner of an otherwise empty bar. On a phone the row is as wide as the
1539- ;; window and centring costs nothing.
1540- ;; Equal air above and below, so the row sits on the middle of the strip
1541- ;; between the separator and the bottom edge rather than flat against it.
1542- ;; Above it is one of the column's gaps, where it used to be four: the
1543- ;; reply banner, the edit banner and the attachment share a wrapper now
1544- ;; and cost one between them whether or not they have anything in them.
1545- ;; This margin plus the window's own is what answers it underneath.
1546- [:hbox {:spacing 8 :align :center :margin-bottom 12}
1547- ;; narrow enough that Send keeps its place on a phone-width row
1548- ;; A picture is pasted where everything else is typed: Ctrl+V. The field
1549- ;; answers a paste of text with the text, and one of a picture reaches
1550- ;; `:on-paste-empty` — a keystroke the field had nothing to put in
1551- ;; itself, which is exactly the one that means "the clipboard has
1552- ;; something else on it".
1553- ;; And the same picture chosen rather than pasted, for a phone — which has
1554- ;; no Ctrl+V, and no clipboard of pictures to read if it had.
1555- ;; In a window, a picture on a Delight 2 tile (src/frq/icons, on the
1556- ;; geometry delight-icons generates its tiles with): grey frame, dark
1557- ;; square, cream glyph — where the emoji was a colour photo that matched
1558- ;; nothing else in the bar. Its path is read from the working directory,
1559- ;; which is the tree under `just run` and `just cosmic` and the store
1560- ;; copy under the flake's launcher. A terminal has no pixels to put
1561- ;; it in, and an APK carries no src/ to read it from — both keep the glyph.
1562- (if (and (platform/desktop?) (not @terminal?))
1563- [:image {:src "src/frq/icons/insert-image.png"
1564- :size [36 36]
1565- ;; for a backend that sizes a picture by its bounds instead
1566- :max-width 36
1567- :max-height 36
1568- :on-click s/open-image-picker!}]
1569- [:button {:label "🖼" :on-click s/open-image-picker!}])
1570- ;; In a terminal the row is the width of the screen and a message is
1571- ;; longer than 260 points of it: the field takes the surplus and wraps
1572- ;; into three rows rather than scrolling one line sideways.
1573- [:entry {:text @s/draft
1574- :width-request 260
1575- ;; Always, not only in a terminal. A window is the case that
1576- ;; needs it more: 260 points is most of a phone's row and a
1577- ;; third of a pane's, and the field sat stranded in the middle
1578- ;; of the bar with the rest of it empty. The number stays as
1579- ;; the minimum it always was.
1580- :hexpand true
1581- :rows (if @terminal? 3 1)
1582- ;; In a window the field starts as one line and takes another
1583- ;; every time the message stops fitting, up to five — past
1584- ;; which it scrolls, keeping the caret in view. A paragraph
1585- ;; typed into a one-line box was readable a dozen characters
1586- ;; at a time, which is not how anybody writes one.
1587- :max-rows 5
1588- :on-rows #(reset! draft-rows %)
1589- ;; The break is worth saying out loud where it is new: Enter
1590- ;; sends, as it always has, and the box under it takes a
1591- ;; paragraph now — which nobody would think to try unasked.
1592- :placeholder (if @terminal? "Message — Shift+Enter for a new line" "Message")
1593- :on-change #(reset! s/draft %)
1594- :on-paste-empty s/paste-image!
1595- :on-activate s/send-draft!}]
1596- [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
1597-
1598349 ;; ---------------------------------------------------------------- split
1599350
1600351 (defn- no-chat-pane
@@ -1859,3 +610,12 @@
1859610 (reset! shown title)
1860611 (title! title))))))
1861612 nil)
613+
614+;; Where a face or a picture is on disk, once it is. These stay here because
615+;; both are a glimmer reaction over a fetch-and-cache — `derived` is the one
616+;; place in this namespace that uses glimmer's own API — and a phone has
617+;; neither the reaction nor the cache. It installs nothing and the screens
618+;; draw what they draw before a face arrives.
619+(actions/install!
620+ {:avatar-path avatar-path
621+ :image-path image-path})
@@ -29,6 +29,7 @@
29 ;; The connect screen lives in common/ now — the same file the29 ;; The connect screen lives in common/ now — the same file the
30 ;; phone renders. It reads frq.cells and calls frq.actions, and30 ;; phone renders. It reads frq.cells and calls frq.actions, and
31 ;; this requires it exactly where its own copy used to be.31 ;; this requires it exactly where its own copy used to be.
32+ [frq.actions :as actions]
32 [frq.metrics :as metrics]33 [frq.metrics :as metrics]
33 [frq.screens.connect :as connect :refer [connect-screen error-note]]34 [frq.screens.connect :as connect :refer [connect-screen error-note]]
34 ;; The conversation list moved out too, with the tab bar and the35 ;; The conversation list moved out too, with the tab bar and the
@@ -36,6 +37,9 @@
36 ;; phone renders this very file.37 ;; phone renders this very file.
37 [frq.screens.chats :refer [below-list chats-screen conversation-row38 [frq.screens.chats :refer [below-list chats-screen conversation-row
38 preview-line tab-bar]]39 preview-line tab-bar]]
40+ [frq.screens.chat :refer [chat-screen emoji-picker message-row
41+ reactor-dialog sidebar-width
42+ users-panel]]
39 [frq.state :as s]))43 [frq.state :as s]))
40 44
41 ;; ---------------------------------------------------------------- pieces45 ;; ---------------------------------------------------------------- pieces
@@ -49,14 +53,7 @@
49 ;; of names with the words hanging under them.53 ;; of names with the words hanging under them.
50 (def terminal-graphics? metrics/terminal-graphics?)54 (def terminal-graphics? metrics/terminal-graphics?)
51 55
52-(defn- terminal-face?56+(def ^:private terminal-face? metrics/terminal-face?)
53- "Whether a message carries a picture of its sender in a terminal.
54-
55- Not the same question as `terminal?`: the face is drawn where the protocol
56- for one is, and where it is not an `:image` is `[ picture ]` printed beside
57- every nick — worse than the nothing that is there now."
58- []
59- (and @terminal? @terminal-graphics?))
60 57
61 (defonce ^:private derived-cells58 (defonce ^:private derived-cells
62 ;; One cell per question, kept for the session: a cell made afresh on every59 ;; One cell per question, kept for the session: a cell made afresh on every
@@ -96,8 +93,6 @@
96 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so93 ;; drawn one at. A terminal's cell is eight points across and sixteen down, so
97 ;; the same number is four columns by two rows there: a cached 128-pixel94 ;; the same number is four columns by two rows there: a cached 128-pixel
98 ;; portrait at a quarter size, and about the smallest a face is still a face at.95 ;; portrait at a quarter size, and about the smallest a face is still a face at.
99-(def ^:private face-size 32)
100-
101 ;; ---------------------------------------------------------------- chats96 ;; ---------------------------------------------------------------- chats
102 97
103 ;; The way into a room. Standard rather than `:primary`: the theme paints a98 ;; The way into a room. Standard rather than `:primary`: the theme paints a
@@ -106,20 +101,6 @@
106 ;; rhythm down the list the one word that is the same on every row, with the101 ;; rhythm down the list the one word that is the same on every row, with the
107 ;; name that differs set quieter than it. Nothing moves; the button stops102 ;; name that differs set quieter than it. Nothing moves; the button stops
108 ;; shouting.103 ;; shouting.
109-(def ^:private sidebar-width 320)
110-
111-;; How tall a row of chrome is — a button, the compose bar, a line of tabs.
112-;;
113-;; The two reserves below are counted in points against this: a window's row is
114-;; 34 of them, and every gap around it was chosen at that size. A terminal's
115-;; row is one cell, and the same count then reserves two or three times the
116-;; room the strip under the list actually needs — which costs a message a row,
117-;; and a conversation is measured in how many of those fit.
118-;;
119-;; So the counts stay as they are, written where the reasoning is, and a
120-;; backend whose rows are a different height says so here. Nothing else in the
121-;; tree needs it: every other number is a length, and a length scales on the
122-;; way across.
123 (def ^:private window-row metrics/window-row)104 (def ^:private window-row metrics/window-row)
124 ;; Re-defined rather than moved out of reach: `frq.tui` resets this before its105 ;; Re-defined rather than moved out of reach: `frq.tui` resets this before its
125 ;; first paint and says `app/chrome-row` when it does.106 ;; first paint and says `app/chrome-row` when it does.
@@ -127,603 +108,8 @@
127 108
128 (def ^:private chrome-scale metrics/chrome-scale)109 (def ^:private chrome-scale metrics/chrome-scale)
129 110
130-(defn- chip-gap
131- "The air between two chips on a row.
132-
133- Four points is half a column, which rounds to none: in a window that is the
134- gap a pair of lozenges want, and in a terminal it puts two emoji hard against
135- each other and they read as one wide glyph. A column, where a column is the
136- smallest thing there is."
137- []
138- (if @terminal? 8 4))
139-
140-(defn- indented
141- "One of a message's columns, held in from the name above it — in a terminal.
142-
143- The nick is the heading and the words hang under it, which is the shape a
144- terminal has read a conversation in for forty years; in a window the column
145- of faces already marks that edge, so there is nothing to hold in.
146-
147- A cell of its own rather than a margin, because the backend insets a node by
148- `:margin` on all four sides or not at all: there is no left margin in a cell
149- grid, and a uniform one would spend a blank row above every message to buy
150- two columns beside it.
151-
152- The spacer asks for its width and no height. A `:size` is a length down the
153- page, and a spacer two cells tall beside an empty column — a message with no
154- picture and no reactions — is two blank rows in the middle of the backlog,
155- which is a message and a half at this size.
156-
157- Nothing to do where a face is drawn: the column that holds the portrait is
158- already to the left of the whole message, and a second indent inside it would
159- hold the words in from a name that is itself held in."
160- [k node]
161- (if (and @terminal? (not (terminal-face?)))
162- [:hbox {:key k :spacing 0}
163- [:spacer {:key :indent :size 0 :width-request 16}]
164- node]
165- node))
166-
167 ;; ---------------------------------------------------------------- chat111 ;; ---------------------------------------------------------------- chat
168 112
169-(def ^:private url-pattern #"https?://[^\s<>\"]+")
170-
171-(defn- trim-trailing-punctuation
172- "A URL at the end of a sentence would otherwise keep the sentence's
173- punctuation. A closing bracket only counts as trailing when the URL does not
174- open one itself, which is what keeps a wikipedia-style path intact."
175- [url]
176- (loop [u url]
177- (let [c (last u)]
178- (cond
179- (nil? c) u
180- (contains? #{\. \, \; \: \! \?} c) (recur (subs u 0 (dec (count u))))
181- (and (= \) c) (not (str/includes? u "("))) (recur (subs u 0 (dec (count u))))
182- :else u))))
183-
184-(defn text-runs
185- "Message text as alternating [:text s] and [:link url] runs.
186-
187- Runs because a link has to be its own widget to be clickable, and stacked
188- rather than laid out in a row because a wrapping label inside a horizontal
189- row lays out against the row's width, not the column's — which is what drags
190- long URLs off the left edge."
191- [text]
192- (let [text (or text "")]
193- (loop [pos 0 acc []]
194- (if-let [raw (re-find url-pattern (subs text pos))]
195- (let [url (trim-trailing-punctuation raw)
196- at (+ pos (str/index-of (subs text pos) raw))
197- before (subs text pos at)
198- acc (cond-> acc (seq before) (conj [:text before]))]
199- (recur (+ at (count url)) (conj acc [:link url])))
200- (let [tail (subs text pos)]
201- (cond-> acc (seq tail) (conj [:text tail])))))))
202-
203-(def ^:private text-emoji-size
204- "An emoji in a message, at the size of the words around it. Bigger and the
205- line it sits in grows to make room for it; smaller and it reads as a
206- footnote on the sentence rather than a word of it."
207- 14)
208-
209-(defn- word-node
210- "Body text, or dim text for the lines the client writes itself."
211- [k value system?]
212- (if system?
213- [:dim-label {:key k :label value}]
214- [:label {:key k :label value}]))
215-
216-(defn- run-node
217- "One run as a widget. A link is accent-coloured and opens on click; the rest
218- is body text.
219-
220- Except for the emoji in it. A label is set in the text font, and that font —
221- Ubuntu with a DejaVu subset behind it — has no emoji in it at all, so every
222- one anybody typed was drawn as tofu. The Twemoji pack has the picture, so
223- the emoji come out of the text and are drawn from the pack, and the words
224- either side stay text.
225-
226- `:emoji` and not `:reaction`, though both draw the same picture. A reaction
227- is a tally: it wears a pill, it answers the pointer, and it names the people
228- in it on hover. An emoji in a sentence is a character — so it is drawn bare,
229- and a message that ends in one no longer reads as a message someone has
230- reacted to.
231-
232- The pieces go in a wrapping row rather than the column the runs themselves
233- are stacked in: a row is what puts an emoji *in* a sentence instead of
234- breaking the sentence around it, and `:hbox` wraps its children by default,
235- so a long line still folds at the column's edge. A run with no emoji in it
236- is still one plain label — the row is only paid for where it is needed."
237- [j [kind value] system?]
238- (if (= :link kind)
239- [:link {:key j :label value :on-click #(platform/open-url! value)}]
240- (let [pieces (glyphs/runs (str/trim value))]
241- (if (glyphs/emoji? pieces)
242- ;; Runs alternate text and picture, so this gap only ever falls either
243- ;; side of an emoji — never between two words, whose spacing is the
244- ;; spaces the message was typed with. A bare glyph carries no margin of
245- ;; its own the way the pill did, and a picture set flush against a word
246- ;; reads as part of it.
247- [:hbox {:key j :spacing 2}
248- (map-indexed (fn [k [pkind pvalue]]
249- (if (= :emoji pkind)
250- [:emoji {:key k :emoji pvalue :size text-emoji-size}]
251- (word-node k pvalue system?)))
252- pieces)]
253- (word-node j (str/trim value) system?)))))
254-
255-(defn- summarise
256- "A message in one line's worth of words."
257- [m limit]
258- (let [text (str/replace (or (:text m) "") #"\s+" " ")]
259- (if (> (count text) limit)
260- (str (subs text 0 (dec limit)) "…")
261- text)))
262-
263-(defn- goto-message!
264- "Show message `id`, in `channel`, and say which one it was.
265-
266- Three things in a fixed order: be in the room, aim the scroll at the line,
267- and mark it once it is there. `jump-to` comes off again as soon as the frame
268- that scrolled has been painted — a scroll target that stays set pins the
269- view and takes scrolling away from the reader — while the highlight outlives
270- it, because arriving at a screenful of messages says nothing about which one
271- was asked for.
272-
273- `settle` is how long that first frame is given, and `linger` how long the
274- mark stays. Within a room the scrolling frame is the next one and the line
275- is somewhere the reader was already looking. Crossing a room is a slower
276- and a stranger arrival — the screen has to be built before there is
277- anything to scroll, and what it opens on is a conversation the reader was
278- not in a moment ago — so a caller that crosses one asks for more of both."
279- [channel id settle linger]
280- (when (and channel (not= channel @s/current))
281- (s/open-channel! channel))
282- (reset! s/jump-to id)
283- (reset! s/highlight id)
284- (platform/after! settle (fn [] (reset! s/jump-to nil)))
285- ;; The highlight only clears itself: a later jump elsewhere owns the
286- ;; highlight from then on.
287- (platform/after! linger (fn [] (when (= id @s/highlight)
288- (reset! s/highlight nil)))))
289-
290-(defn- reply-chip
291- "What a message is replying to, above it, and a way back to it.
292-
293- The chip carries the quote rather than only a marker: a reply is unreadable
294- without knowing what it answers, and the message it answers is usually off
295- the top of the screen. Clicking takes you there."
296- [channel id]
297- [:vbox {:key :reply}
298- (if-let [target (s/message-by-id channel id)]
299- ;; A link, not a button: the chip is a pointer back to a line, not an
300- ;; action, and a filled pill above every answer was the loudest thing in
301- ;; the column.
302- [:link {:label (str "↩ " (:from target) ": " (summarise target 48))
303- ;; The line being answered is in the room already open, so the
304- ;; next frame is the one that scrolls.
305- :on-click #(goto-message! channel id 120 2000)}]
306- ;; The message it answers is older than this buffer goes.
307- [:dim-label {:label "↩ replying to an earlier message"}])])
308-
309-(def ^:private pill-size
310- "How big a reaction is under a message. Small: it is a footnote on what was
311- said, not a second thing said."
312- 14)
313-
314-(defn- action-chips
315- "Answering and reacting, on the sender's row above the message.
316-
317- Both are things done *to* a message rather than parts of it, so they ride
318- the sender's row against its right edge, at the size a reaction is. In the
319- line with the text they took width off every line under them and wrapped a
320- message that had the room to sit on one.
321-
322- Chips rather than buttons: a button is sized for a label and these carry a
323- glyph, which made two lozenges taller than the line they belonged to.
324- `:reaction` with no count is the same pill the picker offers, so what you
325- press to react and what appears once you have look like one family.
326-
327- A row laid out from the right lays its first child furthest right, so
328- reacting comes first in the source and this reads ✏️ then ↩️ then 🙂 on
329- screen — where there is a pencil at all. Only our own lines carry one: the
330- server refuses an edit of somebody else's, and a chip that always fails is a
331- chip that lies about what can be done.
332-
333- The pencil is a chip beside the other two rather than a box holding one: a
334- wrapper is laid out as a child in its own right, which put the pencil on the
335- row at a remove from the pair it belongs with. It keys itself, so the line
336- that has no pencil is a row of two chips and not a row with a hole in it."
337- [channel m]
338- (into [:hbox {:key :actions :align :end :spacing (chip-gap)}
339- [:reaction {:key :react
340- :emoji "🙂"
341- :size pill-size
342- :on-click #(s/open-picker! channel m)}]
343- [:reaction {:key :reply
344- :emoji "↩️"
345- :size pill-size
346- :on-click #(s/reply-to! m)}]]
347- (when (s/mine? m)
348- [[:reaction {:key :edit
349- :emoji "✏️"
350- :size pill-size
351- :on-click #(s/start-edit! channel m)}]])))
352-
353-(defn- reactor-card
354- "Who is on a reaction, beside the pointer resting on it.
355-
356- The pill carries a number, and a number is the one thing about a reaction
357- nobody wants: who it was is what a room is read for. Names, one to a line,
358- with your own said as \"you\" — you are the one name in the list you cannot
359- learn anything from."
360- [emoji nicks]
361- [:vbox {:spacing 4}
362- [:hbox {:spacing 6}
363- [:reaction {:key :glyph :emoji emoji :size pill-size}]
364- [:dim-label {:label (str (count nicks)
365- (if (= 1 (count nicks)) " reaction" " reactions"))}]]
366- [:vbox {:key :who :spacing 2}
367- (for [nick (sort nicks)]
368- [:label {:key nick
369- :label (if (= nick @s/form-nick) "you" nick)}])]])
370-
371-(defn- reaction-row
372- "What people have put on a message, under it.
373-
374- A pill carries its count and toggles: clicking one you are already on takes
375- yours off, which is the same gesture that put it there. `:reaction` rather
376- than a button with the emoji as its label — the chip draws the glyph from the
377- Twemoji pack, in colour, where a label gets whatever the text font has."
378- [channel m]
379- (let [reactions (:reactions m)]
380- ;; `into` and not a lazy `for` inside the vector. The pills read
381- ;; `my-reaction?` — a ratom — and a ratom read while a lazy seq is being
382- ;; realised somewhere other than the render is a read the component never
383- ;; records, so the row went on showing what it showed before. The pictures
384- ;; in `message-body` are the other place a ratom is read inside a `for`,
385- ;; and are made eager for the same reason.
386- (into
387- [:hbox {:key :pills :spacing (chip-gap)}]
388- (for [emoji (sort (keys reactions))]
389- [:reaction (cond-> {:key emoji
390- :emoji emoji
391- :size pill-size
392- :count (count (get reactions emoji))
393- :mine (s/my-reaction? m emoji)
394- :on-click #(s/toggle-reaction! channel m emoji)}
395- ;; Where there is a pointer to ask with, resting on a pill
396- ;; says who put it there. On a phone the pill is a button
397- ;; and nothing more: there is no hover to answer.
398- (platform/desktop?)
399- ;; Nothing is hung under the pill any more: who is on a
400- ;; reaction is shown the way everything else the pointer
401- ;; asks for is shown now — the dialog at the root of the
402- ;; tree, which `reactor-dialog` builds out of
403- ;; `reaction-hover`. The pill's only job is to say where
404- ;; the pointer is.
405- (assoc :on-hover #(s/hover-reaction! (:id m) emoji)
406- :on-unhover #(s/unhover-reaction! (:id m) emoji)))]))))
407-
408-(def ^:private picker-columns
409- "Emoji to a row, at the picker's own size. Narrow enough that the grid fits a
410- phone-width window, which is the width this app is laid out for."
411- 9)
412-
413-(def ^:private picker-size 20)
414-
415-;; A row of chips, and the most the grid may take before it scrolls.
416-
417-
418-(defn emoji-picker
419- "The whole set to choose from, under the message it is for.
420-
421- It opens in the list itself, directly under the line it was opened on:
422- choosing a reaction is something done *to* a message, and the message has to
423- stay in front of the reader while it is chosen — which is not something a
424- panel at the bottom of the window, or a screen of its own, can promise.
425-
426- It opens on the emoji people actually react with; the groups and the search
427- box are for the rest."
428- []
429- (let [shown (s/picker-emoji)
430- over (max 0 (- (count shown) s/picker-limit))
431- rows (partition-all picker-columns (take s/picker-limit shown))
432- searching? (seq (str/trim @s/emoji-search))]
433- [:vbox {:key :picker :spacing 4}
434- [:hbox {:spacing 6}
435- [:entry {:text @s/emoji-search
436- :width-request 240
437- :placeholder "Search emoji"
438- :on-change #(reset! s/emoji-search %)}]
439- [:button {:label "✕" :on-click s/close-picker!}]]
440- ;; The groups are what the search box is not: a way in for someone who has
441- ;; no word for what they want. Their first word is enough to tell them
442- ;; apart, and is what keeps them to two rows. They give way to the
443- ;; search's own answer while something is typed.
444- [:vbox {:key :groups :spacing 4}
445- (when-not searching?
446- (for [[i row] (map-indexed vector (partition-all 5 (cons nil s/emoji-groups)))]
447- [:hbox {:key i :spacing 4}
448- (for [g row]
449- [:button {:key (or g "popular")
450- :label (if g (first (str/split g #" ")) "Popular")
451- :kind (if (= g @s/emoji-group) :primary :normal)
452- :on-click #(reset! s/emoji-group g)}])]))]
453- ;; The grid itself, not a scroll around it: the picker sits inside the
454- ;; message list, and a scroll within a scroll takes the wheel away from
455- ;; the conversation it is standing in. What it shows is bounded instead,
456- ;; and the search box is how you reach past that.
457- [:vbox {:key :grid :spacing 2}
458- (if (seq rows)
459- (for [[i row] (map-indexed vector rows)]
460- [:hbox {:key i :spacing 2}
461- (for [[glyph] row]
462- [:reaction {:key glyph
463- :emoji glyph
464- :size picker-size
465- :count 0
466- :on-click #(s/react-from-picker! glyph)}])])
467- [:dim-label {:label "No emoji by that name."}])]
468- ;; What was left out, said rather than silently dropped.
469- [:vbox {:key :more}
470- (when (pos? over)
471- [:dim-label {:label (str "and " over " more — keep typing to narrow it")}])]]))
472-
473-(defn- preview-height
474- "How tall a picture in the conversation may be.
475-
476- Against the window rather than a fixed 260: on a phone-sized window a
477- preview that tall is the whole screen, and on a laptop one it is a stamp of
478- something worth looking at.
479-
480- Four fifths of the window, because the pictures that arrive here are mostly
481- phone screenshots — 1080x2400, whose height is what binds, and any smaller
482- share draws them as a strip too narrow to read a word of. A picture is what
483- the message is; the line above it is enough context to place it, and the
484- scroll is how the rest is reached.
485-
486- Clamped at both ends, and falling back to the old fixed height until the
487- first poll has landed: a preview is a preview either way, and the lightbox
488- is what full size is for.
489-
490- A terminal gets less of its screen, and a hard ceiling on top of that. Four
491- fifths of a window is a picture you look at; four fifths of a terminal is a
492- conversation with one message in it, and the rows a picture takes are rows
493- that cannot hold a line. Twelve of them is enough to see what a picture is
494- of, and to decide whether to open it."
495- []
496- (let [h @s/window-height]
497- (cond
498- (not (pos? h)) 260
499- @terminal? (max 80 (min 192 (long (* 0.4 h))))
500- :else (min 900 (max 240 (long (* 0.8 h)))))))
501-
502-(defn- preview-width
503- "How wide a picture in the conversation may be.
504-
505- The height alone leaves a wide, short picture to take the column and push
506- the words around it; this holds it inside the conversation the same way.
507- Fitted, never stretched — `:image` scales to whichever bound it meets first."
508- []
509- (let [w @s/window-width]
510- (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))
511-
512-(defn- message-body
513- "A message without its face: the sender's line, the words, and what hangs
514- under them.
515-
516- Its own component because a terminal that draws pictures puts the face
517- beside all of this rather than on the sender's line — the shape the window
518- has always had, and the one that spends no row on the half of a portrait
519- that is taller than a line of text."
520- [m highlit?]
521- ;; A key each: the surface a jump leaves behind is a different node from the
522- ;; plain column, not the same node wearing another tag.
523- [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
524- :spacing 2 :margin 0}
525- ;; Who, when, and what can be done about it, on one line above the
526- ;; words: the name and the time say where the message came from, and the
527- ;; chips at the far end are the two answers to it.
528- [:vbox {:key :who}
529- ;; An event has no sender to head it, but it still happened at a time,
530- ;; and a column of joins and quits with no clock on it says nothing
531- ;; about how long the room was quiet. The time alone is that heading.
532- (when (and (:system? m) (:at m))
533- [:dim-label {:label (clock/clock-time (:at m))}])
534- (when-not (:system? m)
535- ;; Everything that is about the person rather than the line: their
536- ;; picture, their name, and when they started saying this.
537- [:hbox {:spacing 6}
538- ;; Always an avatar, picture or not: the initial stands in until the
539- ;; fetch lands, and for the guests who have no profile at all, which
540- ;; is what keeps the column of faces straight down the left.
541- ;; `avatar-path` is what wakes this row when that fetch lands.
542- ;; The face is also the way to the person behind it: Vidya's plain
543- ;; label does not answer the pointer, so the tap sleek puts on the
544- ;; nick lives here, on the one thing in the row that does.
545- ;; And, where there is a pointer to ask with, resting on the face
546- ;; answers before the click does: the card under it is the profile
547- ;; screen's first few lines, painted beside the pointer.
548- ;; And no face on this line in a terminal: the initial that stands in
549- ;; for a portrait in a window is a letter printed twice in a cell grid,
550- ;; which only pushed every nick in past the words it heads. Where the
551- ;; terminal can draw a picture there is a face after all, hung beside
552- ;; the whole message rather than off its heading — `message-row` has it.
553- (when-not @terminal?
554- (let [src @(avatar-path (:actor m))]
555- ;; One profile, two gestures, and no card hung under the face: the
556- ;; pointer opens the dialog and the press pins it. What makes that
557- ;; work is the dialog being non-modal while the pointer is what is
558- ;; holding it open — see `profile-dialog`.
559- [:avatar (cond-> {:label (:from m)
560- :src (or src "")
561- :size face-size
562- :on-click #(profile/open! (:from m) (:actor m))}
563- (platform/desktop?)
564- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
565- :on-unhover #(profile/unhover! (:from m))))]))
566- ;; The name carries the row, so it is set at body size in the plain
567- ;; text colour: dimmed caption made the one thing you scan a column
568- ;; for the faintest thing on it.
569- [:label {:label (:from m)}]
570- (when-let [at (:at m)]
571- [:dim-label {:label (clock/clock-time at)}])
572- ;; Beside the clock, because it is the same kind of fact about the
573- ;; line: what is on screen is not what was first said.
574- (when (:edited? m)
575- [:dim-label {:label "(edited)"}])
576- ;; And the two things you can do to the message, at the far end of
577- ;; its heading: a nested row laid out from the right takes what is
578- ;; left of the width and puts the chips against the edge of it, so
579- ;; the words below keep the whole column.
580- ;;
581- ;; A key of its own for each branch, rather than one key whose node
582- ;; changes tag under it: a line this client sent has no msgid until
583- ;; the server echoes it back, and swapping a `:spacer` for a chip in
584- ;; place is what left the row rebuilt in the wrong order.
585- (if (:id m)
586- [action-chips {:key :actions} @s/current m]
587- [:spacer {:key :actions-gap :size 0}])])]
588- ;; A little air under the sender's row: the name and face are a heading
589- ;; for what follows, and a heading that touches its text is not one.
590- ;; The text keeps a column of its own: a wrapping label directly in a
591- ;; row wraps against the row.
592- (indented
593- :text
594- [:vbox {:key :text :spacing 2 :margin-top 4}
595- ;; What this answers, in the column the answer itself is in: the chip
596- ;; is a quote of a line, so it wraps to the width a line has. Outside
597- ;; this column it wrapped to the whole row instead — wider than any
598- ;; message, and out to the window's edge on a narrow screen.
599- [:vbox {:key :reply-chip}
600- (when-let [reply-to (:reply-to m)]
601- [reply-chip @s/current reply-to])]
602- (map-indexed (fn [j run] (run-node j run (:system? m)))
603- (text-runs (:text m)))])
604- ;; And the picker, when this is the message it was opened on: under the
605- ;; line it is about, where the reader is already looking.
606- ;;
607- ;; The id has to exist, not merely match: a line this client sent itself
608- ;; has no msgid, and neither does a closed picker — so `nil = nil` was
609- ;; every one of those messages opening a picker of its own at startup.
610- [:vbox {:key :picker :margin-bottom 4}
611- (when (and (:id m) @(derived [:reacting (:id m)] #(= (:id m) (:id @s/reacting))))
612- [emoji-picker])]
613- ;; Pictures under the line that linked them. The link stays: it is what a
614- ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
615- (indented
616- :images
617- [:vbox {:key :images :spacing 4}
618- (when (seq (:images m))
619- ;; Eager, so each read happens during the render and is recorded — see
620- ;; `reaction-row`. Each picture is read through `image-path`, which
621- ;; wakes this row for its own pictures landing and not for everyone's.
622- (doall
623- (for [url (:images m)]
624- (when-let [path @(image-path url)]
625- [:image {:key url
626- :src path
627- :max-height (preview-height)
628- :max-width (preview-width)
629- :on-click #(reset! s/lightbox {:path path :url url})}]))))])
630- ;; Reactions go last, under whatever the message turned out to be: a
631- ;; line with a picture on it is the picture, and pills between the words
632- ;; and the image they introduce read as reactions to the words alone.
633- ;; Nothing moves for a message with no picture — the box above it is
634- ;; empty, and the pills still sit under the last line of text.
635- (indented
636- :reactions-row
637- [:vbox {:key :reactions-row :margin-top 6}
638- (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
639- [reaction-row @s/current m])])])
640-
641-(defn message-row
642- "One message: who said it, when, what you can do to it, and the words.
643-
644- Every line names its sender, rather than the first of a run only. A run
645- collapsed to one heading reads well until you answer the fourth line of it,
646- and then the line quoted back has no name on it; and the actions live on the
647- sender's row, which a headerless line has nowhere to put.
648-
649- Sender above the text, not beside it: a wrapping label in a horizontal row
650- lays out against the row's width rather than the column's, so one long URL
651- drags every line that follows it off the left edge."
652- [i m]
653- (let [;; What a jump landed on wears a surface of its own for a moment, so
654- ;; the answer to "which one was I sent to" is on the screen rather
655- ;; than in the reader's count of rows.
656- ;; Through `derived`, as is everything below that asks about shared
657- ;; state: a row that read the highlight itself re-rendered for every
658- ;; jump anywhere in the backlog.
659- highlit? (boolean (and (:id m)
660- @(derived [:highlit (:id m)]
661- #(= (:id m) @s/highlight))))]
662- [:vbox {:key i :spacing 2 :margin 0
663- ;; Clear of the right edge: the actions ride that edge, and the
664- ;; list's scrollbar rides it too — without this the ↩ is what the
665- ;; scrollbar is drawn over.
666- :margin-right 10
667- ;; One gap between messages: every line carries its own heading
668- ;; now, so there are no runs to hold together and nothing for a
669- ;; closed-up line to belong to.
670- :margin-top 10
671- ;; The jump target is what a "go to message" click scrolls to.
672- :scroll-here (boolean (and (:id m)
673- @(derived [:jump (:id m)]
674- #(= (:id m) @s/jump-to))))}
675- ;; The gap above, where the margin cannot be one. `:margin-top 10` is
676- ;; most of a row in a window and nothing at all in a terminal — ten points
677- ;; against a row of sixteen, rounded down, because a gap that thin is what
678- ;; it looks like at this size. So the terminal buys the row outright:
679- ;; without it every message in the backlog touches the one above it, and a
680- ;; conversation reads as one paragraph with names scattered through it.
681- (when @terminal? [:spacer {:key :gap :size 16}])
682- ;; The face beside the message, where the terminal can draw one: a
683- ;; portrait is two rows tall and a heading is one, so hanging it off the
684- ;; sender's line would leave its lower half beside a blank row. Here the
685- ;; words take that row, and the column the face makes is the indent every
686- ;; other row of the message is held in by — `indented` steps aside for it.
687- (if (terminal-face?)
688- [:hbox {:key :faced :spacing 8}
689- [:vbox {:key :face :width-request face-size}
690- (when-let [path @(avatar-path (:actor m))]
691- [:image {:key :picture
692- :src path
693- :max-width face-size
694- :max-height face-size}])]
695- [message-body m highlit?]]
696- [message-body m highlit?])]))
697-
698-(defn- day-separator [day-key label]
699- [:vbox {:key day-key :spacing 4 :margin 0}
700- [:separator {}]
701- [:dim-label {:label label}]])
702-
703-(defn- message-rows
704- "The messages, with a heading wherever the day changes.
705-
706- A backlog can reach back weeks, and `11:04 AM` says nothing about which day
707- it was. The heading is what makes the time above it mean something."
708- [messages]
709- (mapcat (fn [i m]
710- (let [prev (when (pos? i) (nth messages (dec i)))
711- day (some-> (:at m) clock/day)
712- new-day? (and day (not= day (some-> (:at prev) clock/day)))]
713- (cond-> []
714- new-day? (conj (day-separator (str "day-" i) (clock/day-label (:at m))))
715- ;; A new day breaks the run: the first line under a heading
716- ;; names its sender and its time, whoever spoke last night.
717- ;; Keyed by the line itself, not by where it sits: the list is
718- ;; reconciled by position when its children are not all keyed,
719- ;; and a line that arrives anywhere but the end — a backlog
720- ;; replay, an echo taking the place of what was sent — shifts
721- ;; every row after it onto the widgets of its neighbour.
722- true (conj ^{:key (or (:id m) (str "row-" i))}
723- [message-row i m]))))
724- (range (count messages))
725- messages))
726-
727 (defn- profile-dialog113 (defn- profile-dialog
728 "Who someone is, as libcosmic's own dialog: centred over the window, with114 "Who someone is, as libcosmic's own dialog: centred over the window, with
729 what you were reading dimmed behind it rather than replaced.115 what you were reading dimmed behind it rather than replaced.
@@ -815,25 +201,6 @@
815 :sensitive (boolean url)201 :sensitive (boolean url)
816 :on-click #(when url (platform/open-url! url))}]]))202 :on-click #(when url (platform/open-url! url))}]]))
817 203
818-(defn- reactor-dialog
819- "Who is on the reaction the pointer is resting on.
820-
821- The same dialog a face gets, for the same reason: two hover cards in two
822- shapes was two things to look at and two things to keep working. Non-modal
823- and no buttons, because the pointer is what is holding it open a modal one
824- would go deaf to the pill leaving and never close, and a button in it could
825- never be reached.
826-
827- The message is looked up rather than carried: a pill says which message and
828- which emoji it is, and the room it is in is the one being read a pill in
829- any other room is not under a pointer."
830- []
831- (let [{:keys [id emoji]} @s/reaction-hover
832- nicks (get (:reactions (s/message-by-id @s/current id)) emoji)]
833- (when (seq nicks)
834- [:dialog {:label "Reactions" :max-width 320 :modal false}
835- [reactor-card emoji nicks]])))
836-
837 (defn profile-screen204 (defn profile-screen
838 "Who someone is: sleek's peer profile modal, as a screen.205 "Who someone is: sleek's peer profile modal, as a screen.
839 206
@@ -920,118 +287,6 @@
920 [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]287 [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]]
921 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))288 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
922 289
923-(defn- call-tile
924- "One participant's picture, at the width the row worked out for it.
925-
926- `:feed` rather than `:src`: these pixels never touch the disk and never
927- become a value here — the media plane hands the decoder's own buffer to
928- Vidya as a pointer, and the tag paints whatever arrived last under that name.
929-
930- The height is three quarters of the width, which is the shape a camera hands
931- over. Naming both keeps a portrait phone from making its tile tall enough to
932- push the row off the screen — the picture is fitted inside, never stretched."
933- [width key]
934- (let [mine? (= av/local-feed key)]
935- [:vbox {:key key :spacing 2}
936- ;; `:upscale` because a tile is a slot the layout sized, not a picture
937- ;; sitting at whatever the camera happened to send. Without it a 480-wide
938- ;; stream draws 480 wide in a 900-point slot and the wall looks broken —
939- ;; which is exactly what it did.
940- [:image {:feed key
941- :upscale true
942- :max-width width
943- :max-height (long (* width 0.75))}]
944- [:dim-label {:label (if mine? "You" key)}]]))
945-
946-(defn- call-wall
947- "Everyone with a camera on, sized to the window they are being watched in.
948-
949- A call with no video is the normal case and should look like one — a row of
950- empty frames would suggest something had failed to load.
951-
952- Both cells this reads are what subscribe it: `av/tiles` for who is on
953- screen, and the window width so the tiles follow a window being dragged.
954- Without either it would lay itself out once, on the first frame, and keep
955- that shape for the rest of the call."
956- []
957- (let [[width rows] (av/tile-rows)]
958- [:vbox {:key :call-wall :spacing 6}
959- ;; A seq, not a vector: children splice, and a vector would be read as one
960- ;; more hiccup element — which an empty one is not.
961- (for [[i keys] (map-indexed vector rows)]
962- [:hbox {:key i :spacing 8}
963- (for [key keys]
964- [call-tile width key])])]))
965-
966-(defn- call-controls
967- "What the person in a call can do about it.
968-
969- Mute and deafen are separate buttons because they are separate things: a
970- deafened microphone still carries your voice, and one control for both would
971- make the quieter of the two a surprise."
972- []
973- (let [{:keys [muted? speaker-muted? camera? has-camera? has-mic? media]} @av/local-call]
974- [:vbox {:key :call-controls :spacing 6}
975- [:hbox {:spacing 8}
976- [:label {:label (case media
977- :waiting "Asking to join…"
978- :dialling "Connecting…"
979- :live "In call"
980- :failed "Call failed"
981- "In call")}]
982- ;; A microphone that is not there is worth saying so: the call works,
983- ;; and the person is listening rather than silent by choice.
984- (when (and (= :live media) (not has-mic?))
985- [:dim-label {:label "· listening only"}])]
986- [:hbox {:spacing 8}
987- [:button {:label (if muted? "Unmute" "Mute")
988- :on-click #(av/set-muted! (not muted?))}]
989- [:button {:label (if speaker-muted? "Undeafen" "Deafen")
990- :on-click #(av/set-speaker-muted! (not speaker-muted?))}]
991- ;; Only offered when there is a camera to turn on. Nothing is more
992- ;; annoying than a control that does nothing and does not say why.
993- (when has-camera?
994- [:button {:label (if camera? "Stop video" "Start video")
995- :on-click #(av/set-camera! (not camera?))}])
996- [:button {:label "Leave" :on-click #(s/leave-call!)}]]
997- (when-let [e @av/media-error]
998- [:dim-label {:label (str "⚠ " e)}])]))
999-
1000-(defn call-bar
1001- "The call in this channel, whatever state it is in. Always a node.
1002-
1003- Three cases, and the empty one matters as much as the others: a channel with
1004- no call must render *something* here, because the reconciler matches children
1005- by position and a banner that came and went would patch the message list into
1006- a button."
1007- [channel]
1008- [:vbox {:key :call-bar :spacing 6}
1009- (cond
1010- (av/in-call? channel)
1011- [:card {}
1012- [:vbox {:spacing 6}
1013- [call-controls]
1014- [call-wall]]]
1015-
1016- ;; A call is open in this room and we are not in it.
1017- (av/call-in channel)
1018- (let [{:keys [session-id participants title]} (av/call-in channel)]
1019- [:card {}
1020- [:hbox {:spacing 8}
1021- [:label {:label (str "📞 " (or title "Call in progress")
1022- (if (and participants (pos? participants))
1023- (str " · " participants)
1024- ""))}]
1025- [:button {:label "Join"
1026- :on-click #(s/join-call! channel session-id)}]]])
1027-
1028- ;; We are in a call, but in a different room. Say which, since the
1029- ;; controls are not on this screen to be found by looking.
1030- (av/in-call?)
1031- [:dim-label {:label (str "In a call in " (:channel @av/local-call))}]
1032-
1033- :else nil)])
1034-
1035 (defn image-picker-screen290 (defn image-picker-screen
1036 "The pictures on this device, to send one of.291 "The pictures on this device, to send one of.
1037 292
@@ -1091,510 +346,6 @@
1091 ;; be told to leave. A column with `:fill-height` and no width takes the whole346 ;; be told to leave. A column with `:fill-height` and no width takes the whole
1092 ;; row — so the panel is only ever on screen if the message list is given a347 ;; row — so the panel is only ever on screen if the message list is given a
1093 ;; width that stops short of it.348 ;; width that stops short of it.
1094-(def ^:private users-width 150)
1095-
1096-;; How many lines the compose field is currently drawn as. The window backend
1097-;; says so when it changes — a field with room to grow wraps a long message
1098-;; onto a second and third line instead of sliding it sideways — and the
1099-;; reserve below has to hear about it, since nothing in this tree is laid out
1100-;; by anything but these counts.
1101-(defonce ^:private draft-rows (atom 1))
1102-
1103-;; What the rows under the conversation need left to them: the jump button's
1104-;; row, the separator, the compose bar and the air around it. The columns of
1105-;; the row reserve it, and so nothing inside them has to — a `:scroll` that
1106-;; subtracted it again inside a column already stopped short would take the
1107-;; same points off the list twice.
1108-;;
1109-;; Plus the reply banner's row when there is one, and the pasted picture when
1110-;; there is one. Those rows do move the compose bar, and should: each appears
1111-;; because the reader just asked for something — unlike the jump button, which
1112-;; appears on its own and must not shift what is under it.
1113-;; What the overview strip costs the conversation above it, in points: a row
1114-;; for its heading, a row a line, and the separator and gaps around them. The
1115-;; backlog's `:reserve` takes this too, so the strip is paid for out of the
1116-;; conversation rather than pushing the compose bar off the bottom — which is
1117-;; what every other row under the message list has had to say for itself.
1118-;;
1119-;; Counted from the lines there actually are, not from the ceiling: the strip
1120-;; holds at most `overview-lines` of them there, and a room with two lines in
1121-;; it should not have a screenful reserved against it.
1122-;;
1123-;; The window reserves nothing for the overview. Its pane scrolls and fills,
1124-;; so it takes its half of the column by being in it — there is no number to
1125-;; subtract, and subtracting one would take the room twice.
1126-(defn- overview-height []
1127- (* (chrome-scale)
1128- (+ 24 (* 20 (min s/overview-lines (count (s/recent-everywhere)))) 16)))
1129-
1130-(defn- below-messages []
1131- ;; 115 is that, counted: the gap under the row of columns, the jump button's
1132- ;; 34pt row, the banners in their one wrapper with one gap, then the compose
1133- ;; row and the air under it — its own and the window's. Short by any of it
1134- ;; and the column runs past the bottom edge, which does not show as a list
1135- ;; that is too long: it shows as a compose bar sitting flat on the bottom of
1136- ;; the window with its margin cut off.
1137- ;;
1138- ;; It was 140 when a rule was drawn above the compose bar and the three
1139- ;; banners each cost the column a gap of their own. The rule went, and they
1140- ;; went into one wrapper: twenty-five points of nothing, given back.
1141- (+ (* (chrome-scale)
1142- (+ 115
1143- (if @s/replying-to 34 0)
1144- (if @s/attachment 76 0)))
1145- ;; The two extra rows the terminal's compose field wraps into, in points:
1146- ;; a row down the page is two cells' worth of the scale.
1147- (if @terminal? (* 4 (chrome-scale)) 0)
1148- ;; And the lines a window's compose field has GROWN by. Unscaled and
1149- ;; outside the terminal's arm on purpose: the field only reports its
1150- ;; height where it can change — the terminal's is the fixed block of
1151- ;; three rows reserved just above — so `draft-rows` is one there and
1152- ;; this term is zero. Twenty points a line is a 16-point face's line
1153- ;; box rounded up: the reserve has to be at least what the field took,
1154- ;; because a point too few does not crop the list, it slides the
1155- ;; compose bar off the bottom of the window.
1156- (* 20 (dec @draft-rows))
1157- ;; And the overview strip, when it is up. Reserved here rather than
1158- ;; anywhere else because this is the number the backlog is laid out
1159- ;; against: without it the strip is drawn past the bottom of the window
1160- ;; and takes the compose bar with it.
1161- (if (and @terminal? @s/overview?) (overview-height) 0)))
1162-
1163-(defn- messages-width
1164- "How wide the message list may be with the people panel beside it.
1165-
1166- Measured from the window rather than from what egui has left: the row is
1167- painted left to right, and by the time the panel is placed the list has
1168- already taken everything. On a wide window the chats list is holding the
1169- first `sidebar-width` of the window; the rest is the margins and the gap
1170- between the two columns."
1171- []
1172- (let [pane (- @s/window-width (if (and (s/wide?) (not @s/hide-chat-list?))
1173- sidebar-width
1174- 0))]
1175- (max 240 (- pane users-width 8 28))))
1176-
1177-(defn- member-row [{:keys [nick prefix]}]
1178- ;; The mode where there is one, dim: it says how someone is listed, not who
1179- ;; they are, and the name is what the eye is scanning for. A space where
1180- ;; there is none, rather than nothing at all: the label is always in the row,
1181- ;; so every name starts at the same place — and a column wrapped around a
1182- ;; label that comes and goes is a column, which sits its text against the top
1183- ;; of the row rather than on the line the name is on.
1184- ;;
1185- ;; The name is a button because a person is somewhere to go: pressing one
1186- ;; opens a conversation with them.
1187- [:hbox {:key nick :spacing 6 :wrap false}
1188- ;; In a slot of its own width, so an "@" and a space take the same room and
1189- ;; the names line up whatever mode is in front of them.
1190- [:vbox {:key :mode :width-request 14}
1191- [:dim-label {:label (if (seq prefix) prefix " ")}]]
1192- [:button {:label nick :on-click #(s/open-dm! nick)}]])
1193-
1194-(defn users-panel
1195- "Who is in the channel, beside the conversation.
1196-
1197- The list is the server's NAMES on the way in, kept up by the joins and
1198- parts after it so a channel this client has never been in has nothing to
1199- show, and says so rather than showing an empty column."
1200- [name]
1201- (let [people (s/member-list name)]
1202- [:vbox {:key :users :width-request users-width :fill-height true
1203- :reserve (below-messages) :spacing 8}
1204- [:title-2 {:label (str "People " (count people))}]
1205- [:scroll {:scroll-key (str "users-" name)
1206- :orientation :vertical}
1207- (if (seq people)
1208- ;; Keyed, for the reason the conversation list is: this list reorders
1209- ;; as people come and go, and a name is a button someone can be
1210- ;; standing on.
1211- (for [p people] ^{:key (:nick p)} [member-row p])
1212- [:dim-label {:label "Nobody listed yet."}])]]))
1213-
1214-(defn- messages-scroll-key
1215- "What the backlog's scroll position is remembered under.
1216-
1217- One name in a window: `:scroll-to-bottom` is how the jump button is answered
1218- there, and the position under that name is the one the reader left behind.
1219-
1220- The terminal backend has no `:scroll-to-bottom` a viewport there is moved
1221- by the wheel and the page keys and by nothing else but it does open a
1222- sticky viewport it has never seen at the bottom, which is the same thing
1223- said differently. So a jump renames the viewport: the tick that asks the
1224- window to scroll gives the terminal a name with no position saved under it,
1225- and the newest line is what it opens on.
1226-
1227- Only on a jump, so scrolling and every message that arrives between two
1228- jumps still find the position where they left it."
1229- []
1230- (if @terminal?
1231- (str "chat-messages-" @s/jump-tick)
1232- "chat-messages"))
1233-
1234-(defn- policy-note
1235- "What this channel wants agreed to, and the one button that agrees to it.
1236-
1237- Above the backlog rather than in it: the refusal is already a line in the
1238- buffer, and a button scrolled away with last week's messages is a button
1239- nobody finds. The rules come from the server; when it has none to give, its
1240- own words about that are what shows."
1241- [name buffer]
1242- (when (:policy-required? buffer)
1243- [:vbox {:spacing 4}
1244- [:dim-label {:label (str name " asks you to accept its policy before joining.")}]
1245- (for [[i line] (map-indexed vector (:policy-text buffer))]
1246- ^{:key (str "policy-" i)} [:dim-label {:label line}])
1247- [:button {:label "Accept policy" :kind :primary
1248- :on-click #(s/accept-policy! name)}]]))
1249-
1250-(defn- overview-row
1251- "One line from somewhere else: which room, who, and what on one row.
1252-
1253- The room leads, because that is the whole question the strip answers. The
1254- name is a button for the same reason it is one in the people panel: the
1255- room is somewhere to go, and a line you want more of is a line you want the
1256- conversation behind it."
1257- [i m]
1258- [:hbox {:key i :spacing 8 :wrap false}
1259- ;; The room is the button, and pressing it goes to this line rather than to
1260- ;; the room's end: the line is what was read here and what the press was
1261- ;; about, and a jump that landed on the newest message instead would answer
1262- ;; a question nobody asked from a strip that was showing the answer.
1263- ;;
1264- ;; Unless the line has no id to aim at — one this client has sent and the
1265- ;; server has not echoed back yet — and then the room is all there is to
1266- ;; offer.
1267- ;;
1268- ;; 600ms to land and five seconds marked: the room is a room away, so the
1269- ;; screen it scrolls in has to be built before there is anything to scroll,
1270- ;; and what the reader arrives at is a conversation they were not in a
1271- ;; moment ago. The mark is the whole answer to "which of these was the line
1272- ;; I pressed", and it has to still be there when they have finished
1273- ;; recognising where they are.
1274- [:button {:label (:channel m)
1275- :on-click (if (:id m)
1276- #(do (s/leaving-for-overview!)
1277- (goto-message! (:channel m) (:id m) 600 5000))
1278- #(do (s/leaving-for-overview!)
1279- (s/open-channel! (:channel m))))}]
1280- ;; And the line itself, as something to read rather than to press. It was a
1281- ;; link for a moment, which made the strip two things at once: a line the
1282- ;; server has echoed back has an id to aim at and a line this client has
1283- ;; just sent does not, so half the rows came out accent-coloured and half
1284- ;; plain, down the same list. The chip beside it is the way to the message
1285- ;; and is on every row either way.
1286- [:vbox {:key :said}
1287- [:dim-label {:label (str (when-let [at (:at m)] (str (clock/clock-time at) " "))
1288- (:from m) ": "
1289- (preview-line (:text m)))}]]])
1290-
1291-(defn- overview-pane
1292- "Every other room's recent lines in one list, newest at the top.
1293-
1294- A strip under the conversation rather than a screen of its own: the question
1295- it answers — is anything happening anywhere else — is one you ask while
1296- reading something, and an answer you have to leave the room for is one you
1297- stop asking for.
1298-
1299- In a window it scrolls, and the column it is in fills the height: two
1300- children of a column that both fill it are two halves of it, which is the
1301- horizontal split this is. The backlog above keeps its own scroll and its own
1302- place in it, so reading down here does not move the conversation.
1303-
1304- A terminal gets a handful of lines and no scroll. Its rows are cells rather
1305- than points, and a second scrolling pane in a screen that is already a
1306- conversation, a compose bar and a tab bar leaves neither half enough rows to
1307- be worth reading — so there it stays the strip it was, and `below-messages`
1308- reserves it by the row."
1309- []
1310- (let [lines (s/recent-everywhere)
1311- ;; The way back, and only while there is somewhere to go: the strip
1312- ;; is the one thing here that moves you without your having chosen a
1313- ;; room, so it is the one thing that owes you an undo. Gone once you
1314- ;; are back in that room, where it would offer to take you where you
1315- ;; already are.
1316- back (let [room @s/overview-return]
1317- (when (and room (not= room @s/current))
1318- [:button {:label (str "← Back to " room)
1319- :on-click s/overview-back!}]))
1320- ;; The row keeps its height whether or not the way back is in it. A
1321- ;; button is 34 points and a caption is not, so a heading that grew
1322- ;; one when you jumped and lost it when you came back moved every
1323- ;; line under it — the strip rendering two different ways depending
1324- ;; on where you had been.
1325- heading (fn []
1326- [:hbox {:key :heading :spacing 8 :wrap false :align :center}
1327- [:dim-label {:label "Everywhere else"}]
1328- [:vbox {:key :back}
1329- (or back [:spacer {:size @chrome-row}])]])
1330- ;; The same heading on the same rule the conversation puts between two
1331- ;; days, and needed more here than there: these lines come from rooms
1332- ;; that were last spoken in at their own times, so a strip of them can
1333- ;; cross a day twice in ten rows where a single room's backlog crosses
1334- ;; one once a day. A clock alone would then be the only thing saying
1335- ;; which, and `9:40` says nothing about how long ago it was.
1336- rows (fn [ms]
1337- (if (seq ms)
1338- (mapcat
1339- (fn [i m]
1340- (let [prev (when (pos? i) (nth ms (dec i)))
1341- day (some-> (:at m) clock/day)
1342- new-day? (and day (not= day (some-> (:at prev) clock/day)))]
1343- (cond-> []
1344- new-day?
1345- (conj (day-separator (str "overview-day-" i)
1346- (clock/day-label (:at m))))
1347- true
1348- (conj ^{:key (str (:channel m) "-" (or (:id m) i))}
1349- [overview-row i m]))))
1350- (range (count ms))
1351- ms)
1352- [[:dim-label {:label "Nothing has been said in any other room yet."}]]))]
1353- (if @terminal?
1354- (into [:vbox {:key :overview :spacing 4 :margin-top 4}
1355- [:separator {}]
1356- [heading]]
1357- (rows (take s/overview-lines lines)))
1358- [:vbox {:key :overview :spacing 4 :margin-top 4 :fill-height true}
1359- [:separator {}]
1360- [heading]
1361- ;; Named, so coming back to a conversation does not throw away where
1362- ;; the reader had got to in here.
1363- (into [:scroll {:scroll-key "overview-list" :orientation :vertical
1364- :spacing 4}]
1365- (rows lines))])))
1366-
1367-(defn chat-screen []
1368- (let [name @s/current
1369- buffer (get @s/channels name)
1370- show-users? (and @s/show-users? name (str/starts-with? name "#"))]
1371- ;; Not a :page — a page scrolls everything, which would carry the compose
1372- ;; bar off the bottom with the backlog. The message list is the only thing
1373- ;; that scrolls, bounded so what follows it keeps its room.
1374- ;; Same margin all round: the compose row's own air is what centres it in
1375- ;; the strip below the separator, and it is measured from this edge.
1376- ;; Ctrl-End is the jump without the button, for a reader whose hands are
1377- ;; on the compose bar. It is unhandled everywhere below — the entry takes
1378- ;; plain End for its own caret and leaves this one alone — so it arrives
1379- ;; here by bubbling up from whatever had the focus. The window backend
1380- ;; registers the handler and never calls it: keys there belong to egui.
1381- ;; `:fill-height` for the reason the chats screen gives on its own root:
1382- ;; without it this column is as tall as what is in it, and the compose bar
1383- ;; sits wherever the backlog happens to end rather than at the bottom.
1384- [:vbox {:spacing 8 :margin 12 :fill-height true
1385- :on-key (fn [k]
1386- (when (= k "ctrl+end") (s/jump-to-present!)))}
1387- [:hbox {:spacing 8}
1388- ;; The way back to the list, on a window with room for one thing at a
1389- ;; time. Beside the list there is nothing to go back to, so the button
1390- ;; goes — in a wrapper of its own, since a child that comes and goes
1391- ;; would otherwise renumber the row for the reconciler.
1392- [:vbox {:key :back}
1393- (when-not (s/wide?)
1394- [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])]
1395- ;; And the same room's other way of appearing, on a window wide enough
1396- ;; to have been showing both: the list folds away and the conversation
1397- ;; takes the whole row. Its own wrapper, since it is only offered where
1398- ;; there are two panes to choose between.
1399- [:vbox {:key :fold}
1400- (when (s/wide?)
1401- [:button {:label (if @s/hide-chat-list? "☰ Chats" "☰")
1402- :on-click s/toggle-chat-list!}])]
1403- [:title {:label (or name "Chat")}]
1404- ;; Same wrapper trick: only in a channel, and only when there is no call
1405- ;; to join already — the bar below offers Join in that case, and two ways
1406- ;; into the same call is one more than anybody needs.
1407- [:vbox {:key :call}
1408- (when (and name
1409- (str/starts-with? name "#")
1410- (av/available?)
1411- (not (av/call-in name))
1412- (not (av/in-call?)))
1413- [:button {:label "Call" :on-click #(s/start-call! name)}])]
1414- ;; The people panel's switch, in a wrapper of its own for the same
1415- ;; reason: it is only offered in a channel, where there is a membership
1416- ;; to show.
1417- [:vbox {:key :people}
1418- (when (and name (str/starts-with? name "#"))
1419- [:button {:label (str "People " (s/member-count name))
1420- :kind (when @s/show-users? :primary)
1421- :on-click s/toggle-users!}])]
1422- ;; The overview's switch. Not in a wrapper conditioned on anything: it
1423- ;; is about every room rather than this one, so it is offered in a DM
1424- ;; and in a channel alike.
1425- [:button {:key :overview-toggle
1426- :label "Overview"
1427- :kind (when @s/overview? :primary)
1428- :on-click s/toggle-overview!}]]
1429- [error-note]
1430- ;; In a wrapper of its own, for the reconciler's sake: it comes and goes.
1431- [:vbox {:key :policy}
1432- (policy-note name buffer)]
1433- [call-bar name]
1434- ;; :reserve leaves room for everything below: the jump button's row, the
1435- ;; separator and the compose bar. It does not vary with whether the button
1436- ;; is showing, and neither does that row — a reserve that changed would
1437- ;; move the compose bar under the reader every time the button came and
1438- ;; went.
1439- ;; Named, so the list is the same list when the reader comes back to it.
1440- ;; The lightbox is a screen rather than a layer, so looking at a picture
1441- ;; unmounts the backlog behind it; without a name of its own the position
1442- ;; would come back as a fresh one, and ← Back would answer a click on a
1443- ;; message halfway up a week of history with the top of the buffer.
1444- ;; The backlog and, when it is asked for, who is in the room beside it.
1445- ;; The row is always there and the panel comes and goes inside a wrapper
1446- ;; of its own: a child that appeared and vanished would renumber the row
1447- ;; for the reconciler, and take the message list's scroll position with
1448- ;; it every time the panel was toggled.
1449- [:hbox {:spacing 8 :wrap false}
1450- [:vbox {:key :messages :fill-height true
1451- :width-request (if show-users? (messages-width) 0)}
1452- [:scroll {:scroll-key (messages-scroll-key)
1453- :orientation :vertical
1454- :reserve (below-messages)
1455- :stick-to-bottom true
1456- :scroll-to-bottom @s/jump-tick
1457- :on-change #(reset! s/at-present? (= "end" %))
1458- ;; The terminal's half of the same question, which arrives
1459- ;; as the offset the list moved to rather than as a place.
1460- ;; `scrolled!` is what turns one into the other.
1461- :on-scroll s/scrolled!}
1462- (if (seq (:messages buffer))
1463- (message-rows (:messages buffer))
1464- [:dim-label {:label "Nothing here yet."}])]]
1465- ;; The wrapper takes no height of its own: the panel inside it is the
1466- ;; column, and a fill-height wrapper around it would claim the strip the
1467- ;; compose bar sits in whether or not the panel was showing.
1468- [:vbox {:key :people-pane}
1469- (when show-users? [users-panel name])]]
1470- ;; Only while it is needed, and directly under the backlog: the way back
1471- ;; to the present belongs next to the thing that puts you there, which is
1472- ;; the conversation and not the overview.
1473- ;;
1474- ;; The row used to be held open by a spacer when the button was not in
1475- ;; it, so that the compose bar did not move when it came and went. It sat
1476- ;; under the backlog then, where the empty row read as the end of the
1477- ;; conversation. Above the strip it reads as a hole between two lists,
1478- ;; and there is no arguing a reader out of that — so the row is only
1479- ;; there when the button is, and what moves is a strip below it moving by
1480- ;; one row on the rare frame the reader has scrolled away from the end.
1481- [:vbox {:key :jump}
1482- (when-not @s/at-present?
1483- ;; With the key beside it where there is a key: a terminal is where a
1484- ;; reader is least likely to reach for the mouse, and most likely to
1485- ;; have paged up here with the keyboard in the first place.
1486- [:button {:label (if @terminal?
1487- "↓ Jump to present (Ctrl-End)"
1488- "↓ Jump to present")
1489- :on-click s/jump-to-present!}])]
1490- ;; The strip, under the conversation and the way back to it: it is a
1491- ;; second list of messages, so it belongs with the first rather than down
1492- ;; among the compose bar's rows. In a wrapper of its own, since it comes
1493- ;; and goes.
1494- [:vbox {:key :overview-pane :fill-height (boolean (and @s/overview?
1495- (not @terminal?)))}
1496- (when @s/overview? [overview-pane])]
1497- ;; No rule above the compose bar. The bar is already told apart from the
1498- ;; conversation by what it is — a framed box and a button on a strip of
1499- ;; air — and a line drawn over it as well was the app underlining the one
1500- ;; edge nobody was going to miss.
1501- ;; The three banners in one wrapper, at no spacing of its own. Each is a
1502- ;; row that is usually empty, and an empty child still costs the column
1503- ;; its gap — three of them stacked under the overview was two dozen
1504- ;; points of nothing between the strip and the compose bar. Inside, they
1505- ;; carry their own air only when they have something in them.
1506- [:vbox {:key :banners :spacing 0}
1507- ;; What the draft is answering, directly above where it is being typed.
1508- [:vbox {:key :replying}
1509- (when-let [target @s/replying-to]
1510- [:hbox {:spacing 8}
1511- [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}]
1512- [:button {:label "✕" :on-click s/cancel-reply!}]])]
1513- ;; And, in the same place, that the box holds a rewrite rather than
1514- ;; something new: the text in it is a copy of a line already on screen,
1515- ;; and without this Send would look like it was about to say it twice.
1516- [:vbox {:key :editing}
1517- (when @s/editing
1518- [:hbox {:spacing 8}
1519- [:dim-label {:label "✏️ Editing your message"}]
1520- [:button {:label "✕" :on-click s/cancel-edit!}]])]
1521- ;; The pasted picture, above the line it will go out with. Shown rather
1522- ;; than written into the draft: what is being sent is a picture, and a URL
1523- ;; dropped into the entry would be an unreadable line of text sitting in
1524- ;; the middle of whatever the reader was in the middle of typing.
1525- [:vbox {:key :attachment}
1526- (when-let [att @s/attachment]
1527- [:hbox {:spacing 8}
1528- ;; Small: it is a reminder of what is attached, not the picture
1529- ;; itself, and the backlog above it is what the reader is here for.
1530- [:image {:src (:path att) :max-height 64}]
1531- [:dim-label {:label (if (= :uploading (:status att))
1532- "Uploading…"
1533- "Picture attached")}]
1534- [:button {:label "✕" :on-click s/clear-attachment!}]])]]
1535- ;; The line and its buttons on the middle of the width rather than
1536- ;; against its left edge: the entry asks for a fixed width, and on a
1537- ;; window wider than that a left-aligned row leaves it stranded in the
1538- ;; corner of an otherwise empty bar. On a phone the row is as wide as the
1539- ;; window and centring costs nothing.
1540- ;; Equal air above and below, so the row sits on the middle of the strip
1541- ;; between the separator and the bottom edge rather than flat against it.
1542- ;; Above it is one of the column's gaps, where it used to be four: the
1543- ;; reply banner, the edit banner and the attachment share a wrapper now
1544- ;; and cost one between them whether or not they have anything in them.
1545- ;; This margin plus the window's own is what answers it underneath.
1546- [:hbox {:spacing 8 :align :center :margin-bottom 12}
1547- ;; narrow enough that Send keeps its place on a phone-width row
1548- ;; A picture is pasted where everything else is typed: Ctrl+V. The field
1549- ;; answers a paste of text with the text, and one of a picture reaches
1550- ;; `:on-paste-empty` — a keystroke the field had nothing to put in
1551- ;; itself, which is exactly the one that means "the clipboard has
1552- ;; something else on it".
1553- ;; And the same picture chosen rather than pasted, for a phone — which has
1554- ;; no Ctrl+V, and no clipboard of pictures to read if it had.
1555- ;; In a window, a picture on a Delight 2 tile (src/frq/icons, on the
1556- ;; geometry delight-icons generates its tiles with): grey frame, dark
1557- ;; square, cream glyph — where the emoji was a colour photo that matched
1558- ;; nothing else in the bar. Its path is read from the working directory,
1559- ;; which is the tree under `just run` and `just cosmic` and the store
1560- ;; copy under the flake's launcher. A terminal has no pixels to put
1561- ;; it in, and an APK carries no src/ to read it from — both keep the glyph.
1562- (if (and (platform/desktop?) (not @terminal?))
1563- [:image {:src "src/frq/icons/insert-image.png"
1564- :size [36 36]
1565- ;; for a backend that sizes a picture by its bounds instead
1566- :max-width 36
1567- :max-height 36
1568- :on-click s/open-image-picker!}]
1569- [:button {:label "🖼" :on-click s/open-image-picker!}])
1570- ;; In a terminal the row is the width of the screen and a message is
1571- ;; longer than 260 points of it: the field takes the surplus and wraps
1572- ;; into three rows rather than scrolling one line sideways.
1573- [:entry {:text @s/draft
1574- :width-request 260
1575- ;; Always, not only in a terminal. A window is the case that
1576- ;; needs it more: 260 points is most of a phone's row and a
1577- ;; third of a pane's, and the field sat stranded in the middle
1578- ;; of the bar with the rest of it empty. The number stays as
1579- ;; the minimum it always was.
1580- :hexpand true
1581- :rows (if @terminal? 3 1)
1582- ;; In a window the field starts as one line and takes another
1583- ;; every time the message stops fitting, up to five — past
1584- ;; which it scrolls, keeping the caret in view. A paragraph
1585- ;; typed into a one-line box was readable a dozen characters
1586- ;; at a time, which is not how anybody writes one.
1587- :max-rows 5
1588- :on-rows #(reset! draft-rows %)
1589- ;; The break is worth saying out loud where it is new: Enter
1590- ;; sends, as it always has, and the box under it takes a
1591- ;; paragraph now — which nobody would think to try unasked.
1592- :placeholder (if @terminal? "Message — Shift+Enter for a new line" "Message")
1593- :on-change #(reset! s/draft %)
1594- :on-paste-empty s/paste-image!
1595- :on-activate s/send-draft!}]
1596- [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
1597-
1598 ;; ---------------------------------------------------------------- split349 ;; ---------------------------------------------------------------- split
1599 350
1600 (defn- no-chat-pane351 (defn- no-chat-pane
@@ -1859,3 +610,12 @@
1859 (reset! shown title)610 (reset! shown title)
1860 (title! title))))))611 (title! title))))))
1861 nil)612 nil)
613+
614+;; Where a face or a picture is on disk, once it is. These stay here because
615+;; both are a glimmer reaction over a fetch-and-cache — `derived` is the one
616+;; place in this namespace that uses glimmer's own API — and a phone has
617+;; neither the reaction nor the cache. It installs nothing and the screens
618+;; draw what they draw before a face arrives.
619+(actions/install!
620+ {:avatar-path avatar-path
621+ :image-path image-path})
modified src/frq/state.clj +37 -53
@@ -77,19 +77,12 @@
7777
7878 (def editing cells/editing)
7979
80-;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
81-;; when it is closed. The picker is a panel over the compose bar rather than a
82-;; screen: what is being reacted to has to stay in sight.
83-(defonce reacting (atom nil))
80+(def reacting cells/reacting)
8481
85-;; What the picker is showing: the search box, and which group is on screen
86-;; when nothing has been typed. `nil` is the popular row it opens on.
87-(defonce emoji-search (atom ""))
88-(defonce emoji-group (atom nil))
82+(def emoji-search cells/emoji-search)
83+(def emoji-group cells/emoji-group)
8984
90-;; The picture being looked at full size, or nil. Vidya's tree has no overlay,
91-;; so this is a screen of its own rather than a layer over the chat.
92-(defonce lightbox (atom nil)) ; {:path :url}
85+(def lightbox cells/lightbox)
9386
9487 (def show-users? cells/show-users?)
9588
@@ -109,11 +102,7 @@
109102 (swap! overview? not)
110103 (save-prefs!))
111104
112-;; The room the reader was in when a line in the overview took them somewhere
113-;; else, or nil. The strip is the one place in the app that moves you without
114-;; you having asked to leave where you were — everything else is a room you
115-;; chose — so it is the one place that owes you the way back.
116-(defonce overview-return (atom nil))
105+(def overview-return cells/overview-return)
117106
118107 (declare open-channel!)
119108
@@ -135,17 +124,9 @@
135124 (reset! overview-return nil)
136125 (open-channel! room)))
137126
138-;; The window's content width in points, polled from the backend a few times a
139-;; second. The app is laid out for a phone-width window, and this is what lets
140-;; a wide one be more than a phone with margins: past `wide-width` the channel
141-;; list and the conversation are both on screen instead of taking turns.
142-(defonce window-width (atom 0))
127+(def window-width cells/window-width)
143128
144-;; The window's content height, polled beside the width and for the same
145-;; reason. What it is for is the pictures in the conversation: a preview sized
146-;; against the window is a picture on a laptop and a thumbnail on a phone,
147-;; where one fixed height is only ever right on one of them.
148-(defonce window-height (atom 0))
129+(def window-height cells/window-height)
149130
150131 ;; Where the second pane starts paying for itself. Below this a 300pt list
151132 ;; beside a conversation leaves the messages narrower than the phone layout
@@ -1684,17 +1665,6 @@
16841665
16851666 (defn close-picker! [] (reset! reacting nil))
16861667
1687-(def emoji-groups
1688- "Unicode's own grouping, which is what the picker's tabs are."
1689- emoji/groups)
1690-
1691-(def picker-limit
1692- "How many glyphs the picker will lay out at once — twelve rows of the nine it
1693- fits across. It stands inside the message list, so what it shows pushes the
1694- conversation down; a group that has more says so, and the search box is how
1695- you reach the rest."
1696- 108)
1697-
16981668 (defn picker-emoji
16991669 "What the picker is showing right now: the popular row, one group, or
17001670 whatever the search matches — by name, so \"cat\" finds the cat and the cat
@@ -1719,11 +1689,7 @@
17191689 [m emoji]
17201690 (boolean (some #{@form-nick} (get (:reactions m) emoji))))
17211691
1722-;; The pill the pointer is resting on, or nil — `{:id msgid :emoji glyph}`.
1723-;; One at a time, and named by the message as well as the glyph: the same emoji
1724-;; is a pill under many messages, and only the one under the pointer carries a
1725-;; card.
1726-(defonce reaction-hover (atom nil))
1692+(def reaction-hover cells/reaction-hover)
17271693
17281694 (defn hover-reaction!
17291695 "The pointer has come to rest on a pill."
@@ -1915,15 +1881,9 @@
19151881 (close-picker!)))
19161882
19171883
1918-;; The message a "go to" is currently aiming at. Set for the frame that scrolls
1919-;; to it and taken off again — a scroll target that stays set would pin the
1920-;; view there and take scrolling away from the reader.
1921-(defonce jump-to (atom nil))
1884+(def jump-to cells/jump-to)
19221885
1923-;; The message a jump has just landed on. It outlives the scroll: arriving at a
1924-;; screenful of messages says nothing about which one was asked for, so the one
1925-;; that was answers for itself until the reader has had time to see it.
1926-(defonce highlight (atom nil))
1886+(def highlight cells/highlight)
19271887
19281888
19291889
@@ -1933,8 +1893,6 @@
19331893 ;; How many it shows without being scrolled — the terminal's whole answer,
19341894 ;; since a strip there is a handful of rows taken off a conversation that is
19351895 ;; measured in how many of those fit.
1936-(def overview-lines 8)
1937-
19381896 (defn- round-robin
19391897 "The colls' firsts, then their seconds, and so on until they are spent.
19401898
@@ -2019,4 +1977,30 @@
20191977 :start-call! start-call!
20201978 :in-call? av/in-call?
20211979 :call-in av/call-in
2022- :call-available? av/available?})
1980+ :call-available? av/available?
1981+ :desktop? platform/desktop?
1982+ :quit! platform/quit!
1983+ :hovering (fn [] @profile/hovering)
1984+ :viewing (fn [] @profile/viewing)
1985+ :avatar-path nil
1986+ :image-path nil
1987+ :accept-policy! accept-policy!
1988+ :close-picker! close-picker!
1989+ :hover-reaction! hover-reaction!
1990+ :join-call! join-call!
1991+ :leave-call! leave-call!
1992+ :leaving-for-overview! leaving-for-overview!
1993+ :member-list member-list
1994+ :message-by-id message-by-id
1995+ :mine? mine?
1996+ :my-reaction? my-reaction?
1997+ :open-dm! open-dm!
1998+ :open-picker! open-picker!
1999+ :overview-back! overview-back!
2000+ :picker-emoji picker-emoji
2001+ :react-from-picker! react-from-picker!
2002+ :recent-everywhere recent-everywhere
2003+ :reply-to! reply-to!
2004+ :start-edit! start-edit!
2005+ :toggle-reaction! toggle-reaction!
2006+ :unhover-reaction! unhover-reaction!})
@@ -77,19 +77,12 @@
77 77
78 (def editing cells/editing)78 (def editing cells/editing)
79 79
80-;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil80+(def reacting cells/reacting)
81-;; when it is closed. The picker is a panel over the compose bar rather than a
82-;; screen: what is being reacted to has to stay in sight.
83-(defonce reacting (atom nil))
84 81
85-;; What the picker is showing: the search box, and which group is on screen82+(def emoji-search cells/emoji-search)
86-;; when nothing has been typed. `nil` is the popular row it opens on.83+(def emoji-group cells/emoji-group)
87-(defonce emoji-search (atom ""))
88-(defonce emoji-group (atom nil))
89 84
90-;; The picture being looked at full size, or nil. Vidya's tree has no overlay,85+(def lightbox cells/lightbox)
91-;; so this is a screen of its own rather than a layer over the chat.
92-(defonce lightbox (atom nil)) ; {:path :url}
93 86
94 (def show-users? cells/show-users?)87 (def show-users? cells/show-users?)
95 88
@@ -109,11 +102,7 @@
109 (swap! overview? not)102 (swap! overview? not)
110 (save-prefs!))103 (save-prefs!))
111 104
112-;; The room the reader was in when a line in the overview took them somewhere105+(def overview-return cells/overview-return)
113-;; else, or nil. The strip is the one place in the app that moves you without
114-;; you having asked to leave where you were — everything else is a room you
115-;; chose — so it is the one place that owes you the way back.
116-(defonce overview-return (atom nil))
117 106
118 (declare open-channel!)107 (declare open-channel!)
119 108
@@ -135,17 +124,9 @@
135 (reset! overview-return nil)124 (reset! overview-return nil)
136 (open-channel! room)))125 (open-channel! room)))
137 126
138-;; The window's content width in points, polled from the backend a few times a127+(def window-width cells/window-width)
139-;; second. The app is laid out for a phone-width window, and this is what lets
140-;; a wide one be more than a phone with margins: past `wide-width` the channel
141-;; list and the conversation are both on screen instead of taking turns.
142-(defonce window-width (atom 0))
143 128
144-;; The window's content height, polled beside the width and for the same129+(def window-height cells/window-height)
145-;; reason. What it is for is the pictures in the conversation: a preview sized
146-;; against the window is a picture on a laptop and a thumbnail on a phone,
147-;; where one fixed height is only ever right on one of them.
148-(defonce window-height (atom 0))
149 130
150 ;; Where the second pane starts paying for itself. Below this a 300pt list131 ;; Where the second pane starts paying for itself. Below this a 300pt list
151 ;; beside a conversation leaves the messages narrower than the phone layout132 ;; beside a conversation leaves the messages narrower than the phone layout
@@ -1684,17 +1665,6 @@
1684 1665
1685 (defn close-picker! [] (reset! reacting nil))1666 (defn close-picker! [] (reset! reacting nil))
1686 1667
1687-(def emoji-groups
1688- "Unicode's own grouping, which is what the picker's tabs are."
1689- emoji/groups)
1690-
1691-(def picker-limit
1692- "How many glyphs the picker will lay out at once — twelve rows of the nine it
1693- fits across. It stands inside the message list, so what it shows pushes the
1694- conversation down; a group that has more says so, and the search box is how
1695- you reach the rest."
1696- 108)
1697-
1698 (defn picker-emoji1668 (defn picker-emoji
1699 "What the picker is showing right now: the popular row, one group, or1669 "What the picker is showing right now: the popular row, one group, or
1700 whatever the search matches — by name, so \"cat\" finds the cat and the cat1670 whatever the search matches — by name, so \"cat\" finds the cat and the cat
@@ -1719,11 +1689,7 @@
1719 [m emoji]1689 [m emoji]
1720 (boolean (some #{@form-nick} (get (:reactions m) emoji))))1690 (boolean (some #{@form-nick} (get (:reactions m) emoji))))
1721 1691
1722-;; The pill the pointer is resting on, or nil — `{:id msgid :emoji glyph}`.1692+(def reaction-hover cells/reaction-hover)
1723-;; One at a time, and named by the message as well as the glyph: the same emoji
1724-;; is a pill under many messages, and only the one under the pointer carries a
1725-;; card.
1726-(defonce reaction-hover (atom nil))
1727 1693
1728 (defn hover-reaction!1694 (defn hover-reaction!
1729 "The pointer has come to rest on a pill."1695 "The pointer has come to rest on a pill."
@@ -1915,15 +1881,9 @@
1915 (close-picker!)))1881 (close-picker!)))
1916 1882
1917 1883
1918-;; The message a "go to" is currently aiming at. Set for the frame that scrolls1884+(def jump-to cells/jump-to)
1919-;; to it and taken off again — a scroll target that stays set would pin the
1920-;; view there and take scrolling away from the reader.
1921-(defonce jump-to (atom nil))
1922 1885
1923-;; The message a jump has just landed on. It outlives the scroll: arriving at a1886+(def highlight cells/highlight)
1924-;; screenful of messages says nothing about which one was asked for, so the one
1925-;; that was answers for itself until the reader has had time to see it.
1926-(defonce highlight (atom nil))
1927 1887
1928 1888
1929 1889
@@ -1933,8 +1893,6 @@
1933 ;; How many it shows without being scrolled — the terminal's whole answer,1893 ;; How many it shows without being scrolled — the terminal's whole answer,
1934 ;; since a strip there is a handful of rows taken off a conversation that is1894 ;; since a strip there is a handful of rows taken off a conversation that is
1935 ;; measured in how many of those fit.1895 ;; measured in how many of those fit.
1936-(def overview-lines 8)
1937-
1938 (defn- round-robin1896 (defn- round-robin
1939 "The colls' firsts, then their seconds, and so on until they are spent.1897 "The colls' firsts, then their seconds, and so on until they are spent.
1940 1898
@@ -2019,4 +1977,30 @@
2019 :start-call! start-call!1977 :start-call! start-call!
2020 :in-call? av/in-call?1978 :in-call? av/in-call?
2021 :call-in av/call-in1979 :call-in av/call-in
2022- :call-available? av/available?})1980+ :call-available? av/available?
1981+ :desktop? platform/desktop?
1982+ :quit! platform/quit!
1983+ :hovering (fn [] @profile/hovering)
1984+ :viewing (fn [] @profile/viewing)
1985+ :avatar-path nil
1986+ :image-path nil
1987+ :accept-policy! accept-policy!
1988+ :close-picker! close-picker!
1989+ :hover-reaction! hover-reaction!
1990+ :join-call! join-call!
1991+ :leave-call! leave-call!
1992+ :leaving-for-overview! leaving-for-overview!
1993+ :member-list member-list
1994+ :message-by-id message-by-id
1995+ :mine? mine?
1996+ :my-reaction? my-reaction?
1997+ :open-dm! open-dm!
1998+ :open-picker! open-picker!
1999+ :overview-back! overview-back!
2000+ :picker-emoji picker-emoji
2001+ :react-from-picker! react-from-picker!
2002+ :recent-everywhere recent-everywhere
2003+ :reply-to! reply-to!
2004+ :start-edit! start-edit!
2005+ :toggle-reaction! toggle-reaction!
2006+ :unhover-reaction! unhover-reaction!})