Share the chats screen, and the four things under it
The second screen out of frq.app, and again a rename rather than a rewrite: the same hiccup, reading frq.cells and calling frq.actions. The desktop draws this file now — the TUI shows the list with its rooms, previews and Open buttons exactly as before. Four pieces came with it, each because the screen needed it and each worth having apart: frq.rooms — channel-list, dm?, last-preview. Pure over the cells, so shared; frq.state re-defs all three. frq.metrics — window-row, chrome-row, chrome-scale and the two terminal flags. These are what the backend is like, and screens count reserves against them. frq.tui still says `app/chrome-row` and still works, because frq.app re-defs them. frq.cells grows channels, current, join-input and search; frq.actions grows connected?, join!, open-channel! and leave-channel!. connected? is a query rather than a cell because what counts as connected is a socket, and each platform holds its own. The phone does not draw it yet, and that is the renderer rather than the screen. `:fill-height` is iced's Length::Fill — the space left over, which is Flutter's Expanded and not a taller mainAxisSize. Getting that onto the right children without breaking the bands around them blanked the screen, so it is reverted rather than shipped half-working, and flutter/README.md says what it needs. One real renderer bug did come out of the attempt and is worth keeping in mind when it resumes: an :entry with no :width-request fills its row, which in a Row is Expanded. The connect screen's server boxes carry widths and were fine; the chats screen's join box carries none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1f55e68 parent: 19a2ec6 modified
common/frq/actions.cljc +12 -0 | @@ -21,7 +21,19 @@ | ||
| 21 | 21 | (defn- call [k args] |
| 22 | 22 | (when-let [f (get @impl k)] (apply f args))) |
| 23 | 23 | |
| 24 | +(defn connected? | |
| 25 | + "Whether there is a live connection. A query rather than a cell: what counts | |
| 26 | + as connected is a socket, and each platform holds its own." | |
| 27 | + [] | |
| 28 | + (boolean (call :connected? []))) | |
| 29 | + | |
| 24 | 30 | (defn connect! [] (call :connect! [])) |
| 25 | 31 | (defn disconnect! [] (call :disconnect! [])) |
| 26 | 32 | (defn forget-session! [] (call :forget-session! [])) |
| 27 | 33 | (defn open-url! [url] (call :open-url! [url])) |
| 34 | + | |
| 35 | +;; ------------------------------------------------------------------ rooms | |
| 36 | + | |
| 37 | +(defn join! [name] (call :join! [name])) | |
| 38 | +(defn open-channel! [name] (call :open-channel! [name])) | |
| 39 | +(defn leave-channel! [name] (call :leave-channel! [name])) | |
| @@ -21,7 +21,19 @@ | |||
| 21 | (defn- call [k args] | 21 | (defn- call [k args] |
| 22 | (when-let [f (get @impl k)] (apply f args))) | 22 | (when-let [f (get @impl k)] (apply f args))) |
| 23 | 23 | ||
| 24 | +(defn connected? | ||
| 25 | + "Whether there is a live connection. A query rather than a cell: what counts | ||
| 26 | + as connected is a socket, and each platform holds its own." | ||
| 27 | + [] | ||
| 28 | + (boolean (call :connected? []))) | ||
| 29 | + | ||
| 24 | (defn connect! [] (call :connect! [])) | 30 | (defn connect! [] (call :connect! [])) |
| 25 | (defn disconnect! [] (call :disconnect! [])) | 31 | (defn disconnect! [] (call :disconnect! [])) |
| 26 | (defn forget-session! [] (call :forget-session! [])) | 32 | (defn forget-session! [] (call :forget-session! [])) |
| 27 | (defn open-url! [url] (call :open-url! [url])) | 33 | (defn open-url! [url] (call :open-url! [url])) |
| 34 | + | ||
| 35 | +;; ------------------------------------------------------------------ rooms | ||
| 36 | + | ||
| 37 | +(defn join! [name] (call :join! [name])) | ||
| 38 | +(defn open-channel! [name] (call :open-channel! [name])) | ||
| 39 | +(defn leave-channel! [name] (call :leave-channel! [name])) | ||
modified
common/frq/cells.cljc +7 -0 | @@ -43,5 +43,12 @@ | ||
| 43 | 43 | |
| 44 | 44 | ;; The durable half of an OAuth sign-in. The web-token beside it is single-use, |
| 45 | 45 | ;; so a reconnect mints a fresh one from this rather than replaying the old. |
| 46 | +; ------------------------------------------------------------------ rooms | |
| 47 | +;; name -> {:name :messages [{:from :text}] :unread n :joined? bool} | |
| 48 | +(defonce channels (atom {})) | |
| 49 | +(defonce current (atom nil)) | |
| 50 | +(defonce join-input (atom "")) | |
| 51 | +(defonce search (atom "")) | |
| 52 | + | |
| 46 | 53 | (defonce broker-token (atom nil)) |
| 47 | 54 | (defonce login-url (atom nil)) ; shown while the browser is open |
| @@ -43,5 +43,12 @@ | |||
| 43 | 43 | ||
| 44 | ;; The durable half of an OAuth sign-in. The web-token beside it is single-use, | 44 | ;; The durable half of an OAuth sign-in. The web-token beside it is single-use, |
| 45 | ;; so a reconnect mints a fresh one from this rather than replaying the old. | 45 | ;; so a reconnect mints a fresh one from this rather than replaying the old. |
| 46 | +; ------------------------------------------------------------------ rooms | ||
| 47 | +;; name -> {:name :messages [{:from :text}] :unread n :joined? bool} | ||
| 48 | +(defonce channels (atom {})) | ||
| 49 | +(defonce current (atom nil)) | ||
| 50 | +(defonce join-input (atom "")) | ||
| 51 | +(defonce search (atom "")) | ||
| 52 | + | ||
| 46 | (defonce broker-token (atom nil)) | 53 | (defonce broker-token (atom nil)) |
| 47 | (defonce login-url (atom nil)) ; shown while the browser is open | 54 | (defonce login-url (atom nil)) ; shown while the browser is open |
added
common/frq/metrics.cljc +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +(ns frq.metrics | |
| 2 | + "How tall a row of chrome is — a button, the compose bar, a line of tabs. | |
| 3 | + | |
| 4 | + The reserves the screens count are in points against this: a window's row is | |
| 5 | + 34 of them, and every gap around it was chosen at that size. A terminal's row | |
| 6 | + is one cell, and the same count then reserves two or three times the room the | |
| 7 | + strip under the list actually needs — which costs a message a row, and a | |
| 8 | + conversation is measured in how many of those fit. | |
| 9 | + | |
| 10 | + So the counts stay where the reasoning is and a backend whose rows are a | |
| 11 | + different height says so here. `frq.tui` sets `chrome-row` before its first | |
| 12 | + paint; a window and a phone leave it alone. | |
| 13 | + | |
| 14 | + Shared because the screens that count against it are shared: `below-list` in | |
| 15 | + `frq.screens.chats` is the first, and the chat screen's reserves will be next. | |
| 16 | + `frq.app` re-defs all three, so `frq.tui` still writes `app/chrome-row`." | |
| 17 | + (:require #?@(:cljd [] | |
| 18 | + :jolt [[glimmer.ratom :refer [atom]]]))) | |
| 19 | + | |
| 20 | +(def window-row 34) | |
| 21 | +(defonce chrome-row (atom window-row)) | |
| 22 | + | |
| 23 | +(defn chrome-scale [] (/ (double @chrome-row) window-row)) | |
| 24 | + | |
| 25 | +;; Whether the backend under this tree is a terminal, set by `frq.tui` before | |
| 26 | +;; the first paint and never again. Two things in a message hang on it, and | |
| 27 | +;; both are about a cell grid rather than a canvas: there is no picture to draw | |
| 28 | +;; a face with, and a name with nothing to its left is the heading the text | |
| 29 | +;; hangs off — the shape a terminal has read messages in for forty years. | |
| 30 | +(defonce terminal? (atom false)) | |
| 31 | + | |
| 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. | |
| 34 | +(defonce terminal-graphics? (atom false)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +(ns frq.metrics | ||
| 2 | + "How tall a row of chrome is — a button, the compose bar, a line of tabs. | ||
| 3 | + | ||
| 4 | + The reserves the screens count are in points against this: a window's row is | ||
| 5 | + 34 of them, and every gap around it was chosen at that size. A terminal's row | ||
| 6 | + is one cell, and the same count then reserves two or three times the room the | ||
| 7 | + strip under the list actually needs — which costs a message a row, and a | ||
| 8 | + conversation is measured in how many of those fit. | ||
| 9 | + | ||
| 10 | + So the counts stay where the reasoning is and a backend whose rows are a | ||
| 11 | + different height says so here. `frq.tui` sets `chrome-row` before its first | ||
| 12 | + paint; a window and a phone leave it alone. | ||
| 13 | + | ||
| 14 | + Shared because the screens that count against it are shared: `below-list` in | ||
| 15 | + `frq.screens.chats` is the first, and the chat screen's reserves will be next. | ||
| 16 | + `frq.app` re-defs all three, so `frq.tui` still writes `app/chrome-row`." | ||
| 17 | + (:require #?@(:cljd [] | ||
| 18 | + :jolt [[glimmer.ratom :refer [atom]]]))) | ||
| 19 | + | ||
| 20 | +(def window-row 34) | ||
| 21 | +(defonce chrome-row (atom window-row)) | ||
| 22 | + | ||
| 23 | +(defn chrome-scale [] (/ (double @chrome-row) window-row)) | ||
| 24 | + | ||
| 25 | +;; Whether the backend under this tree is a terminal, set by `frq.tui` before | ||
| 26 | +;; the first paint and never again. Two things in a message hang on it, and | ||
| 27 | +;; both are about a cell grid rather than a canvas: there is no picture to draw | ||
| 28 | +;; a face with, and a name with nothing to its left is the heading the text | ||
| 29 | +;; hangs off — the shape a terminal has read messages in for forty years. | ||
| 30 | +(defonce terminal? (atom false)) | ||
| 31 | + | ||
| 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. | ||
| 34 | +(defonce terminal-graphics? (atom false)) | ||
added
common/frq/rooms.cljc +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +(ns frq.rooms | |
| 2 | + "What the conversation list is, derived from the cells it is held in. | |
| 3 | + | |
| 4 | + Pure, and so shared: `channel-list` is a filter and a sort over | |
| 5 | + `frq.cells/channels` and the search box, `dm?` is a question about a name, | |
| 6 | + `last-preview` reads the last message of a buffer. `frq.state` re-defs all | |
| 7 | + three, so the thousand lines below them did not move." | |
| 8 | + (:require [clojure.string :as str] | |
| 9 | + [frq.cells :as cells])) | |
| 10 | + | |
| 11 | +(defn dm? | |
| 12 | + "Whether a buffer is a conversation with a person rather than a room. Every | |
| 13 | + channel name starts with `#`; what does not is somebody's nick." | |
| 14 | + [name] | |
| 15 | + (and (seq name) (not (str/starts-with? name "#")))) | |
| 16 | + | |
| 17 | +(defn last-preview [buffer] | |
| 18 | + (if-let [m (last (:messages buffer))] | |
| 19 | + (str (:from m) ": " (:text m)) | |
| 20 | + "No messages yet")) | |
| 21 | + | |
| 22 | +(defn channel-list | |
| 23 | + "Buffers most recently opened first, filtered by the search box. | |
| 24 | + | |
| 25 | + A conversation list is read from the top, and the one you were just in is the | |
| 26 | + one you are most likely to want again. Buffers never opened — a DM that | |
| 27 | + arrived, a channel someone mentioned — sort under those, by name, rather than | |
| 28 | + jumping the queue." | |
| 29 | + [] | |
| 30 | + (let [q (str/lower-case (str/trim @cells/search))] | |
| 31 | + (->> (vals @cells/channels) | |
| 32 | + (filter #(or (str/blank? q) | |
| 33 | + (str/includes? (str/lower-case (:name %)) q))) | |
| 34 | + (sort-by (juxt #(- (:accessed % 0)) :name)) | |
| 35 | + vec))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +(ns frq.rooms | ||
| 2 | + "What the conversation list is, derived from the cells it is held in. | ||
| 3 | + | ||
| 4 | + Pure, and so shared: `channel-list` is a filter and a sort over | ||
| 5 | + `frq.cells/channels` and the search box, `dm?` is a question about a name, | ||
| 6 | + `last-preview` reads the last message of a buffer. `frq.state` re-defs all | ||
| 7 | + three, so the thousand lines below them did not move." | ||
| 8 | + (:require [clojure.string :as str] | ||
| 9 | + [frq.cells :as cells])) | ||
| 10 | + | ||
| 11 | +(defn dm? | ||
| 12 | + "Whether a buffer is a conversation with a person rather than a room. Every | ||
| 13 | + channel name starts with `#`; what does not is somebody's nick." | ||
| 14 | + [name] | ||
| 15 | + (and (seq name) (not (str/starts-with? name "#")))) | ||
| 16 | + | ||
| 17 | +(defn last-preview [buffer] | ||
| 18 | + (if-let [m (last (:messages buffer))] | ||
| 19 | + (str (:from m) ": " (:text m)) | ||
| 20 | + "No messages yet")) | ||
| 21 | + | ||
| 22 | +(defn channel-list | ||
| 23 | + "Buffers most recently opened first, filtered by the search box. | ||
| 24 | + | ||
| 25 | + A conversation list is read from the top, and the one you were just in is the | ||
| 26 | + one you are most likely to want again. Buffers never opened — a DM that | ||
| 27 | + arrived, a channel someone mentioned — sort under those, by name, rather than | ||
| 28 | + jumping the queue." | ||
| 29 | + [] | ||
| 30 | + (let [q (str/lower-case (str/trim @cells/search))] | ||
| 31 | + (->> (vals @cells/channels) | ||
| 32 | + (filter #(or (str/blank? q) | ||
| 33 | + (str/includes? (str/lower-case (:name %)) q))) | ||
| 34 | + (sort-by (juxt #(- (:accessed % 0)) :name)) | ||
| 35 | + vec))) | ||
added
common/frq/screens/chats.cljc +222 -0 | new file mode 100644 | ||
| @@ -0,0 +1,222 @@ | ||
| 1 | +(ns frq.screens.chats | |
| 2 | + "The conversation list, shared. | |
| 3 | + | |
| 4 | + The second screen out of `frq.app`, and like the first it moved rather than | |
| 5 | + changed: the same hiccup, reading `frq.cells` and `frq.rooms` instead of | |
| 6 | + `frq.state`, and calling `frq.actions` instead of the reducers. | |
| 7 | + | |
| 8 | + `error-note` and `tab-bar` come with it because the list is not the only | |
| 9 | + screen that shows them — the chat screen and settings do too, and they will | |
| 10 | + want this namespace rather than a second copy when their turn comes." | |
| 11 | + (:require [clojure.string :as str] | |
| 12 | + [frq.actions :as actions] | |
| 13 | + [frq.cells :as cells] | |
| 14 | + [frq.metrics :refer [chrome-scale terminal?]] | |
| 15 | + [frq.rooms :as rooms] | |
| 16 | + [frq.screens.connect :refer [error-note]])) | |
| 17 | + | |
| 18 | +(defn tab-bar [] | |
| 19 | + [:hbox {:spacing 8} | |
| 20 | + (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]] | |
| 21 | + [:button {:key k | |
| 22 | + :label label | |
| 23 | + :kind (if (= k @cells/screen) :primary :default) | |
| 24 | + :on-click #(reset! cells/screen k)}])]) | |
| 25 | + | |
| 26 | + | |
| 27 | +;; Whether the backend under this tree is a terminal, set by `frq.tui` before | |
| 28 | +;; the first paint and never again. Two things in a message hang on it, and | |
| 29 | +;; both are about a cell grid rather than a canvas: there is no picture to | |
| 30 | +;; draw a face with, and a name with nothing to its left is the heading the | |
| 31 | +;; text hangs off — the shape a terminal has read messages in for forty years. | |
| 32 | + | |
| 33 | +(def ^:private list-gutter 16) | |
| 34 | + | |
| 35 | +(defn- below-list [] | |
| 36 | + ;; What the strip under the list needs, counted: the gap after the list, the | |
| 37 | + ;; separator, the gap under it, the tab bar's row of buttons and the margin | |
| 38 | + ;; below them. Short by any of it and the tabs go off the bottom edge — which | |
| 39 | + ;; is the thing this layout exists to stop. | |
| 40 | + (* (chrome-scale) (+ 8 8 8 34 12))) | |
| 41 | + | |
| 42 | +(defn- preview-line | |
| 43 | + "The last line of a conversation, as one line: a pasted shell script or a | |
| 44 | + long link is a card's worth of text otherwise, and the cards stop reading as | |
| 45 | + a list of rooms." | |
| 46 | + [text] | |
| 47 | + (let [line (str/replace (str text) #"\s+" " ")] | |
| 48 | + ;; 60 rather than a card's worth: at `sidebar-width` a 90-character line is | |
| 49 | + ;; three wrapped rows, and three rows of somebody else's last sentence is | |
| 50 | + ;; the row shouting over the name above it. One row of it says which | |
| 51 | + ;; conversation this is, which is all the list is for. | |
| 52 | + (if (> (count line) 60) | |
| 53 | + (str (subs line 0 59) "…") | |
| 54 | + line))) | |
| 55 | + | |
| 56 | +;; What everything in the chats pane keeps clear of its right edge. | |
| 57 | +;; | |
| 58 | +;; It is one number because it is one edge: the scrollbar rides it, and the | |
| 59 | +;; cards in the list have to stop short of the bar rather than be drawn under | |
| 60 | +;; it — but the Join box above the list is outside the scroll and has no bar | |
| 61 | +;; beside it, so left to itself it ran on past the cards and the pane had two | |
| 62 | +;; right edges half a finger apart. Whatever the gap is, both of them take it. | |
| 63 | + | |
| 64 | +(defn- close-button [name] | |
| 65 | + [:button {:label "Close" | |
| 66 | + :on-click #(actions/leave-channel! name)}]) | |
| 67 | + | |
| 68 | +(defn- open-button [name] | |
| 69 | + [:button {:label "Open" | |
| 70 | + :on-click #(actions/open-channel! name)}]) | |
| 71 | + | |
| 72 | +;; The only way out of a room. Everything else adds one — the server saying we | |
| 73 | +;; are in it, a message arriving in it — so without this the list only grows, | |
| 74 | +;; and what rooms.edn claims we are in could never shrink. | |
| 75 | + | |
| 76 | +(defn conversation-row [buffer] | |
| 77 | + (let [name (:name buffer) | |
| 78 | + ;; Defaulted rather than assumed. Everything that builds a buffer | |
| 79 | + ;; goes through `ensure-channel`, but the list draws whatever is in | |
| 80 | + ;; the atom, and a half-made room reaching here should be a row that | |
| 81 | + ;; reads as quiet rather than the frame that killed the client. | |
| 82 | + unread (:unread buffer 0)] | |
| 83 | + ;; The card in a wrapper that holds it off the right edge, where the | |
| 84 | + ;; list's scrollbar rides — `message-row` keeps its actions clear of the | |
| 85 | + ;; same edge for the same reason, and without it the cards are drawn | |
| 86 | + ;; under the bar rather than beside it. The wrapper and not the card | |
| 87 | + ;; itself: the window backend pads a card by a fixed 12 and never reads a | |
| 88 | + ;; margin off one, so the room has to be taken outside it. | |
| 89 | + [:vbox {:key name :margin-right list-gutter} | |
| 90 | + [:card {} | |
| 91 | + ;; The name gets the line, and membership and unread count the one under | |
| 92 | + ;; it. All three on one line is what the list pane has no room for: the | |
| 93 | + ;; name takes what it likes, the badges are left the remainder, and a | |
| 94 | + ;; long name squeezes them into a status reading "joine/d" and a count | |
| 95 | + ;; split down two lines — while the card, sized to a row that no longer | |
| 96 | + ;; fits, sticks out past the cards above and below it. | |
| 97 | + ;; | |
| 98 | + ;; In a terminal the Open button rides up onto the name's line. A row of | |
| 99 | + ;; chrome there is one cell, so a button on a line of its own costs the | |
| 100 | + ;; card a whole row — four of them and the list has spent a screenful on | |
| 101 | + ;; buttons. A window's row is 34 points and its button is a lozenge with | |
| 102 | + ;; air around it, which is why it keeps its own line there. | |
| 103 | + (if @terminal? | |
| 104 | + [:hbox {:spacing 8 :wrap false} | |
| 105 | + [:title-2 {:label name}] | |
| 106 | + [open-button name] | |
| 107 | + [close-button name]] | |
| 108 | + [:title-2 {:label name}]) | |
| 109 | + ;; The badge line, and only when there is a badge: every row carrying a | |
| 110 | + ;; green "joined" was a row spending a third of its height saying the | |
| 111 | + ;; ordinary thing, and a list where every card says the same word is a | |
| 112 | + ;; list you read by skipping. So membership is reported when it is news — | |
| 113 | + ;; a room in the list we are not in — and a DM says nothing at all, since | |
| 114 | + ;; there is no membership to have and the name already says what it is. | |
| 115 | + ;; | |
| 116 | + ;; In a wrapper of its own, because the line comes and goes with the | |
| 117 | + ;; unread count and the reconciler numbers a card's children by position. | |
| 118 | + ;; `:wrap false` on the badges: they sit beside each other or not at all. | |
| 119 | + [:vbox {:key :badges} | |
| 120 | + (let [away? (and (not (rooms/dm? name)) (not (:joined? buffer)))] | |
| 121 | + (when (or away? (pos? unread)) | |
| 122 | + [:hbox {:spacing 12 :wrap false} | |
| 123 | + (when away? | |
| 124 | + [:status {:label "not joined" :live false}]) | |
| 125 | + ;; A mention is not more unread, it is different unread: the dot | |
| 126 | + ;; says how much there is and the name says it was aimed at you. | |
| 127 | + ;; Both or neither — a room with your name in it always has a line | |
| 128 | + ;; to count. | |
| 129 | + (when (pos? unread) | |
| 130 | + [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]))] | |
| 131 | + [:dim-label {:label (preview-line (rooms/last-preview buffer))}] | |
| 132 | + (when-not @terminal? | |
| 133 | + [:hbox {:spacing 8 :wrap false} | |
| 134 | + [open-button name] | |
| 135 | + [close-button name]])]])) | |
| 136 | + | |
| 137 | +(defn chats-screen [] | |
| 138 | + (let [buffers (rooms/channel-list)] | |
| 139 | + ;; Three bands rather than a page: the title and the two boxes at the top, | |
| 140 | + ;; the tabs at the bottom, and the conversations scrolling between them. | |
| 141 | + ;; Everything you act with stays where you last saw it — the same bargain | |
| 142 | + ;; the chat screen makes, where the compose bar holds still under a backlog | |
| 143 | + ;; that moves. | |
| 144 | + ;; | |
| 145 | + ;; What goes with the page is its centring at 620, on a window between that | |
| 146 | + ;; and the split view's 900: the list is full-bleed there now, which is | |
| 147 | + ;; what the conversation beside it has always been. | |
| 148 | + ;; `:fill-height` on the outermost box, not only on the band inside it. | |
| 149 | + ;; A backend that gives a column the height of what is in it hands the | |
| 150 | + ;; band below a window's worth of nothing to divide: the list asks for | |
| 151 | + ;; the rest of the screen, is told the rest is forty points, and the | |
| 152 | + ;; whole screen paints into a strip along the top with the window empty | |
| 153 | + ;; under it. Every screen that pins something to the bottom says this on | |
| 154 | + ;; its own root — the split view already said it, one wrapper further out. | |
| 155 | + [:vbox {:spacing 8 :margin 12 :fill-height true} | |
| 156 | + [:vbox {:key :head :spacing 8 :margin-right list-gutter} | |
| 157 | + ;; Your nick in the title, where "Chats" alone said nothing you did not | |
| 158 | + ;; already know — and said plainly, as a sign-in rather than as a name | |
| 159 | + ;; the list is somehow held in. It is what every channel calls you and | |
| 160 | + ;; what your own lines are signed with, and the only other place it | |
| 161 | + ;; showed was Settings. | |
| 162 | + [:title {:label (if (actions/connected?) | |
| 163 | + (str "Logged in as " @cells/form-nick) | |
| 164 | + "Chats")}] | |
| 165 | + [error-note] | |
| 166 | + ;; Join and search are the same shape — a channel box with a button | |
| 167 | + ;; beside it — so they read as one control block rather than a titled | |
| 168 | + ;; card and a stray row: same card, same widths, same gap. The screen | |
| 169 | + ;; title already says what the box is for, which is what the "Join | |
| 170 | + ;; channel" header was doing. | |
| 171 | + [:card {} | |
| 172 | + [:vbox {:spacing 8} | |
| 173 | + ;; Button first and `:align :end`, so the row is laid out from its | |
| 174 | + ;; right edge: the button is placed against that edge and the box takes | |
| 175 | + ;; what is left. An entry asks for whatever remains where it stands, so | |
| 176 | + ;; a box placed first takes the row and pushes the button onto a line of | |
| 177 | + ;; its own — Join under the box rather than beside it — and a box given | |
| 178 | + ;; a fixed width only holds that off until the window is narrower than | |
| 179 | + ;; the number. Laid out from the right the box is the part that gives, | |
| 180 | + ;; at every width, in the frame the drag happens rather than the one | |
| 181 | + ;; after the window is next measured. | |
| 182 | + [:hbox {:spacing 8 :align :end} | |
| 183 | + [:button {:label (if (str/starts-with? @cells/join-input "@") "Message" "Join") | |
| 184 | + :kind :primary | |
| 185 | + :on-click #(do (actions/join! @cells/join-input) (reset! cells/join-input ""))}] | |
| 186 | + ;; Both kinds of conversation through one box: `#room` joins a | |
| 187 | + ;; channel, `@nick` opens a message to a person. | |
| 188 | + [:entry {:text @cells/join-input | |
| 189 | + :placeholder "#channel or @nick" | |
| 190 | + :on-change #(reset! cells/join-input %) | |
| 191 | + :on-activate #(do (actions/join! @cells/join-input) (reset! cells/join-input ""))}]] | |
| 192 | + ;; The clear button only shows while there is something to clear: an | |
| 193 | + ;; empty box has nothing to undo, and a dead button beside it reads as | |
| 194 | + ;; one that stopped working. It leads the row for the same reason the | |
| 195 | + ;; Join button does, and the box gives up the width it takes. | |
| 196 | + [:hbox {:spacing 8 :align :end} | |
| 197 | + (when (seq @cells/search) | |
| 198 | + [:button {:label "✕" :on-click #(reset! cells/search "")}]) | |
| 199 | + [:entry {:text @cells/search | |
| 200 | + :placeholder "Search channels" | |
| 201 | + :on-change #(reset! cells/search %)}]]]]] | |
| 202 | + ;; `:fill-height` so the scroll inside is handed the rest of the window | |
| 203 | + ;; rather than the one card it starts out holding, and `:reserve` to keep | |
| 204 | + ;; the tabs' strip out of what it may take. | |
| 205 | + ;; Named, so it is the same list — and the same scroll position — when the | |
| 206 | + ;; reader comes back from a conversation. | |
| 207 | + [:vbox {:key :list :fill-height true} | |
| 208 | + [:scroll {:scroll-key "chats-list" :orientation :vertical | |
| 209 | + :reserve (below-list)} | |
| 210 | + (if (seq buffers) | |
| 211 | + ;; Keyed on the name, because this list reorders: opening a | |
| 212 | + ;; conversation bumps it to the top, and unkeyed children reconcile by | |
| 213 | + ;; position — the widgets stay put and their props are rewritten under | |
| 214 | + ;; them. What is focused is a widget, so the highlight would stay in | |
| 215 | + ;; the slot the reader clicked while the row that moved into it is | |
| 216 | + ;; someone else's conversation. | |
| 217 | + (for [b buffers] ^{:key (:name b)} [conversation-row b]) | |
| 218 | + [:vbox {:margin-right list-gutter} | |
| 219 | + [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]])]] | |
| 220 | + [:vbox {:key :foot :spacing 8} | |
| 221 | + [:separator {}] | |
| 222 | + [tab-bar]]]))\n | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,222 @@ | |||
| 1 | +(ns frq.screens.chats | ||
| 2 | + "The conversation list, shared. | ||
| 3 | + | ||
| 4 | + The second screen out of `frq.app`, and like the first it moved rather than | ||
| 5 | + changed: the same hiccup, reading `frq.cells` and `frq.rooms` instead of | ||
| 6 | + `frq.state`, and calling `frq.actions` instead of the reducers. | ||
| 7 | + | ||
| 8 | + `error-note` and `tab-bar` come with it because the list is not the only | ||
| 9 | + screen that shows them — the chat screen and settings do too, and they will | ||
| 10 | + want this namespace rather than a second copy when their turn comes." | ||
| 11 | + (:require [clojure.string :as str] | ||
| 12 | + [frq.actions :as actions] | ||
| 13 | + [frq.cells :as cells] | ||
| 14 | + [frq.metrics :refer [chrome-scale terminal?]] | ||
| 15 | + [frq.rooms :as rooms] | ||
| 16 | + [frq.screens.connect :refer [error-note]])) | ||
| 17 | + | ||
| 18 | +(defn tab-bar [] | ||
| 19 | + [:hbox {:spacing 8} | ||
| 20 | + (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]] | ||
| 21 | + [:button {:key k | ||
| 22 | + :label label | ||
| 23 | + :kind (if (= k @cells/screen) :primary :default) | ||
| 24 | + :on-click #(reset! cells/screen k)}])]) | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +;; Whether the backend under this tree is a terminal, set by `frq.tui` before | ||
| 28 | +;; the first paint and never again. Two things in a message hang on it, and | ||
| 29 | +;; both are about a cell grid rather than a canvas: there is no picture to | ||
| 30 | +;; draw a face with, and a name with nothing to its left is the heading the | ||
| 31 | +;; text hangs off — the shape a terminal has read messages in for forty years. | ||
| 32 | + | ||
| 33 | +(def ^:private list-gutter 16) | ||
| 34 | + | ||
| 35 | +(defn- below-list [] | ||
| 36 | + ;; What the strip under the list needs, counted: the gap after the list, the | ||
| 37 | + ;; separator, the gap under it, the tab bar's row of buttons and the margin | ||
| 38 | + ;; below them. Short by any of it and the tabs go off the bottom edge — which | ||
| 39 | + ;; is the thing this layout exists to stop. | ||
| 40 | + (* (chrome-scale) (+ 8 8 8 34 12))) | ||
| 41 | + | ||
| 42 | +(defn- preview-line | ||
| 43 | + "The last line of a conversation, as one line: a pasted shell script or a | ||
| 44 | + long link is a card's worth of text otherwise, and the cards stop reading as | ||
| 45 | + a list of rooms." | ||
| 46 | + [text] | ||
| 47 | + (let [line (str/replace (str text) #"\s+" " ")] | ||
| 48 | + ;; 60 rather than a card's worth: at `sidebar-width` a 90-character line is | ||
| 49 | + ;; three wrapped rows, and three rows of somebody else's last sentence is | ||
| 50 | + ;; the row shouting over the name above it. One row of it says which | ||
| 51 | + ;; conversation this is, which is all the list is for. | ||
| 52 | + (if (> (count line) 60) | ||
| 53 | + (str (subs line 0 59) "…") | ||
| 54 | + line))) | ||
| 55 | + | ||
| 56 | +;; What everything in the chats pane keeps clear of its right edge. | ||
| 57 | +;; | ||
| 58 | +;; It is one number because it is one edge: the scrollbar rides it, and the | ||
| 59 | +;; cards in the list have to stop short of the bar rather than be drawn under | ||
| 60 | +;; it — but the Join box above the list is outside the scroll and has no bar | ||
| 61 | +;; beside it, so left to itself it ran on past the cards and the pane had two | ||
| 62 | +;; right edges half a finger apart. Whatever the gap is, both of them take it. | ||
| 63 | + | ||
| 64 | +(defn- close-button [name] | ||
| 65 | + [:button {:label "Close" | ||
| 66 | + :on-click #(actions/leave-channel! name)}]) | ||
| 67 | + | ||
| 68 | +(defn- open-button [name] | ||
| 69 | + [:button {:label "Open" | ||
| 70 | + :on-click #(actions/open-channel! name)}]) | ||
| 71 | + | ||
| 72 | +;; The only way out of a room. Everything else adds one — the server saying we | ||
| 73 | +;; are in it, a message arriving in it — so without this the list only grows, | ||
| 74 | +;; and what rooms.edn claims we are in could never shrink. | ||
| 75 | + | ||
| 76 | +(defn conversation-row [buffer] | ||
| 77 | + (let [name (:name buffer) | ||
| 78 | + ;; Defaulted rather than assumed. Everything that builds a buffer | ||
| 79 | + ;; goes through `ensure-channel`, but the list draws whatever is in | ||
| 80 | + ;; the atom, and a half-made room reaching here should be a row that | ||
| 81 | + ;; reads as quiet rather than the frame that killed the client. | ||
| 82 | + unread (:unread buffer 0)] | ||
| 83 | + ;; The card in a wrapper that holds it off the right edge, where the | ||
| 84 | + ;; list's scrollbar rides — `message-row` keeps its actions clear of the | ||
| 85 | + ;; same edge for the same reason, and without it the cards are drawn | ||
| 86 | + ;; under the bar rather than beside it. The wrapper and not the card | ||
| 87 | + ;; itself: the window backend pads a card by a fixed 12 and never reads a | ||
| 88 | + ;; margin off one, so the room has to be taken outside it. | ||
| 89 | + [:vbox {:key name :margin-right list-gutter} | ||
| 90 | + [:card {} | ||
| 91 | + ;; The name gets the line, and membership and unread count the one under | ||
| 92 | + ;; it. All three on one line is what the list pane has no room for: the | ||
| 93 | + ;; name takes what it likes, the badges are left the remainder, and a | ||
| 94 | + ;; long name squeezes them into a status reading "joine/d" and a count | ||
| 95 | + ;; split down two lines — while the card, sized to a row that no longer | ||
| 96 | + ;; fits, sticks out past the cards above and below it. | ||
| 97 | + ;; | ||
| 98 | + ;; In a terminal the Open button rides up onto the name's line. A row of | ||
| 99 | + ;; chrome there is one cell, so a button on a line of its own costs the | ||
| 100 | + ;; card a whole row — four of them and the list has spent a screenful on | ||
| 101 | + ;; buttons. A window's row is 34 points and its button is a lozenge with | ||
| 102 | + ;; air around it, which is why it keeps its own line there. | ||
| 103 | + (if @terminal? | ||
| 104 | + [:hbox {:spacing 8 :wrap false} | ||
| 105 | + [:title-2 {:label name}] | ||
| 106 | + [open-button name] | ||
| 107 | + [close-button name]] | ||
| 108 | + [:title-2 {:label name}]) | ||
| 109 | + ;; The badge line, and only when there is a badge: every row carrying a | ||
| 110 | + ;; green "joined" was a row spending a third of its height saying the | ||
| 111 | + ;; ordinary thing, and a list where every card says the same word is a | ||
| 112 | + ;; list you read by skipping. So membership is reported when it is news — | ||
| 113 | + ;; a room in the list we are not in — and a DM says nothing at all, since | ||
| 114 | + ;; there is no membership to have and the name already says what it is. | ||
| 115 | + ;; | ||
| 116 | + ;; In a wrapper of its own, because the line comes and goes with the | ||
| 117 | + ;; unread count and the reconciler numbers a card's children by position. | ||
| 118 | + ;; `:wrap false` on the badges: they sit beside each other or not at all. | ||
| 119 | + [:vbox {:key :badges} | ||
| 120 | + (let [away? (and (not (rooms/dm? name)) (not (:joined? buffer)))] | ||
| 121 | + (when (or away? (pos? unread)) | ||
| 122 | + [:hbox {:spacing 12 :wrap false} | ||
| 123 | + (when away? | ||
| 124 | + [:status {:label "not joined" :live false}]) | ||
| 125 | + ;; A mention is not more unread, it is different unread: the dot | ||
| 126 | + ;; says how much there is and the name says it was aimed at you. | ||
| 127 | + ;; Both or neither — a room with your name in it always has a line | ||
| 128 | + ;; to count. | ||
| 129 | + (when (pos? unread) | ||
| 130 | + [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]))] | ||
| 131 | + [:dim-label {:label (preview-line (rooms/last-preview buffer))}] | ||
| 132 | + (when-not @terminal? | ||
| 133 | + [:hbox {:spacing 8 :wrap false} | ||
| 134 | + [open-button name] | ||
| 135 | + [close-button name]])]])) | ||
| 136 | + | ||
| 137 | +(defn chats-screen [] | ||
| 138 | + (let [buffers (rooms/channel-list)] | ||
| 139 | + ;; Three bands rather than a page: the title and the two boxes at the top, | ||
| 140 | + ;; the tabs at the bottom, and the conversations scrolling between them. | ||
| 141 | + ;; Everything you act with stays where you last saw it — the same bargain | ||
| 142 | + ;; the chat screen makes, where the compose bar holds still under a backlog | ||
| 143 | + ;; that moves. | ||
| 144 | + ;; | ||
| 145 | + ;; What goes with the page is its centring at 620, on a window between that | ||
| 146 | + ;; and the split view's 900: the list is full-bleed there now, which is | ||
| 147 | + ;; what the conversation beside it has always been. | ||
| 148 | + ;; `:fill-height` on the outermost box, not only on the band inside it. | ||
| 149 | + ;; A backend that gives a column the height of what is in it hands the | ||
| 150 | + ;; band below a window's worth of nothing to divide: the list asks for | ||
| 151 | + ;; the rest of the screen, is told the rest is forty points, and the | ||
| 152 | + ;; whole screen paints into a strip along the top with the window empty | ||
| 153 | + ;; under it. Every screen that pins something to the bottom says this on | ||
| 154 | + ;; its own root — the split view already said it, one wrapper further out. | ||
| 155 | + [:vbox {:spacing 8 :margin 12 :fill-height true} | ||
| 156 | + [:vbox {:key :head :spacing 8 :margin-right list-gutter} | ||
| 157 | + ;; Your nick in the title, where "Chats" alone said nothing you did not | ||
| 158 | + ;; already know — and said plainly, as a sign-in rather than as a name | ||
| 159 | + ;; the list is somehow held in. It is what every channel calls you and | ||
| 160 | + ;; what your own lines are signed with, and the only other place it | ||
| 161 | + ;; showed was Settings. | ||
| 162 | + [:title {:label (if (actions/connected?) | ||
| 163 | + (str "Logged in as " @cells/form-nick) | ||
| 164 | + "Chats")}] | ||
| 165 | + [error-note] | ||
| 166 | + ;; Join and search are the same shape — a channel box with a button | ||
| 167 | + ;; beside it — so they read as one control block rather than a titled | ||
| 168 | + ;; card and a stray row: same card, same widths, same gap. The screen | ||
| 169 | + ;; title already says what the box is for, which is what the "Join | ||
| 170 | + ;; channel" header was doing. | ||
| 171 | + [:card {} | ||
| 172 | + [:vbox {:spacing 8} | ||
| 173 | + ;; Button first and `:align :end`, so the row is laid out from its | ||
| 174 | + ;; right edge: the button is placed against that edge and the box takes | ||
| 175 | + ;; what is left. An entry asks for whatever remains where it stands, so | ||
| 176 | + ;; a box placed first takes the row and pushes the button onto a line of | ||
| 177 | + ;; its own — Join under the box rather than beside it — and a box given | ||
| 178 | + ;; a fixed width only holds that off until the window is narrower than | ||
| 179 | + ;; the number. Laid out from the right the box is the part that gives, | ||
| 180 | + ;; at every width, in the frame the drag happens rather than the one | ||
| 181 | + ;; after the window is next measured. | ||
| 182 | + [:hbox {:spacing 8 :align :end} | ||
| 183 | + [:button {:label (if (str/starts-with? @cells/join-input "@") "Message" "Join") | ||
| 184 | + :kind :primary | ||
| 185 | + :on-click #(do (actions/join! @cells/join-input) (reset! cells/join-input ""))}] | ||
| 186 | + ;; Both kinds of conversation through one box: `#room` joins a | ||
| 187 | + ;; channel, `@nick` opens a message to a person. | ||
| 188 | + [:entry {:text @cells/join-input | ||
| 189 | + :placeholder "#channel or @nick" | ||
| 190 | + :on-change #(reset! cells/join-input %) | ||
| 191 | + :on-activate #(do (actions/join! @cells/join-input) (reset! cells/join-input ""))}]] | ||
| 192 | + ;; The clear button only shows while there is something to clear: an | ||
| 193 | + ;; empty box has nothing to undo, and a dead button beside it reads as | ||
| 194 | + ;; one that stopped working. It leads the row for the same reason the | ||
| 195 | + ;; Join button does, and the box gives up the width it takes. | ||
| 196 | + [:hbox {:spacing 8 :align :end} | ||
| 197 | + (when (seq @cells/search) | ||
| 198 | + [:button {:label "✕" :on-click #(reset! cells/search "")}]) | ||
| 199 | + [:entry {:text @cells/search | ||
| 200 | + :placeholder "Search channels" | ||
| 201 | + :on-change #(reset! cells/search %)}]]]]] | ||
| 202 | + ;; `:fill-height` so the scroll inside is handed the rest of the window | ||
| 203 | + ;; rather than the one card it starts out holding, and `:reserve` to keep | ||
| 204 | + ;; the tabs' strip out of what it may take. | ||
| 205 | + ;; Named, so it is the same list — and the same scroll position — when the | ||
| 206 | + ;; reader comes back from a conversation. | ||
| 207 | + [:vbox {:key :list :fill-height true} | ||
| 208 | + [:scroll {:scroll-key "chats-list" :orientation :vertical | ||
| 209 | + :reserve (below-list)} | ||
| 210 | + (if (seq buffers) | ||
| 211 | + ;; Keyed on the name, because this list reorders: opening a | ||
| 212 | + ;; conversation bumps it to the top, and unkeyed children reconcile by | ||
| 213 | + ;; position — the widgets stay put and their props are rewritten under | ||
| 214 | + ;; them. What is focused is a widget, so the highlight would stay in | ||
| 215 | + ;; the slot the reader clicked while the row that moved into it is | ||
| 216 | + ;; someone else's conversation. | ||
| 217 | + (for [b buffers] ^{:key (:name b)} [conversation-row b]) | ||
| 218 | + [:vbox {:margin-right list-gutter} | ||
| 219 | + [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]])]] | ||
| 220 | + [:vbox {:key :foot :spacing 8} | ||
| 221 | + [:separator {}] | ||
| 222 | + [tab-bar]]]))\n | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
flutter/README.md +20 -0 | @@ -135,6 +135,26 @@ screen rather than the subtree that read it. Fine at this size. | ||
| 135 | 135 | reads is `frq.cells` and what it calls is `frq.actions`, and each platform |
| 136 | 136 | fills those in: `frq.state`'s reducers on the desktop, dart:io here. |
| 137 | 137 | |
| 138 | +## Two screens shared, and where the renderer stops | |
| 139 | + | |
| 140 | +`frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the | |
| 141 | +cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend | |
| 142 | +metrics in `frq.metrics` and the things a screen cannot do itself behind | |
| 143 | +`frq.actions`. `frq.app` requires both and the desktop draws them — verified in | |
| 144 | +the TUI, including the conversation list with its rooms and previews. | |
| 145 | + | |
| 146 | +The phone draws the connect screen. It does not draw the chats one yet, and | |
| 147 | +the gap is in `frq.hiccup` rather than in the screen: `:fill-height` is iced's | |
| 148 | +`Length::Fill`, the space left over, which is Flutter's `Expanded` — not a | |
| 149 | +taller `mainAxisSize`. Getting that onto the right children without breaking | |
| 150 | +the bands around them is unfinished; the attempt blanked the screen and was | |
| 151 | +reverted rather than shipped half-working. | |
| 152 | + | |
| 153 | +`:entry` is the same lesson twice. An entry with no `:width-request` fills its | |
| 154 | +row, which in a Row is `Expanded`; the connect screen's server boxes carry | |
| 155 | +widths and came out fine, and the chats screen's join box carries none and did | |
| 156 | +not. | |
| 157 | + | |
| 138 | 158 | ## The order to do the rest in |
| 139 | 159 | |
| 140 | 160 | 1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now, |
| @@ -135,6 +135,26 @@ screen rather than the subtree that read it. Fine at this size. | |||
| 135 | reads is `frq.cells` and what it calls is `frq.actions`, and each platform | 135 | reads is `frq.cells` and what it calls is `frq.actions`, and each platform |
| 136 | fills those in: `frq.state`'s reducers on the desktop, dart:io here. | 136 | fills those in: `frq.state`'s reducers on the desktop, dart:io here. |
| 137 | 137 | ||
| 138 | +## Two screens shared, and where the renderer stops | ||
| 139 | + | ||
| 140 | +`frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the | ||
| 141 | +cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend | ||
| 142 | +metrics in `frq.metrics` and the things a screen cannot do itself behind | ||
| 143 | +`frq.actions`. `frq.app` requires both and the desktop draws them — verified in | ||
| 144 | +the TUI, including the conversation list with its rooms and previews. | ||
| 145 | + | ||
| 146 | +The phone draws the connect screen. It does not draw the chats one yet, and | ||
| 147 | +the gap is in `frq.hiccup` rather than in the screen: `:fill-height` is iced's | ||
| 148 | +`Length::Fill`, the space left over, which is Flutter's `Expanded` — not a | ||
| 149 | +taller `mainAxisSize`. Getting that onto the right children without breaking | ||
| 150 | +the bands around them is unfinished; the attempt blanked the screen and was | ||
| 151 | +reverted rather than shipped half-working. | ||
| 152 | + | ||
| 153 | +`:entry` is the same lesson twice. An entry with no `:width-request` fills its | ||
| 154 | +row, which in a Row is `Expanded`; the connect screen's server boxes carry | ||
| 155 | +widths and came out fine, and the chats screen's join box carries none and did | ||
| 156 | +not. | ||
| 157 | + | ||
| 138 | ## The order to do the rest in | 158 | ## The order to do the rest in |
| 139 | 159 | ||
| 140 | 1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now, | 160 | 1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now, |
modified
src/frq/app.clj +13 -208 | @@ -29,33 +29,25 @@ | ||
| 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.metrics :as metrics] | |
| 32 | 33 | [frq.screens.connect :as connect :refer [connect-screen error-note]] |
| 34 | + ;; The conversation list moved out too, with the tab bar and the | |
| 35 | + ;; row it paints. Same arrangement as the connect screen: the | |
| 36 | + ;; phone renders this very file. | |
| 37 | + [frq.screens.chats :refer [below-list chats-screen conversation-row | |
| 38 | + preview-line tab-bar]] | |
| 33 | 39 | [frq.state :as s])) |
| 34 | 40 | |
| 35 | 41 | ;; ---------------------------------------------------------------- pieces |
| 36 | 42 | |
| 37 | -(defn tab-bar [] | |
| 38 | - [:hbox {:spacing 8} | |
| 39 | - (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]] | |
| 40 | - [:button {:key k | |
| 41 | - :label label | |
| 42 | - :kind (if (= k @s/screen) :primary :default) | |
| 43 | - :on-click #(reset! s/screen k)}])]) | |
| 44 | - | |
| 45 | - | |
| 46 | -;; Whether the backend under this tree is a terminal, set by `frq.tui` before | |
| 47 | -;; the first paint and never again. Two things in a message hang on it, and | |
| 48 | -;; both are about a cell grid rather than a canvas: there is no picture to | |
| 49 | -;; draw a face with, and a name with nothing to its left is the heading the | |
| 50 | -;; text hangs off — the shape a terminal has read messages in for forty years. | |
| 51 | -(defonce terminal? (atom false)) | |
| 43 | +(def terminal? metrics/terminal?) | |
| 52 | 44 | |
| 53 | 45 | ;; And whether that terminal draws pictures over its cells — Kitty's graphics |
| 54 | 46 | ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not. |
| 55 | 47 | ;; `frq.tui` works it out from the environment and sets this beside the flag |
| 56 | 48 | ;; above; a terminal that has not got it is the one described there, a column |
| 57 | 49 | ;; of names with the words hanging under them. |
| 58 | -(defonce terminal-graphics? (atom false)) | |
| 50 | +(def terminal-graphics? metrics/terminal-graphics?) | |
| 59 | 51 | |
| 60 | 52 | (defn- terminal-face? |
| 61 | 53 | "Whether a message carries a picture of its sender in a terminal. |
| @@ -114,101 +106,6 @@ | ||
| 114 | 106 | ;; rhythm down the list the one word that is the same on every row, with the |
| 115 | 107 | ;; name that differs set quieter than it. Nothing moves; the button stops |
| 116 | 108 | ;; shouting. |
| 117 | -(defn- open-button [name] | |
| 118 | - [:button {:label "Open" | |
| 119 | - :on-click #(s/open-channel! name)}]) | |
| 120 | - | |
| 121 | -;; The only way out of a room. Everything else adds one — the server saying we | |
| 122 | -;; are in it, a message arriving in it — so without this the list only grows, | |
| 123 | -;; and what rooms.edn claims we are in could never shrink. | |
| 124 | -(defn- close-button [name] | |
| 125 | - [:button {:label "Close" | |
| 126 | - :on-click #(s/leave-channel! name)}]) | |
| 127 | - | |
| 128 | -(defn- preview-line | |
| 129 | - "The last line of a conversation, as one line: a pasted shell script or a | |
| 130 | - long link is a card's worth of text otherwise, and the cards stop reading as | |
| 131 | - a list of rooms." | |
| 132 | - [text] | |
| 133 | - (let [line (str/replace (str text) #"\s+" " ")] | |
| 134 | - ;; 60 rather than a card's worth: at `sidebar-width` a 90-character line is | |
| 135 | - ;; three wrapped rows, and three rows of somebody else's last sentence is | |
| 136 | - ;; the row shouting over the name above it. One row of it says which | |
| 137 | - ;; conversation this is, which is all the list is for. | |
| 138 | - (if (> (count line) 60) | |
| 139 | - (str (subs line 0 59) "…") | |
| 140 | - line))) | |
| 141 | - | |
| 142 | -;; What everything in the chats pane keeps clear of its right edge. | |
| 143 | -;; | |
| 144 | -;; It is one number because it is one edge: the scrollbar rides it, and the | |
| 145 | -;; cards in the list have to stop short of the bar rather than be drawn under | |
| 146 | -;; it — but the Join box above the list is outside the scroll and has no bar | |
| 147 | -;; beside it, so left to itself it ran on past the cards and the pane had two | |
| 148 | -;; right edges half a finger apart. Whatever the gap is, both of them take it. | |
| 149 | -(def ^:private list-gutter 16) | |
| 150 | - | |
| 151 | -(defn conversation-row [buffer] | |
| 152 | - (let [name (:name buffer) | |
| 153 | - ;; Defaulted rather than assumed. Everything that builds a buffer | |
| 154 | - ;; goes through `ensure-channel`, but the list draws whatever is in | |
| 155 | - ;; the atom, and a half-made room reaching here should be a row that | |
| 156 | - ;; reads as quiet rather than the frame that killed the client. | |
| 157 | - unread (:unread buffer 0)] | |
| 158 | - ;; The card in a wrapper that holds it off the right edge, where the | |
| 159 | - ;; list's scrollbar rides — `message-row` keeps its actions clear of the | |
| 160 | - ;; same edge for the same reason, and without it the cards are drawn | |
| 161 | - ;; under the bar rather than beside it. The wrapper and not the card | |
| 162 | - ;; itself: the window backend pads a card by a fixed 12 and never reads a | |
| 163 | - ;; margin off one, so the room has to be taken outside it. | |
| 164 | - [:vbox {:key name :margin-right list-gutter} | |
| 165 | - [:card {} | |
| 166 | - ;; The name gets the line, and membership and unread count the one under | |
| 167 | - ;; it. All three on one line is what the list pane has no room for: the | |
| 168 | - ;; name takes what it likes, the badges are left the remainder, and a | |
| 169 | - ;; long name squeezes them into a status reading "joine/d" and a count | |
| 170 | - ;; split down two lines — while the card, sized to a row that no longer | |
| 171 | - ;; fits, sticks out past the cards above and below it. | |
| 172 | - ;; | |
| 173 | - ;; In a terminal the Open button rides up onto the name's line. A row of | |
| 174 | - ;; chrome there is one cell, so a button on a line of its own costs the | |
| 175 | - ;; card a whole row — four of them and the list has spent a screenful on | |
| 176 | - ;; buttons. A window's row is 34 points and its button is a lozenge with | |
| 177 | - ;; air around it, which is why it keeps its own line there. | |
| 178 | - (if @terminal? | |
| 179 | - [:hbox {:spacing 8 :wrap false} | |
| 180 | - [:title-2 {:label name}] | |
| 181 | - [open-button name] | |
| 182 | - [close-button name]] | |
| 183 | - [:title-2 {:label name}]) | |
| 184 | - ;; The badge line, and only when there is a badge: every row carrying a | |
| 185 | - ;; green "joined" was a row spending a third of its height saying the | |
| 186 | - ;; ordinary thing, and a list where every card says the same word is a | |
| 187 | - ;; list you read by skipping. So membership is reported when it is news — | |
| 188 | - ;; a room in the list we are not in — and a DM says nothing at all, since | |
| 189 | - ;; there is no membership to have and the name already says what it is. | |
| 190 | - ;; | |
| 191 | - ;; In a wrapper of its own, because the line comes and goes with the | |
| 192 | - ;; unread count and the reconciler numbers a card's children by position. | |
| 193 | - ;; `:wrap false` on the badges: they sit beside each other or not at all. | |
| 194 | - [:vbox {:key :badges} | |
| 195 | - (let [away? (and (not (s/dm? name)) (not (:joined? buffer)))] | |
| 196 | - (when (or away? (pos? unread)) | |
| 197 | - [:hbox {:spacing 12 :wrap false} | |
| 198 | - (when away? | |
| 199 | - [:status {:label "not joined" :live false}]) | |
| 200 | - ;; A mention is not more unread, it is different unread: the dot | |
| 201 | - ;; says how much there is and the name says it was aimed at you. | |
| 202 | - ;; Both or neither — a room with your name in it always has a line | |
| 203 | - ;; to count. | |
| 204 | - (when (pos? unread) | |
| 205 | - [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]))] | |
| 206 | - [:dim-label {:label (preview-line (s/last-preview buffer))}] | |
| 207 | - (when-not @terminal? | |
| 208 | - [:hbox {:spacing 8 :wrap false} | |
| 209 | - [open-button name] | |
| 210 | - [close-button name]])]])) | |
| 211 | - | |
| 212 | 109 | (def ^:private sidebar-width 320) |
| 213 | 110 | |
| 214 | 111 | ;; How tall a row of chrome is — a button, the compose bar, a line of tabs. |
| @@ -223,10 +120,12 @@ | ||
| 223 | 120 | ;; backend whose rows are a different height says so here. Nothing else in the |
| 224 | 121 | ;; tree needs it: every other number is a length, and a length scales on the |
| 225 | 122 | ;; way across. |
| 226 | -(def ^:private window-row 34) | |
| 227 | -(defonce chrome-row (atom window-row)) | |
| 123 | +(def ^:private window-row metrics/window-row) | |
| 124 | +;; 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. | |
| 126 | +(def chrome-row metrics/chrome-row) | |
| 228 | 127 | |
| 229 | -(defn- chrome-scale [] (/ (double @chrome-row) window-row)) | |
| 128 | +(def ^:private chrome-scale metrics/chrome-scale) | |
| 230 | 129 | |
| 231 | 130 | (defn- chip-gap |
| 232 | 131 | "The air between two chips on a row. |
| @@ -265,100 +164,6 @@ | ||
| 265 | 164 | node] |
| 266 | 165 | node)) |
| 267 | 166 | |
| 268 | -(defn- below-list [] | |
| 269 | - ;; What the strip under the list needs, counted: the gap after the list, the | |
| 270 | - ;; separator, the gap under it, the tab bar's row of buttons and the margin | |
| 271 | - ;; below them. Short by any of it and the tabs go off the bottom edge — which | |
| 272 | - ;; is the thing this layout exists to stop. | |
| 273 | - (* (chrome-scale) (+ 8 8 8 34 12))) | |
| 274 | - | |
| 275 | -(defn chats-screen [] | |
| 276 | - (let [buffers (s/channel-list)] | |
| 277 | - ;; Three bands rather than a page: the title and the two boxes at the top, | |
| 278 | - ;; the tabs at the bottom, and the conversations scrolling between them. | |
| 279 | - ;; Everything you act with stays where you last saw it — the same bargain | |
| 280 | - ;; the chat screen makes, where the compose bar holds still under a backlog | |
| 281 | - ;; that moves. | |
| 282 | - ;; | |
| 283 | - ;; What goes with the page is its centring at 620, on a window between that | |
| 284 | - ;; and the split view's 900: the list is full-bleed there now, which is | |
| 285 | - ;; what the conversation beside it has always been. | |
| 286 | - ;; `:fill-height` on the outermost box, not only on the band inside it. | |
| 287 | - ;; A backend that gives a column the height of what is in it hands the | |
| 288 | - ;; band below a window's worth of nothing to divide: the list asks for | |
| 289 | - ;; the rest of the screen, is told the rest is forty points, and the | |
| 290 | - ;; whole screen paints into a strip along the top with the window empty | |
| 291 | - ;; under it. Every screen that pins something to the bottom says this on | |
| 292 | - ;; its own root — the split view already said it, one wrapper further out. | |
| 293 | - [:vbox {:spacing 8 :margin 12 :fill-height true} | |
| 294 | - [:vbox {:key :head :spacing 8 :margin-right list-gutter} | |
| 295 | - ;; Your nick in the title, where "Chats" alone said nothing you did not | |
| 296 | - ;; already know — and said plainly, as a sign-in rather than as a name | |
| 297 | - ;; the list is somehow held in. It is what every channel calls you and | |
| 298 | - ;; what your own lines are signed with, and the only other place it | |
| 299 | - ;; showed was Settings. | |
| 300 | - [:title {:label (if (s/connected?) | |
| 301 | - (str "Logged in as " @s/form-nick) | |
| 302 | - "Chats")}] | |
| 303 | - [error-note] | |
| 304 | - ;; Join and search are the same shape — a channel box with a button | |
| 305 | - ;; beside it — so they read as one control block rather than a titled | |
| 306 | - ;; card and a stray row: same card, same widths, same gap. The screen | |
| 307 | - ;; title already says what the box is for, which is what the "Join | |
| 308 | - ;; channel" header was doing. | |
| 309 | - [:card {} | |
| 310 | - [:vbox {:spacing 8} | |
| 311 | - ;; Button first and `:align :end`, so the row is laid out from its | |
| 312 | - ;; right edge: the button is placed against that edge and the box takes | |
| 313 | - ;; what is left. An entry asks for whatever remains where it stands, so | |
| 314 | - ;; a box placed first takes the row and pushes the button onto a line of | |
| 315 | - ;; its own — Join under the box rather than beside it — and a box given | |
| 316 | - ;; a fixed width only holds that off until the window is narrower than | |
| 317 | - ;; the number. Laid out from the right the box is the part that gives, | |
| 318 | - ;; at every width, in the frame the drag happens rather than the one | |
| 319 | - ;; after the window is next measured. | |
| 320 | - [:hbox {:spacing 8 :align :end} | |
| 321 | - [:button {:label (if (str/starts-with? @s/join-input "@") "Message" "Join") | |
| 322 | - :kind :primary | |
| 323 | - :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}] | |
| 324 | - ;; Both kinds of conversation through one box: `#room` joins a | |
| 325 | - ;; channel, `@nick` opens a message to a person. | |
| 326 | - [:entry {:text @s/join-input | |
| 327 | - :placeholder "#channel or @nick" | |
| 328 | - :on-change #(reset! s/join-input %) | |
| 329 | - :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}]] | |
| 330 | - ;; The clear button only shows while there is something to clear: an | |
| 331 | - ;; empty box has nothing to undo, and a dead button beside it reads as | |
| 332 | - ;; one that stopped working. It leads the row for the same reason the | |
| 333 | - ;; Join button does, and the box gives up the width it takes. | |
| 334 | - [:hbox {:spacing 8 :align :end} | |
| 335 | - (when (seq @s/search) | |
| 336 | - [:button {:label "✕" :on-click #(reset! s/search "")}]) | |
| 337 | - [:entry {:text @s/search | |
| 338 | - :placeholder "Search channels" | |
| 339 | - :on-change #(reset! s/search %)}]]]]] | |
| 340 | - ;; `:fill-height` so the scroll inside is handed the rest of the window | |
| 341 | - ;; rather than the one card it starts out holding, and `:reserve` to keep | |
| 342 | - ;; the tabs' strip out of what it may take. | |
| 343 | - ;; Named, so it is the same list — and the same scroll position — when the | |
| 344 | - ;; reader comes back from a conversation. | |
| 345 | - [:vbox {:key :list :fill-height true} | |
| 346 | - [:scroll {:scroll-key "chats-list" :orientation :vertical | |
| 347 | - :reserve (below-list)} | |
| 348 | - (if (seq buffers) | |
| 349 | - ;; Keyed on the name, because this list reorders: opening a | |
| 350 | - ;; conversation bumps it to the top, and unkeyed children reconcile by | |
| 351 | - ;; position — the widgets stay put and their props are rewritten under | |
| 352 | - ;; them. What is focused is a widget, so the highlight would stay in | |
| 353 | - ;; the slot the reader clicked while the row that moved into it is | |
| 354 | - ;; someone else's conversation. | |
| 355 | - (for [b buffers] ^{:key (:name b)} [conversation-row b]) | |
| 356 | - [:vbox {:margin-right list-gutter} | |
| 357 | - [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]])]] | |
| 358 | - [:vbox {:key :foot :spacing 8} | |
| 359 | - [:separator {}] | |
| 360 | - [tab-bar]]])) | |
| 361 | - | |
| 362 | 167 | ;; ---------------------------------------------------------------- chat |
| 363 | 168 | |
| 364 | 169 | (def ^:private url-pattern #"https?://[^\s<>\"]+") |
| @@ -29,33 +29,25 @@ | |||
| 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.metrics :as metrics] | ||
| 32 | [frq.screens.connect :as connect :refer [connect-screen error-note]] | 33 | [frq.screens.connect :as connect :refer [connect-screen error-note]] |
| 34 | + ;; The conversation list moved out too, with the tab bar and the | ||
| 35 | + ;; row it paints. Same arrangement as the connect screen: the | ||
| 36 | + ;; phone renders this very file. | ||
| 37 | + [frq.screens.chats :refer [below-list chats-screen conversation-row | ||
| 38 | + preview-line tab-bar]] | ||
| 33 | [frq.state :as s])) | 39 | [frq.state :as s])) |
| 34 | 40 | ||
| 35 | ;; ---------------------------------------------------------------- pieces | 41 | ;; ---------------------------------------------------------------- pieces |
| 36 | 42 | ||
| 37 | -(defn tab-bar [] | 43 | +(def terminal? metrics/terminal?) |
| 38 | - [:hbox {:spacing 8} | ||
| 39 | - (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]] | ||
| 40 | - [:button {:key k | ||
| 41 | - :label label | ||
| 42 | - :kind (if (= k @s/screen) :primary :default) | ||
| 43 | - :on-click #(reset! s/screen k)}])]) | ||
| 44 | - | ||
| 45 | - | ||
| 46 | -;; Whether the backend under this tree is a terminal, set by `frq.tui` before | ||
| 47 | -;; the first paint and never again. Two things in a message hang on it, and | ||
| 48 | -;; both are about a cell grid rather than a canvas: there is no picture to | ||
| 49 | -;; draw a face with, and a name with nothing to its left is the heading the | ||
| 50 | -;; text hangs off — the shape a terminal has read messages in for forty years. | ||
| 51 | -(defonce terminal? (atom false)) | ||
| 52 | 44 | ||
| 53 | ;; And whether that terminal draws pictures over its cells — Kitty's graphics | 45 | ;; And whether that terminal draws pictures over its cells — Kitty's graphics |
| 54 | ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not. | 46 | ;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not. |
| 55 | ;; `frq.tui` works it out from the environment and sets this beside the flag | 47 | ;; `frq.tui` works it out from the environment and sets this beside the flag |
| 56 | ;; above; a terminal that has not got it is the one described there, a column | 48 | ;; above; a terminal that has not got it is the one described there, a column |
| 57 | ;; of names with the words hanging under them. | 49 | ;; of names with the words hanging under them. |
| 58 | -(defonce terminal-graphics? (atom false)) | 50 | +(def terminal-graphics? metrics/terminal-graphics?) |
| 59 | 51 | ||
| 60 | (defn- terminal-face? | 52 | (defn- terminal-face? |
| 61 | "Whether a message carries a picture of its sender in a terminal. | 53 | "Whether a message carries a picture of its sender in a terminal. |
| @@ -114,101 +106,6 @@ | |||
| 114 | ;; rhythm down the list the one word that is the same on every row, with the | 106 | ;; rhythm down the list the one word that is the same on every row, with the |
| 115 | ;; name that differs set quieter than it. Nothing moves; the button stops | 107 | ;; name that differs set quieter than it. Nothing moves; the button stops |
| 116 | ;; shouting. | 108 | ;; shouting. |
| 117 | -(defn- open-button [name] | ||
| 118 | - [:button {:label "Open" | ||
| 119 | - :on-click #(s/open-channel! name)}]) | ||
| 120 | - | ||
| 121 | -;; The only way out of a room. Everything else adds one — the server saying we | ||
| 122 | -;; are in it, a message arriving in it — so without this the list only grows, | ||
| 123 | -;; and what rooms.edn claims we are in could never shrink. | ||
| 124 | -(defn- close-button [name] | ||
| 125 | - [:button {:label "Close" | ||
| 126 | - :on-click #(s/leave-channel! name)}]) | ||
| 127 | - | ||
| 128 | -(defn- preview-line | ||
| 129 | - "The last line of a conversation, as one line: a pasted shell script or a | ||
| 130 | - long link is a card's worth of text otherwise, and the cards stop reading as | ||
| 131 | - a list of rooms." | ||
| 132 | - [text] | ||
| 133 | - (let [line (str/replace (str text) #"\s+" " ")] | ||
| 134 | - ;; 60 rather than a card's worth: at `sidebar-width` a 90-character line is | ||
| 135 | - ;; three wrapped rows, and three rows of somebody else's last sentence is | ||
| 136 | - ;; the row shouting over the name above it. One row of it says which | ||
| 137 | - ;; conversation this is, which is all the list is for. | ||
| 138 | - (if (> (count line) 60) | ||
| 139 | - (str (subs line 0 59) "…") | ||
| 140 | - line))) | ||
| 141 | - | ||
| 142 | -;; What everything in the chats pane keeps clear of its right edge. | ||
| 143 | -;; | ||
| 144 | -;; It is one number because it is one edge: the scrollbar rides it, and the | ||
| 145 | -;; cards in the list have to stop short of the bar rather than be drawn under | ||
| 146 | -;; it — but the Join box above the list is outside the scroll and has no bar | ||
| 147 | -;; beside it, so left to itself it ran on past the cards and the pane had two | ||
| 148 | -;; right edges half a finger apart. Whatever the gap is, both of them take it. | ||
| 149 | -(def ^:private list-gutter 16) | ||
| 150 | - | ||
| 151 | -(defn conversation-row [buffer] | ||
| 152 | - (let [name (:name buffer) | ||
| 153 | - ;; Defaulted rather than assumed. Everything that builds a buffer | ||
| 154 | - ;; goes through `ensure-channel`, but the list draws whatever is in | ||
| 155 | - ;; the atom, and a half-made room reaching here should be a row that | ||
| 156 | - ;; reads as quiet rather than the frame that killed the client. | ||
| 157 | - unread (:unread buffer 0)] | ||
| 158 | - ;; The card in a wrapper that holds it off the right edge, where the | ||
| 159 | - ;; list's scrollbar rides — `message-row` keeps its actions clear of the | ||
| 160 | - ;; same edge for the same reason, and without it the cards are drawn | ||
| 161 | - ;; under the bar rather than beside it. The wrapper and not the card | ||
| 162 | - ;; itself: the window backend pads a card by a fixed 12 and never reads a | ||
| 163 | - ;; margin off one, so the room has to be taken outside it. | ||
| 164 | - [:vbox {:key name :margin-right list-gutter} | ||
| 165 | - [:card {} | ||
| 166 | - ;; The name gets the line, and membership and unread count the one under | ||
| 167 | - ;; it. All three on one line is what the list pane has no room for: the | ||
| 168 | - ;; name takes what it likes, the badges are left the remainder, and a | ||
| 169 | - ;; long name squeezes them into a status reading "joine/d" and a count | ||
| 170 | - ;; split down two lines — while the card, sized to a row that no longer | ||
| 171 | - ;; fits, sticks out past the cards above and below it. | ||
| 172 | - ;; | ||
| 173 | - ;; In a terminal the Open button rides up onto the name's line. A row of | ||
| 174 | - ;; chrome there is one cell, so a button on a line of its own costs the | ||
| 175 | - ;; card a whole row — four of them and the list has spent a screenful on | ||
| 176 | - ;; buttons. A window's row is 34 points and its button is a lozenge with | ||
| 177 | - ;; air around it, which is why it keeps its own line there. | ||
| 178 | - (if @terminal? | ||
| 179 | - [:hbox {:spacing 8 :wrap false} | ||
| 180 | - [:title-2 {:label name}] | ||
| 181 | - [open-button name] | ||
| 182 | - [close-button name]] | ||
| 183 | - [:title-2 {:label name}]) | ||
| 184 | - ;; The badge line, and only when there is a badge: every row carrying a | ||
| 185 | - ;; green "joined" was a row spending a third of its height saying the | ||
| 186 | - ;; ordinary thing, and a list where every card says the same word is a | ||
| 187 | - ;; list you read by skipping. So membership is reported when it is news — | ||
| 188 | - ;; a room in the list we are not in — and a DM says nothing at all, since | ||
| 189 | - ;; there is no membership to have and the name already says what it is. | ||
| 190 | - ;; | ||
| 191 | - ;; In a wrapper of its own, because the line comes and goes with the | ||
| 192 | - ;; unread count and the reconciler numbers a card's children by position. | ||
| 193 | - ;; `:wrap false` on the badges: they sit beside each other or not at all. | ||
| 194 | - [:vbox {:key :badges} | ||
| 195 | - (let [away? (and (not (s/dm? name)) (not (:joined? buffer)))] | ||
| 196 | - (when (or away? (pos? unread)) | ||
| 197 | - [:hbox {:spacing 12 :wrap false} | ||
| 198 | - (when away? | ||
| 199 | - [:status {:label "not joined" :live false}]) | ||
| 200 | - ;; A mention is not more unread, it is different unread: the dot | ||
| 201 | - ;; says how much there is and the name says it was aimed at you. | ||
| 202 | - ;; Both or neither — a room with your name in it always has a line | ||
| 203 | - ;; to count. | ||
| 204 | - (when (pos? unread) | ||
| 205 | - [:label {:label (str (if (:mention? buffer) "◆ @ " "● ") unread)}])]))] | ||
| 206 | - [:dim-label {:label (preview-line (s/last-preview buffer))}] | ||
| 207 | - (when-not @terminal? | ||
| 208 | - [:hbox {:spacing 8 :wrap false} | ||
| 209 | - [open-button name] | ||
| 210 | - [close-button name]])]])) | ||
| 211 | - | ||
| 212 | (def ^:private sidebar-width 320) | 109 | (def ^:private sidebar-width 320) |
| 213 | 110 | ||
| 214 | ;; How tall a row of chrome is — a button, the compose bar, a line of tabs. | 111 | ;; How tall a row of chrome is — a button, the compose bar, a line of tabs. |
| @@ -223,10 +120,12 @@ | |||
| 223 | ;; backend whose rows are a different height says so here. Nothing else in the | 120 | ;; backend whose rows are a different height says so here. Nothing else in the |
| 224 | ;; tree needs it: every other number is a length, and a length scales on the | 121 | ;; tree needs it: every other number is a length, and a length scales on the |
| 225 | ;; way across. | 122 | ;; way across. |
| 226 | -(def ^:private window-row 34) | 123 | +(def ^:private window-row metrics/window-row) |
| 227 | -(defonce chrome-row (atom window-row)) | 124 | +;; 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. | ||
| 126 | +(def chrome-row metrics/chrome-row) | ||
| 228 | 127 | ||
| 229 | -(defn- chrome-scale [] (/ (double @chrome-row) window-row)) | 128 | +(def ^:private chrome-scale metrics/chrome-scale) |
| 230 | 129 | ||
| 231 | (defn- chip-gap | 130 | (defn- chip-gap |
| 232 | "The air between two chips on a row. | 131 | "The air between two chips on a row. |
| @@ -265,100 +164,6 @@ | |||
| 265 | node] | 164 | node] |
| 266 | node)) | 165 | node)) |
| 267 | 166 | ||
| 268 | -(defn- below-list [] | ||
| 269 | - ;; What the strip under the list needs, counted: the gap after the list, the | ||
| 270 | - ;; separator, the gap under it, the tab bar's row of buttons and the margin | ||
| 271 | - ;; below them. Short by any of it and the tabs go off the bottom edge — which | ||
| 272 | - ;; is the thing this layout exists to stop. | ||
| 273 | - (* (chrome-scale) (+ 8 8 8 34 12))) | ||
| 274 | - | ||
| 275 | -(defn chats-screen [] | ||
| 276 | - (let [buffers (s/channel-list)] | ||
| 277 | - ;; Three bands rather than a page: the title and the two boxes at the top, | ||
| 278 | - ;; the tabs at the bottom, and the conversations scrolling between them. | ||
| 279 | - ;; Everything you act with stays where you last saw it — the same bargain | ||
| 280 | - ;; the chat screen makes, where the compose bar holds still under a backlog | ||
| 281 | - ;; that moves. | ||
| 282 | - ;; | ||
| 283 | - ;; What goes with the page is its centring at 620, on a window between that | ||
| 284 | - ;; and the split view's 900: the list is full-bleed there now, which is | ||
| 285 | - ;; what the conversation beside it has always been. | ||
| 286 | - ;; `:fill-height` on the outermost box, not only on the band inside it. | ||
| 287 | - ;; A backend that gives a column the height of what is in it hands the | ||
| 288 | - ;; band below a window's worth of nothing to divide: the list asks for | ||
| 289 | - ;; the rest of the screen, is told the rest is forty points, and the | ||
| 290 | - ;; whole screen paints into a strip along the top with the window empty | ||
| 291 | - ;; under it. Every screen that pins something to the bottom says this on | ||
| 292 | - ;; its own root — the split view already said it, one wrapper further out. | ||
| 293 | - [:vbox {:spacing 8 :margin 12 :fill-height true} | ||
| 294 | - [:vbox {:key :head :spacing 8 :margin-right list-gutter} | ||
| 295 | - ;; Your nick in the title, where "Chats" alone said nothing you did not | ||
| 296 | - ;; already know — and said plainly, as a sign-in rather than as a name | ||
| 297 | - ;; the list is somehow held in. It is what every channel calls you and | ||
| 298 | - ;; what your own lines are signed with, and the only other place it | ||
| 299 | - ;; showed was Settings. | ||
| 300 | - [:title {:label (if (s/connected?) | ||
| 301 | - (str "Logged in as " @s/form-nick) | ||
| 302 | - "Chats")}] | ||
| 303 | - [error-note] | ||
| 304 | - ;; Join and search are the same shape — a channel box with a button | ||
| 305 | - ;; beside it — so they read as one control block rather than a titled | ||
| 306 | - ;; card and a stray row: same card, same widths, same gap. The screen | ||
| 307 | - ;; title already says what the box is for, which is what the "Join | ||
| 308 | - ;; channel" header was doing. | ||
| 309 | - [:card {} | ||
| 310 | - [:vbox {:spacing 8} | ||
| 311 | - ;; Button first and `:align :end`, so the row is laid out from its | ||
| 312 | - ;; right edge: the button is placed against that edge and the box takes | ||
| 313 | - ;; what is left. An entry asks for whatever remains where it stands, so | ||
| 314 | - ;; a box placed first takes the row and pushes the button onto a line of | ||
| 315 | - ;; its own — Join under the box rather than beside it — and a box given | ||
| 316 | - ;; a fixed width only holds that off until the window is narrower than | ||
| 317 | - ;; the number. Laid out from the right the box is the part that gives, | ||
| 318 | - ;; at every width, in the frame the drag happens rather than the one | ||
| 319 | - ;; after the window is next measured. | ||
| 320 | - [:hbox {:spacing 8 :align :end} | ||
| 321 | - [:button {:label (if (str/starts-with? @s/join-input "@") "Message" "Join") | ||
| 322 | - :kind :primary | ||
| 323 | - :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}] | ||
| 324 | - ;; Both kinds of conversation through one box: `#room` joins a | ||
| 325 | - ;; channel, `@nick` opens a message to a person. | ||
| 326 | - [:entry {:text @s/join-input | ||
| 327 | - :placeholder "#channel or @nick" | ||
| 328 | - :on-change #(reset! s/join-input %) | ||
| 329 | - :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}]] | ||
| 330 | - ;; The clear button only shows while there is something to clear: an | ||
| 331 | - ;; empty box has nothing to undo, and a dead button beside it reads as | ||
| 332 | - ;; one that stopped working. It leads the row for the same reason the | ||
| 333 | - ;; Join button does, and the box gives up the width it takes. | ||
| 334 | - [:hbox {:spacing 8 :align :end} | ||
| 335 | - (when (seq @s/search) | ||
| 336 | - [:button {:label "✕" :on-click #(reset! s/search "")}]) | ||
| 337 | - [:entry {:text @s/search | ||
| 338 | - :placeholder "Search channels" | ||
| 339 | - :on-change #(reset! s/search %)}]]]]] | ||
| 340 | - ;; `:fill-height` so the scroll inside is handed the rest of the window | ||
| 341 | - ;; rather than the one card it starts out holding, and `:reserve` to keep | ||
| 342 | - ;; the tabs' strip out of what it may take. | ||
| 343 | - ;; Named, so it is the same list — and the same scroll position — when the | ||
| 344 | - ;; reader comes back from a conversation. | ||
| 345 | - [:vbox {:key :list :fill-height true} | ||
| 346 | - [:scroll {:scroll-key "chats-list" :orientation :vertical | ||
| 347 | - :reserve (below-list)} | ||
| 348 | - (if (seq buffers) | ||
| 349 | - ;; Keyed on the name, because this list reorders: opening a | ||
| 350 | - ;; conversation bumps it to the top, and unkeyed children reconcile by | ||
| 351 | - ;; position — the widgets stay put and their props are rewritten under | ||
| 352 | - ;; them. What is focused is a widget, so the highlight would stay in | ||
| 353 | - ;; the slot the reader clicked while the row that moved into it is | ||
| 354 | - ;; someone else's conversation. | ||
| 355 | - (for [b buffers] ^{:key (:name b)} [conversation-row b]) | ||
| 356 | - [:vbox {:margin-right list-gutter} | ||
| 357 | - [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]])]] | ||
| 358 | - [:vbox {:key :foot :spacing 8} | ||
| 359 | - [:separator {}] | ||
| 360 | - [tab-bar]]])) | ||
| 361 | - | ||
| 362 | ;; ---------------------------------------------------------------- chat | 167 | ;; ---------------------------------------------------------------- chat |
| 363 | 168 | ||
| 364 | (def ^:private url-pattern #"https?://[^\s<>\"]+") | 169 | (def ^:private url-pattern #"https?://[^\s<>\"]+") |
modified
src/frq/state.clj +16 -32 | @@ -5,6 +5,7 @@ | ||
| 5 | 5 | of `atom`s here; the IRC reader thread pushes into the same ones. `apply-msg!` |
| 6 | 6 | is the only place a wire message turns into UI state." |
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | + [frq.rooms :as rooms] | |
| 8 | 9 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 10 | [frq.actions :as actions] |
| 10 | 11 | [frq.cells :as cells] |
| @@ -47,6 +48,10 @@ | ||
| 47 | 48 | (def form-app-password cells/form-app-password) |
| 48 | 49 | (def session cells/session) |
| 49 | 50 | (def broker-token cells/broker-token) |
| 51 | +(def channels cells/channels) | |
| 52 | +(def current cells/current) | |
| 53 | +(def join-input cells/join-input) | |
| 54 | +(def search cells/search) | |
| 50 | 55 | (def login-url cells/login-url) |
| 51 | 56 | |
| 52 | 57 | (def popular-channels |
| @@ -64,9 +69,6 @@ | ||
| 64 | 69 | ;; How much backlog to ask for when the server did not volunteer any. |
| 65 | 70 | (def history-limit 100) |
| 66 | 71 | |
| 67 | -;; name -> {:name :messages [{:from :text}] :unread n :joined? bool} | |
| 68 | -(defonce channels (atom {})) | |
| 69 | -(defonce current (atom nil)) | |
| 70 | 72 | (defonce draft (atom "")) |
| 71 | 73 | ;; The message the draft is answering, as {:id :from :text}, or nil. Held whole |
| 72 | 74 | ;; rather than as an id alone so the compose bar can say who is being answered |
| @@ -94,7 +96,6 @@ | ||
| 94 | 96 | ;; The picture being looked at full size, or nil. Vidya's tree has no overlay, |
| 95 | 97 | ;; so this is a screen of its own rather than a layer over the chat. |
| 96 | 98 | (defonce lightbox (atom nil)) ; {:path :url} |
| 97 | -(defonce join-input (atom "")) | |
| 98 | 99 | |
| 99 | 100 | ;; Whether the chat screen is showing who is in the channel. Off by default: |
| 100 | 101 | ;; the panel costs the conversation a column, and the reader is here for the |
| @@ -202,7 +203,6 @@ | ||
| 202 | 203 | ;; A counter rather than a clock: the list only needs their order, and a |
| 203 | 204 | ;; monotonic tick cannot be surprised by the system time moving. |
| 204 | 205 | (defonce access-tick (atom 0)) |
| 205 | -(defonce search (atom "")) | |
| 206 | 206 | |
| 207 | 207 | ;; Comings and goings, hidden or not. A quiet room reads better with them — |
| 208 | 208 | ;; they are how you notice someone arriving — and a busy one drowns in them, |
| @@ -269,11 +269,7 @@ | ||
| 269 | 269 | (reset! rooms-saved-at now) |
| 270 | 270 | (future (store/save-rooms! (room-records))))))) |
| 271 | 271 | |
| 272 | -(defn dm? | |
| 273 | - "Whether a buffer is a conversation with a person rather than a room. Every | |
| 274 | - channel name starts with `#`; what does not is somebody's nick." | |
| 275 | - [name] | |
| 276 | - (and (seq name) (not (str/starts-with? name "#")))) | |
| 272 | +(def dm? rooms/dm?) | |
| 277 | 273 | |
| 278 | 274 | (defn normalize-channel [s] |
| 279 | 275 | (let [s (str/trim (or s ""))] |
| @@ -1833,20 +1829,7 @@ | ||
| 1833 | 1829 | (when (seq session-id) |
| 1834 | 1830 | (irc/tagmsg! c channel (av/leave-tags session-id instance)))))) |
| 1835 | 1831 | |
| 1836 | -(defn channel-list | |
| 1837 | - "Buffers most recently opened first, filtered by the search box. | |
| 1838 | - | |
| 1839 | - A conversation list is read from the top, and the one you were just in is the | |
| 1840 | - one you are most likely to want again. Buffers never opened — a DM that | |
| 1841 | - arrived, a channel someone mentioned — sort under those, by name, rather than | |
| 1842 | - jumping the queue." | |
| 1843 | - [] | |
| 1844 | - (let [q (str/lower-case (str/trim @search))] | |
| 1845 | - (->> (vals @channels) | |
| 1846 | - (filter #(or (str/blank? q) | |
| 1847 | - (str/includes? (str/lower-case (:name %)) q))) | |
| 1848 | - (sort-by (juxt #(- (:accessed % 0)) :name)) | |
| 1849 | - vec))) | |
| 1832 | +(def channel-list rooms/channel-list) | |
| 1850 | 1833 | |
| 1851 | 1834 | (defn channel-order |
| 1852 | 1835 | "The buffer names, most recently opened first — what gets written to disk. |
| @@ -1997,15 +1980,16 @@ | ||
| 1997 | 1980 | (sort-by #(or (:at %) 0)) |
| 1998 | 1981 | (take-last overview-limit))) |
| 1999 | 1982 | |
| 2000 | -(defn last-preview [buffer] | |
| 2001 | - (if-let [m (last (:messages buffer))] | |
| 2002 | - (str (:from m) ": " (:text m)) | |
| 2003 | - "No messages yet")) | |
| 1983 | +(def last-preview rooms/last-preview) | |
| 2004 | 1984 | |
| 2005 | -;; What the shared connect screen calls. Installed here rather than in an | |
| 2006 | -;; entry point because these are this namespace's own reducers, and the screen | |
| 2007 | -;; that calls them is no longer in a position to name them. | |
| 1985 | +;; What the shared screens call. Installed here rather than in an entry point | |
| 1986 | +;; because these are this namespace's own reducers, and the screens that call | |
| 1987 | +;; them are no longer in a position to name them. | |
| 2008 | 1988 | (actions/install! |
| 2009 | 1989 | {:connect! connect! |
| 2010 | 1990 | :disconnect! disconnect! |
| 2011 | - :forget-session! forget-session!}) | |
| 1991 | + :forget-session! forget-session! | |
| 1992 | + :connected? connected? | |
| 1993 | + :join! join! | |
| 1994 | + :open-channel! open-channel! | |
| 1995 | + :leave-channel! leave-channel!}) | |
| @@ -5,6 +5,7 @@ | |||
| 5 | of `atom`s here; the IRC reader thread pushes into the same ones. `apply-msg!` | 5 | of `atom`s here; the IRC reader thread pushes into the same ones. `apply-msg!` |
| 6 | is the only place a wire message turns into UI state." | 6 | is the only place a wire message turns into UI state." |
| 7 | (:require [clojure.string :as str] | 7 | (:require [clojure.string :as str] |
| 8 | + [frq.rooms :as rooms] | ||
| 8 | [glimmer.ratom :as r :refer [atom]] | 9 | [glimmer.ratom :as r :refer [atom]] |
| 9 | [frq.actions :as actions] | 10 | [frq.actions :as actions] |
| 10 | [frq.cells :as cells] | 11 | [frq.cells :as cells] |
| @@ -47,6 +48,10 @@ | |||
| 47 | (def form-app-password cells/form-app-password) | 48 | (def form-app-password cells/form-app-password) |
| 48 | (def session cells/session) | 49 | (def session cells/session) |
| 49 | (def broker-token cells/broker-token) | 50 | (def broker-token cells/broker-token) |
| 51 | +(def channels cells/channels) | ||
| 52 | +(def current cells/current) | ||
| 53 | +(def join-input cells/join-input) | ||
| 54 | +(def search cells/search) | ||
| 50 | (def login-url cells/login-url) | 55 | (def login-url cells/login-url) |
| 51 | 56 | ||
| 52 | (def popular-channels | 57 | (def popular-channels |
| @@ -64,9 +69,6 @@ | |||
| 64 | ;; How much backlog to ask for when the server did not volunteer any. | 69 | ;; How much backlog to ask for when the server did not volunteer any. |
| 65 | (def history-limit 100) | 70 | (def history-limit 100) |
| 66 | 71 | ||
| 67 | -;; name -> {:name :messages [{:from :text}] :unread n :joined? bool} | ||
| 68 | -(defonce channels (atom {})) | ||
| 69 | -(defonce current (atom nil)) | ||
| 70 | (defonce draft (atom "")) | 72 | (defonce draft (atom "")) |
| 71 | ;; The message the draft is answering, as {:id :from :text}, or nil. Held whole | 73 | ;; The message the draft is answering, as {:id :from :text}, or nil. Held whole |
| 72 | ;; rather than as an id alone so the compose bar can say who is being answered | 74 | ;; rather than as an id alone so the compose bar can say who is being answered |
| @@ -94,7 +96,6 @@ | |||
| 94 | ;; The picture being looked at full size, or nil. Vidya's tree has no overlay, | 96 | ;; The picture being looked at full size, or nil. Vidya's tree has no overlay, |
| 95 | ;; so this is a screen of its own rather than a layer over the chat. | 97 | ;; so this is a screen of its own rather than a layer over the chat. |
| 96 | (defonce lightbox (atom nil)) ; {:path :url} | 98 | (defonce lightbox (atom nil)) ; {:path :url} |
| 97 | -(defonce join-input (atom "")) | ||
| 98 | 99 | ||
| 99 | ;; Whether the chat screen is showing who is in the channel. Off by default: | 100 | ;; Whether the chat screen is showing who is in the channel. Off by default: |
| 100 | ;; the panel costs the conversation a column, and the reader is here for the | 101 | ;; the panel costs the conversation a column, and the reader is here for the |
| @@ -202,7 +203,6 @@ | |||
| 202 | ;; A counter rather than a clock: the list only needs their order, and a | 203 | ;; A counter rather than a clock: the list only needs their order, and a |
| 203 | ;; monotonic tick cannot be surprised by the system time moving. | 204 | ;; monotonic tick cannot be surprised by the system time moving. |
| 204 | (defonce access-tick (atom 0)) | 205 | (defonce access-tick (atom 0)) |
| 205 | -(defonce search (atom "")) | ||
| 206 | 206 | ||
| 207 | ;; Comings and goings, hidden or not. A quiet room reads better with them — | 207 | ;; Comings and goings, hidden or not. A quiet room reads better with them — |
| 208 | ;; they are how you notice someone arriving — and a busy one drowns in them, | 208 | ;; they are how you notice someone arriving — and a busy one drowns in them, |
| @@ -269,11 +269,7 @@ | |||
| 269 | (reset! rooms-saved-at now) | 269 | (reset! rooms-saved-at now) |
| 270 | (future (store/save-rooms! (room-records))))))) | 270 | (future (store/save-rooms! (room-records))))))) |
| 271 | 271 | ||
| 272 | -(defn dm? | 272 | +(def dm? rooms/dm?) |
| 273 | - "Whether a buffer is a conversation with a person rather than a room. Every | ||
| 274 | - channel name starts with `#`; what does not is somebody's nick." | ||
| 275 | - [name] | ||
| 276 | - (and (seq name) (not (str/starts-with? name "#")))) | ||
| 277 | 273 | ||
| 278 | (defn normalize-channel [s] | 274 | (defn normalize-channel [s] |
| 279 | (let [s (str/trim (or s ""))] | 275 | (let [s (str/trim (or s ""))] |
| @@ -1833,20 +1829,7 @@ | |||
| 1833 | (when (seq session-id) | 1829 | (when (seq session-id) |
| 1834 | (irc/tagmsg! c channel (av/leave-tags session-id instance)))))) | 1830 | (irc/tagmsg! c channel (av/leave-tags session-id instance)))))) |
| 1835 | 1831 | ||
| 1836 | -(defn channel-list | 1832 | +(def channel-list rooms/channel-list) |
| 1837 | - "Buffers most recently opened first, filtered by the search box. | ||
| 1838 | - | ||
| 1839 | - A conversation list is read from the top, and the one you were just in is the | ||
| 1840 | - one you are most likely to want again. Buffers never opened — a DM that | ||
| 1841 | - arrived, a channel someone mentioned — sort under those, by name, rather than | ||
| 1842 | - jumping the queue." | ||
| 1843 | - [] | ||
| 1844 | - (let [q (str/lower-case (str/trim @search))] | ||
| 1845 | - (->> (vals @channels) | ||
| 1846 | - (filter #(or (str/blank? q) | ||
| 1847 | - (str/includes? (str/lower-case (:name %)) q))) | ||
| 1848 | - (sort-by (juxt #(- (:accessed % 0)) :name)) | ||
| 1849 | - vec))) | ||
| 1850 | 1833 | ||
| 1851 | (defn channel-order | 1834 | (defn channel-order |
| 1852 | "The buffer names, most recently opened first — what gets written to disk. | 1835 | "The buffer names, most recently opened first — what gets written to disk. |
| @@ -1997,15 +1980,16 @@ | |||
| 1997 | (sort-by #(or (:at %) 0)) | 1980 | (sort-by #(or (:at %) 0)) |
| 1998 | (take-last overview-limit))) | 1981 | (take-last overview-limit))) |
| 1999 | 1982 | ||
| 2000 | -(defn last-preview [buffer] | 1983 | +(def last-preview rooms/last-preview) |
| 2001 | - (if-let [m (last (:messages buffer))] | ||
| 2002 | - (str (:from m) ": " (:text m)) | ||
| 2003 | - "No messages yet")) | ||
| 2004 | 1984 | ||
| 2005 | -;; What the shared connect screen calls. Installed here rather than in an | 1985 | +;; What the shared screens call. Installed here rather than in an entry point |
| 2006 | -;; entry point because these are this namespace's own reducers, and the screen | 1986 | +;; because these are this namespace's own reducers, and the screens that call |
| 2007 | -;; that calls them is no longer in a position to name them. | 1987 | +;; them are no longer in a position to name them. |
| 2008 | (actions/install! | 1988 | (actions/install! |
| 2009 | {:connect! connect! | 1989 | {:connect! connect! |
| 2010 | :disconnect! disconnect! | 1990 | :disconnect! disconnect! |
| 2011 | - :forget-session! forget-session!}) | 1991 | + :forget-session! forget-session! |
| 1992 | + :connected? connected? | ||
| 1993 | + :join! join! | ||
| 1994 | + :open-channel! open-channel! | ||
| 1995 | + :leave-channel! leave-channel!}) | ||