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>
5485559 parent: 0addab3 modified
common/frq/actions.cljc +38 -7 | @@ -58,7 +58,7 @@ | ||
| 58 | 58 | (defn toggle-overview! [] (call :toggle-overview! [])) |
| 59 | 59 | |
| 60 | 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 | 63 | ;; ------------------------------------------------------------------ calls |
| 64 | 64 | ;; |
| @@ -66,10 +66,10 @@ | ||
| 66 | 66 | ;; the desktop and nothing installs these here, so the call bar renders its |
| 67 | 67 | ;; unavailable shape rather than being special-cased in the screen. |
| 68 | 68 | |
| 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))) | |
| 73 | 73 | |
| 74 | 74 | ;; ----------------------------------------------------------------- assets |
| 75 | 75 | ;; |
| @@ -78,6 +78,37 @@ | ||
| 78 | 78 | ;; the rows that read it; the phone has neither yet and answers nil, which the |
| 79 | 79 | ;; screens already treat as "not here". |
| 80 | 80 | |
| 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)) | |
| 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)) | |
| @@ -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 | ;; ------------------------------------------------------------------ calls | 63 | ;; ------------------------------------------------------------------ calls |
| 64 | ;; | 64 | ;; |
| @@ -66,10 +66,10 @@ | |||
| 66 | ;; the desktop and nothing installs these here, so the call bar renders its | 66 | ;; 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 | ;; ----------------------------------------------------------------- assets | 74 | ;; ----------------------------------------------------------------- assets |
| 75 | ;; | 75 | ;; |
| @@ -78,6 +78,37 @@ | |||
| 78 | ;; the rows that read it; the phone has neither yet and answers nil, which the | 78 | ;; 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 @@ | ||
| 16 | 16 | jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has |
| 17 | 17 | `:clj` always on, which is why neither branch is spelled that way." |
| 18 | 18 | (:require #?@(:cljd [] |
| 19 | - :jolt [[glimmer.ratom :refer [atom]]]))) | |
| 19 | + :jolt [[glimmer.ratom :refer [atom reaction]]]))) | |
| 20 | 20 | |
| 21 | 21 | (def default-host "irc.freeq.at") |
| 22 | 22 | (def default-port "6697") |
| @@ -114,3 +114,80 @@ | ||
| 114 | 114 | ;; watches to be told to go back to it. A counter rather than a flag: a flag |
| 115 | 115 | ;; would need clearing, and there is no frame in which to clear it. |
| 116 | 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)) | |
| @@ -16,7 +16,7 @@ | |||
| 16 | jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has | 16 | 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 flag | 114 | ;; 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 @@ | ||
| 32 | 32 | ;; And whether that terminal draws pictures over its cells — Kitty's graphics |
| 33 | 33 | ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not. |
| 34 | 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?)) | |
| @@ -32,3 +32,12 @@ | |||
| 32 | ;; And whether that terminal draws pictures over its cells — Kitty's graphics | 32 | ;; 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 @@ | ||
| 29 | 29 | ;; The connect screen lives in common/ now — the same file the |
| 30 | 30 | ;; phone renders. It reads frq.cells and calls frq.actions, and |
| 31 | 31 | ;; this requires it exactly where its own copy used to be. |
| 32 | + [frq.actions :as actions] | |
| 32 | 33 | [frq.metrics :as metrics] |
| 33 | 34 | [frq.screens.connect :as connect :refer [connect-screen error-note]] |
| 34 | 35 | ;; The conversation list moved out too, with the tab bar and the |
| @@ -36,6 +37,9 @@ | ||
| 36 | 37 | ;; phone renders this very file. |
| 37 | 38 | [frq.screens.chats :refer [below-list chats-screen conversation-row |
| 38 | 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 | 43 | [frq.state :as s])) |
| 40 | 44 | |
| 41 | 45 | ;; ---------------------------------------------------------------- pieces |
| @@ -49,14 +53,7 @@ | ||
| 49 | 53 | ;; of names with the words hanging under them. |
| 50 | 54 | (def terminal-graphics? metrics/terminal-graphics?) |
| 51 | 55 | |
| 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?) | |
| 60 | 57 | |
| 61 | 58 | (defonce ^:private derived-cells |
| 62 | 59 | ;; One cell per question, kept for the session: a cell made afresh on every |
| @@ -96,8 +93,6 @@ | ||
| 96 | 93 | ;; drawn one at. A terminal's cell is eight points across and sixteen down, so |
| 97 | 94 | ;; the same number is four columns by two rows there: a cached 128-pixel |
| 98 | 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 | 96 | ;; ---------------------------------------------------------------- chats |
| 102 | 97 | |
| 103 | 98 | ;; The way into a room. Standard rather than `:primary`: the theme paints a |
| @@ -106,20 +101,6 @@ | ||
| 106 | 101 | ;; rhythm down the list the one word that is the same on every row, with the |
| 107 | 102 | ;; name that differs set quieter than it. Nothing moves; the button stops |
| 108 | 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 | 104 | (def ^:private window-row metrics/window-row) |
| 124 | 105 | ;; Re-defined rather than moved out of reach: `frq.tui` resets this before its |
| 125 | 106 | ;; first paint and says `app/chrome-row` when it does. |
| @@ -127,603 +108,8 @@ | ||
| 127 | 108 | |
| 128 | 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 | 111 | ;; ---------------------------------------------------------------- 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 | 113 | (defn- profile-dialog |
| 728 | 114 | "Who someone is, as libcosmic's own dialog: centred over the window, with |
| 729 | 115 | what you were reading dimmed behind it rather than replaced. |
| @@ -815,25 +201,6 @@ | ||
| 815 | 201 | :sensitive (boolean url) |
| 816 | 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 | 204 | (defn profile-screen |
| 838 | 205 | "Who someone is: sleek's peer profile modal, as a screen. |
| 839 | 206 | |
| @@ -920,118 +287,6 @@ | ||
| 920 | 287 | [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] |
| 921 | 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 | 290 | (defn image-picker-screen |
| 1036 | 291 | "The pictures on this device, to send one of. |
| 1037 | 292 | |
| @@ -1091,510 +346,6 @@ | ||
| 1091 | 346 | ;; be told to leave. A column with `:fill-height` and no width takes the whole |
| 1092 | 347 | ;; row — so the panel is only ever on screen if the message list is given a |
| 1093 | 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 | 349 | ;; ---------------------------------------------------------------- split |
| 1599 | 350 | |
| 1600 | 351 | (defn- no-chat-pane |
| @@ -1859,3 +610,12 @@ | ||
| 1859 | 610 | (reset! shown title) |
| 1860 | 611 | (title! title)))))) |
| 1861 | 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}) | |
| @@ -29,6 +29,7 @@ | |||
| 29 | ;; The connect screen lives in common/ now — the same file the | 29 | ;; The connect screen lives in common/ now — the same file the |
| 30 | ;; phone renders. It reads frq.cells and calls frq.actions, and | 30 | ;; 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 the | 35 | ;; 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-row | 38 | [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 | ;; ---------------------------------------------------------------- pieces | 45 | ;; ---------------------------------------------------------------- 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-cells | 58 | (defonce ^:private derived-cells |
| 62 | ;; One cell per question, kept for the session: a cell made afresh on every | 59 | ;; 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, so | 93 | ;; 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-pixel | 94 | ;; 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 | ;; ---------------------------------------------------------------- chats | 96 | ;; ---------------------------------------------------------------- chats |
| 102 | 97 | ||
| 103 | ;; The way into a room. Standard rather than `:primary`: the theme paints a | 98 | ;; 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 the | 101 | ;; 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 stops | 102 | ;; 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 its | 105 | ;; 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 | ;; ---------------------------------------------------------------- chat | 111 | ;; ---------------------------------------------------------------- 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-dialog | 113 | (defn- profile-dialog |
| 728 | "Who someone is, as libcosmic's own dialog: centred over the window, with | 114 | "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-screen | 204 | (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-screen | 290 | (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 whole | 346 | ;; 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 a | 347 | ;; 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 | ;; ---------------------------------------------------------------- split | 349 | ;; ---------------------------------------------------------------- split |
| 1599 | 350 | ||
| 1600 | (defn- no-chat-pane | 351 | (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 @@ | ||
| 77 | 77 | |
| 78 | 78 | (def editing cells/editing) |
| 79 | 79 | |
| 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) | |
| 84 | 81 | |
| 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) | |
| 89 | 84 | |
| 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) | |
| 93 | 86 | |
| 94 | 87 | (def show-users? cells/show-users?) |
| 95 | 88 | |
| @@ -109,11 +102,7 @@ | ||
| 109 | 102 | (swap! overview? not) |
| 110 | 103 | (save-prefs!)) |
| 111 | 104 | |
| 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) | |
| 117 | 106 | |
| 118 | 107 | (declare open-channel!) |
| 119 | 108 | |
| @@ -135,17 +124,9 @@ | ||
| 135 | 124 | (reset! overview-return nil) |
| 136 | 125 | (open-channel! room))) |
| 137 | 126 | |
| 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) | |
| 143 | 128 | |
| 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) | |
| 149 | 130 | |
| 150 | 131 | ;; Where the second pane starts paying for itself. Below this a 300pt list |
| 151 | 132 | ;; beside a conversation leaves the messages narrower than the phone layout |
| @@ -1684,17 +1665,6 @@ | ||
| 1684 | 1665 | |
| 1685 | 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 | 1668 | (defn picker-emoji |
| 1699 | 1669 | "What the picker is showing right now: the popular row, one group, or |
| 1700 | 1670 | whatever the search matches — by name, so \"cat\" finds the cat and the cat |
| @@ -1719,11 +1689,7 @@ | ||
| 1719 | 1689 | [m emoji] |
| 1720 | 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}`. | |
| 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) | |
| 1727 | 1693 | |
| 1728 | 1694 | (defn hover-reaction! |
| 1729 | 1695 | "The pointer has come to rest on a pill." |
| @@ -1915,15 +1881,9 @@ | ||
| 1915 | 1881 | (close-picker!))) |
| 1916 | 1882 | |
| 1917 | 1883 | |
| 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) | |
| 1922 | 1885 | |
| 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) | |
| 1927 | 1887 | |
| 1928 | 1888 | |
| 1929 | 1889 | |
| @@ -1933,8 +1893,6 @@ | ||
| 1933 | 1893 | ;; How many it shows without being scrolled — the terminal's whole answer, |
| 1934 | 1894 | ;; since a strip there is a handful of rows taken off a conversation that is |
| 1935 | 1895 | ;; measured in how many of those fit. |
| 1936 | -(def overview-lines 8) | |
| 1937 | - | |
| 1938 | 1896 | (defn- round-robin |
| 1939 | 1897 | "The colls' firsts, then their seconds, and so on until they are spent. |
| 1940 | 1898 | |
| @@ -2019,4 +1977,30 @@ | ||
| 2019 | 1977 | :start-call! start-call! |
| 2020 | 1978 | :in-call? av/in-call? |
| 2021 | 1979 | :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 nil | 80 | +(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 screen | 82 | +(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 somewhere | 105 | +(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 a | 127 | +(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 same | 129 | +(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 list | 131 | ;; Where the second pane starts paying for itself. Below this a 300pt list |
| 151 | ;; beside a conversation leaves the messages narrower than the phone layout | 132 | ;; 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-emoji | 1668 | (defn picker-emoji |
| 1699 | "What the picker is showing right now: the popular row, one group, or | 1669 | "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 cat | 1670 | 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 scrolls | 1884 | +(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 a | 1886 | +(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 is | 1894 | ;; 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-robin | 1896 | (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-in | 1979 | :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!}) | ||