Merge remote-tracking branch 'origin/main' into claude/emoji-reacts-irc-webview-c28ce6
modified
src/frq/app.jolt +140 -31 | @@ -149,12 +149,32 @@ | ||
| 149 | 149 | ;; `:wrap false` on the badges: they sit beside each other or not at all. |
| 150 | 150 | [:title-2 {:label name}] |
| 151 | 151 | [:hbox {:spacing 12 :wrap false} |
| 152 | - [:status {:label (if (:joined? buffer) "joined" "not joined") | |
| 152 | + ;; A DM has no membership to report — there is nothing to be in — so the | |
| 153 | + ;; badge says what the buffer is instead of answering a question nobody | |
| 154 | + ;; asked of it. | |
| 155 | + [:status {:label (cond (s/dm? name) "direct message" | |
| 156 | + (:joined? buffer) "joined" | |
| 157 | + :else "not joined") | |
| 153 | 158 | :live (boolean (:joined? buffer))}] |
| 154 | 159 | (when (pos? unread) [:label {:label (str "● " unread)}])] |
| 155 | 160 | [:dim-label {:label (s/last-preview buffer)}] |
| 156 | 161 | [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]])) |
| 157 | 162 | |
| 163 | +(def ^:private sidebar-width 320) | |
| 164 | + | |
| 165 | +(defn- control-entry-width | |
| 166 | + "How wide the join and search boxes ask to be. | |
| 167 | + | |
| 168 | + A fixed 380 is right on a window that holds the list alone, and too wide for | |
| 169 | + the list as a split view's left column: the row it sits on is only | |
| 170 | + `sidebar-width` across, and an entry that asks for more than remains pushes | |
| 171 | + the button beside it onto a row of its own — Join under the box rather than | |
| 172 | + next to it. Beside the entry go the button and the gap, and around the row | |
| 173 | + the card's and the page's padding, so the column's share is what is left of | |
| 174 | + the sidebar after those." | |
| 175 | + [] | |
| 176 | + (if (s/wide?) (- sidebar-width 130) 380)) | |
| 177 | + | |
| 158 | 178 | (defn chats-screen [] |
| 159 | 179 | (let [buffers (s/channel-list)] |
| 160 | 180 | [:page {:max-width 620} |
| @@ -171,12 +191,14 @@ | ||
| 171 | 191 | [:card {} |
| 172 | 192 | [:vbox {:spacing 8} |
| 173 | 193 | [:hbox {:spacing 8} |
| 194 | + ;; Both kinds of conversation through one box: `#room` joins a | |
| 195 | + ;; channel, `@nick` opens a message to a person. | |
| 174 | 196 | [:entry {:text @s/join-input |
| 175 | - :width-request 380 | |
| 176 | - :placeholder "#channel" | |
| 197 | + :width-request (control-entry-width) | |
| 198 | + :placeholder "#channel or @nick" | |
| 177 | 199 | :on-change #(reset! s/join-input %) |
| 178 | 200 | :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}] |
| 179 | - [:button {:label "Join" | |
| 201 | + [:button {:label (if (str/starts-with? @s/join-input "@") "Message" "Join") | |
| 180 | 202 | :kind :primary |
| 181 | 203 | :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]] |
| 182 | 204 | ;; The clear button only shows while there is something to clear: an |
| @@ -184,7 +206,7 @@ | ||
| 184 | 206 | ;; one that stopped working. |
| 185 | 207 | [:hbox {:spacing 8} |
| 186 | 208 | [:entry {:text @s/search |
| 187 | - :width-request 380 | |
| 209 | + :width-request (control-entry-width) | |
| 188 | 210 | :placeholder "Search channels" |
| 189 | 211 | :on-change #(reset! s/search %)}] |
| 190 | 212 | (when (seq @s/search) |
| @@ -833,15 +855,86 @@ | ||
| 833 | 855 | (when (and (empty? dirs) (empty? files)) |
| 834 | 856 | [:dim-label {:label "No pictures here that this app can read."}])]]]])) |
| 835 | 857 | |
| 858 | +;; What the people panel asks for, and what the conversation beside it has to | |
| 859 | +;; be told to leave. A column with `:fill-height` and no width takes the whole | |
| 860 | +;; row — so the panel is only ever on screen if the message list is given a | |
| 861 | +;; width that stops short of it. | |
| 862 | +(def ^:private users-width 180) | |
| 863 | + | |
| 864 | +;; What the rows under the conversation need left to them: the jump button's | |
| 865 | +;; row, the separator, the compose bar and the air around it. The columns of | |
| 866 | +;; the row reserve it, and so nothing inside them has to — a `:scroll` that | |
| 867 | +;; subtracted it again inside a column already stopped short would take the | |
| 868 | +;; same points off the list twice. | |
| 869 | +;; | |
| 870 | +;; Plus the reply banner's row when there is one, and the pasted picture when | |
| 871 | +;; there is one. Those rows do move the compose bar, and should: each appears | |
| 872 | +;; because the reader just asked for something — unlike the jump button, which | |
| 873 | +;; appears on its own and must not shift what is under it. | |
| 874 | +(defn- below-messages [] | |
| 875 | + ;; 140 is that, counted: the gap under the row of columns, the jump button's | |
| 876 | + ;; 34pt row, the separator and the two empty wrappers with a gap apiece, then | |
| 877 | + ;; the compose row and the air under it — its own and the window's. Short by | |
| 878 | + ;; any of it and the column runs past the bottom edge, which does not show as | |
| 879 | + ;; a list that is too long: it shows as a compose bar sitting flat on the | |
| 880 | + ;; bottom of the window with its margin cut off. | |
| 881 | + (+ 140 | |
| 882 | + (if @s/replying-to 34 0) | |
| 883 | + (if @s/attachment 76 0))) | |
| 884 | + | |
| 885 | +(defn- messages-width | |
| 886 | + "How wide the message list may be with the people panel beside it. | |
| 887 | + | |
| 888 | + Measured from the window rather than from what egui has left: the row is | |
| 889 | + painted left to right, and by the time the panel is placed the list has | |
| 890 | + already taken everything. On a wide window the chats list is holding the | |
| 891 | + first `sidebar-width` of the window; the rest is the margins and the gap | |
| 892 | + between the two columns." | |
| 893 | + [] | |
| 894 | + (let [pane (- @s/window-width (if (s/wide?) sidebar-width 0))] | |
| 895 | + (max 240 (- pane users-width 8 28)))) | |
| 896 | + | |
| 897 | +(defn- member-row [{:keys [nick prefix]}] | |
| 898 | + ;; The mode where there is one, dim: it says how someone is listed, not who | |
| 899 | + ;; they are, and the name is what the eye is scanning for. A space where | |
| 900 | + ;; there is none, rather than nothing at all: the label is always in the row, | |
| 901 | + ;; so every name starts at the same place — and a column wrapped around a | |
| 902 | + ;; label that comes and goes is a column, which sits its text against the top | |
| 903 | + ;; of the row rather than on the line the name is on. | |
| 904 | + ;; | |
| 905 | + ;; The name is a button because a person is somewhere to go: pressing one | |
| 906 | + ;; opens a conversation with them. | |
| 907 | + [:hbox {:key nick :spacing 6 :wrap false} | |
| 908 | + [:dim-label {:label (if (seq prefix) prefix " ")}] | |
| 909 | + [:button {:label nick :on-click #(s/open-dm! nick)}]]) | |
| 910 | + | |
| 911 | +(defn users-panel | |
| 912 | + "Who is in the channel, beside the conversation. | |
| 913 | + | |
| 914 | + The list is the server's — NAMES on the way in, kept up by the joins and | |
| 915 | + parts after it — so a channel this client has never been in has nothing to | |
| 916 | + show, and says so rather than showing an empty column." | |
| 917 | + [name] | |
| 918 | + (let [people (s/member-list name)] | |
| 919 | + [:vbox {:key :users :width-request users-width :fill-height true | |
| 920 | + :reserve (below-messages) :spacing 8} | |
| 921 | + [:title-2 {:label (str "People " (count people))}] | |
| 922 | + [:scroll {:scroll-key (str "users-" name) | |
| 923 | + :orientation :vertical} | |
| 924 | + (if (seq people) | |
| 925 | + (for [p people] [member-row p]) | |
| 926 | + [:dim-label {:label "Nobody listed yet."}])]])) | |
| 927 | + | |
| 836 | 928 | (defn chat-screen [] |
| 837 | 929 | (let [name @s/current |
| 838 | - buffer (get @s/channels name)] | |
| 930 | + buffer (get @s/channels name) | |
| 931 | + show-users? (and @s/show-users? name (str/starts-with? name "#"))] | |
| 839 | 932 | ;; Not a :page — a page scrolls everything, which would carry the compose |
| 840 | 933 | ;; bar off the bottom with the backlog. The message list is the only thing |
| 841 | 934 | ;; that scrolls, bounded so what follows it keeps its room. |
| 842 | - ;; Less room under the compose row than around it: the space a row at the | |
| 843 | - ;; bottom of a screen wants is above it, not beneath it. | |
| 844 | - [:vbox {:spacing 8 :margin 12 :margin-bottom 4} | |
| 935 | + ;; Same margin all round: the compose row's own air is what centres it in | |
| 936 | + ;; the strip below the separator, and it is measured from this edge. | |
| 937 | + [:vbox {:spacing 8 :margin 12} | |
| 845 | 938 | [:hbox {:spacing 8} |
| 846 | 939 | ;; The way back to the list, on a window with room for one thing at a |
| 847 | 940 | ;; time. Beside the list there is nothing to go back to, so the button |
| @@ -860,7 +953,15 @@ | ||
| 860 | 953 | (av/available?) |
| 861 | 954 | (not (av/call-in name)) |
| 862 | 955 | (not (av/in-call?))) |
| 863 | - [:button {:label "Call" :on-click #(s/start-call! name)}])]] | |
| 956 | + [:button {:label "Call" :on-click #(s/start-call! name)}])] | |
| 957 | + ;; The people panel's switch, in a wrapper of its own for the same | |
| 958 | + ;; reason: it is only offered in a channel, where there is a membership | |
| 959 | + ;; to show. | |
| 960 | + [:vbox {:key :people} | |
| 961 | + (when (and name (str/starts-with? name "#")) | |
| 962 | + [:button {:label (str "People " (s/member-count name)) | |
| 963 | + :kind (when @s/show-users? :primary) | |
| 964 | + :on-click s/toggle-users!}])]] | |
| 864 | 965 | [error-note] |
| 865 | 966 | [call-bar name] |
| 866 | 967 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -873,22 +974,28 @@ | ||
| 873 | 974 | ;; unmounts the backlog behind it; without a name of its own the position |
| 874 | 975 | ;; would come back as a fresh one, and ← Back would answer a click on a |
| 875 | 976 | ;; message halfway up a week of history with the top of the buffer. |
| 876 | - [:scroll {:scroll-key "chat-messages" | |
| 877 | - :orientation :vertical | |
| 878 | - ;; Plus the reply banner's row when there is one, and the | |
| 879 | - ;; pasted picture when there is one. Those rows do move the | |
| 880 | - ;; compose bar, and should: each appears because the reader just | |
| 881 | - ;; asked for something — unlike the jump button, which appears | |
| 882 | - ;; on its own and must not shift what is under it. | |
| 883 | - :reserve (+ 124 | |
| 884 | - (if @s/replying-to 34 0) | |
| 885 | - (if @s/attachment 76 0)) | |
| 886 | - :stick-to-bottom true | |
| 887 | - :scroll-to-bottom @s/jump-tick | |
| 888 | - :on-change #(reset! s/at-present? (= "end" %))} | |
| 889 | - (if (seq (:messages buffer)) | |
| 890 | - (message-rows (:messages buffer)) | |
| 891 | - [:dim-label {:label "Nothing here yet."}])] | |
| 977 | + ;; The backlog and, when it is asked for, who is in the room beside it. | |
| 978 | + ;; The row is always there and the panel comes and goes inside a wrapper | |
| 979 | + ;; of its own: a child that appeared and vanished would renumber the row | |
| 980 | + ;; for the reconciler, and take the message list's scroll position with | |
| 981 | + ;; it every time the panel was toggled. | |
| 982 | + [:hbox {:spacing 8 :wrap false} | |
| 983 | + [:vbox {:key :messages :fill-height true | |
| 984 | + :reserve (below-messages) | |
| 985 | + :width-request (if show-users? (messages-width) 0)} | |
| 986 | + [:scroll {:scroll-key "chat-messages" | |
| 987 | + :orientation :vertical | |
| 988 | + :stick-to-bottom true | |
| 989 | + :scroll-to-bottom @s/jump-tick | |
| 990 | + :on-change #(reset! s/at-present? (= "end" %))} | |
| 991 | + (if (seq (:messages buffer)) | |
| 992 | + (message-rows (:messages buffer)) | |
| 993 | + [:dim-label {:label "Nothing here yet."}])]] | |
| 994 | + ;; The wrapper takes no height of its own: the panel inside it is the | |
| 995 | + ;; column, and a fill-height wrapper around it would claim the strip the | |
| 996 | + ;; compose bar sits in whether or not the panel was showing. | |
| 997 | + [:vbox {:key :people-pane} | |
| 998 | + (when show-users? [users-panel name])]] | |
| 892 | 999 | ;; Only while it is needed, and directly above the compose bar: the way |
| 893 | 1000 | ;; back to the present belongs next to the thing that puts you there. |
| 894 | 1001 | ;; The row keeps its height whether or not the button is in it, so the |
| @@ -923,9 +1030,13 @@ | ||
| 923 | 1030 | ;; window wider than that a left-aligned row leaves it stranded in the |
| 924 | 1031 | ;; corner of an otherwise empty bar. On a phone the row is as wide as the |
| 925 | 1032 | ;; window and centring costs nothing. |
| 926 | - ;; A little air under the row, so the line being typed does not sit flat | |
| 927 | - ;; on the bottom edge of the window. | |
| 928 | - [:hbox {:spacing 8 :align :center :margin-bottom 8} | |
| 1033 | + ;; Equal air above and below, so the row sits on the middle of the strip | |
| 1034 | + ;; between the separator and the bottom edge rather than flat against it. | |
| 1035 | + ;; Above it is three of the column's gaps: one after the separator and one | |
| 1036 | + ;; for each of the empty wrappers — the reply banner and the attachment, | |
| 1037 | + ;; which cost a gap apiece whether or not they have anything in them. This | |
| 1038 | + ;; margin plus the window's own is what answers them underneath. | |
| 1039 | + [:hbox {:spacing 8 :align :center :margin-bottom 12} | |
| 929 | 1040 | ;; narrow enough that Send keeps its place on a phone-width row |
| 930 | 1041 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field |
| 931 | 1042 | ;; answers a paste of text with the text, and one of a picture reaches |
| @@ -945,8 +1056,6 @@ | ||
| 945 | 1056 | |
| 946 | 1057 | ;; ---------------------------------------------------------------- split |
| 947 | 1058 | |
| 948 | -(def ^:private sidebar-width 320) | |
| 949 | - | |
| 950 | 1059 | (defn- no-chat-pane |
| 951 | 1060 | "What fills the second pane before a conversation has been picked. The pane |
| 952 | 1061 | is there either way — a list that widened into two columns and back again as |
| @@ -149,12 +149,32 @@ | |||
| 149 | ;; `:wrap false` on the badges: they sit beside each other or not at all. | 149 | ;; `:wrap false` on the badges: they sit beside each other or not at all. |
| 150 | [:title-2 {:label name}] | 150 | [:title-2 {:label name}] |
| 151 | [:hbox {:spacing 12 :wrap false} | 151 | [:hbox {:spacing 12 :wrap false} |
| 152 | - [:status {:label (if (:joined? buffer) "joined" "not joined") | 152 | + ;; A DM has no membership to report — there is nothing to be in — so the |
| 153 | + ;; badge says what the buffer is instead of answering a question nobody | ||
| 154 | + ;; asked of it. | ||
| 155 | + [:status {:label (cond (s/dm? name) "direct message" | ||
| 156 | + (:joined? buffer) "joined" | ||
| 157 | + :else "not joined") | ||
| 153 | :live (boolean (:joined? buffer))}] | 158 | :live (boolean (:joined? buffer))}] |
| 154 | (when (pos? unread) [:label {:label (str "● " unread)}])] | 159 | (when (pos? unread) [:label {:label (str "● " unread)}])] |
| 155 | [:dim-label {:label (s/last-preview buffer)}] | 160 | [:dim-label {:label (s/last-preview buffer)}] |
| 156 | [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]])) | 161 | [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]])) |
| 157 | 162 | ||
| 163 | +(def ^:private sidebar-width 320) | ||
| 164 | + | ||
| 165 | +(defn- control-entry-width | ||
| 166 | + "How wide the join and search boxes ask to be. | ||
| 167 | + | ||
| 168 | + A fixed 380 is right on a window that holds the list alone, and too wide for | ||
| 169 | + the list as a split view's left column: the row it sits on is only | ||
| 170 | + `sidebar-width` across, and an entry that asks for more than remains pushes | ||
| 171 | + the button beside it onto a row of its own — Join under the box rather than | ||
| 172 | + next to it. Beside the entry go the button and the gap, and around the row | ||
| 173 | + the card's and the page's padding, so the column's share is what is left of | ||
| 174 | + the sidebar after those." | ||
| 175 | + [] | ||
| 176 | + (if (s/wide?) (- sidebar-width 130) 380)) | ||
| 177 | + | ||
| 158 | (defn chats-screen [] | 178 | (defn chats-screen [] |
| 159 | (let [buffers (s/channel-list)] | 179 | (let [buffers (s/channel-list)] |
| 160 | [:page {:max-width 620} | 180 | [:page {:max-width 620} |
| @@ -171,12 +191,14 @@ | |||
| 171 | [:card {} | 191 | [:card {} |
| 172 | [:vbox {:spacing 8} | 192 | [:vbox {:spacing 8} |
| 173 | [:hbox {:spacing 8} | 193 | [:hbox {:spacing 8} |
| 194 | + ;; Both kinds of conversation through one box: `#room` joins a | ||
| 195 | + ;; channel, `@nick` opens a message to a person. | ||
| 174 | [:entry {:text @s/join-input | 196 | [:entry {:text @s/join-input |
| 175 | - :width-request 380 | 197 | + :width-request (control-entry-width) |
| 176 | - :placeholder "#channel" | 198 | + :placeholder "#channel or @nick" |
| 177 | :on-change #(reset! s/join-input %) | 199 | :on-change #(reset! s/join-input %) |
| 178 | :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}] | 200 | :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}] |
| 179 | - [:button {:label "Join" | 201 | + [:button {:label (if (str/starts-with? @s/join-input "@") "Message" "Join") |
| 180 | :kind :primary | 202 | :kind :primary |
| 181 | :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]] | 203 | :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]] |
| 182 | ;; The clear button only shows while there is something to clear: an | 204 | ;; The clear button only shows while there is something to clear: an |
| @@ -184,7 +206,7 @@ | |||
| 184 | ;; one that stopped working. | 206 | ;; one that stopped working. |
| 185 | [:hbox {:spacing 8} | 207 | [:hbox {:spacing 8} |
| 186 | [:entry {:text @s/search | 208 | [:entry {:text @s/search |
| 187 | - :width-request 380 | 209 | + :width-request (control-entry-width) |
| 188 | :placeholder "Search channels" | 210 | :placeholder "Search channels" |
| 189 | :on-change #(reset! s/search %)}] | 211 | :on-change #(reset! s/search %)}] |
| 190 | (when (seq @s/search) | 212 | (when (seq @s/search) |
| @@ -833,15 +855,86 @@ | |||
| 833 | (when (and (empty? dirs) (empty? files)) | 855 | (when (and (empty? dirs) (empty? files)) |
| 834 | [:dim-label {:label "No pictures here that this app can read."}])]]]])) | 856 | [:dim-label {:label "No pictures here that this app can read."}])]]]])) |
| 835 | 857 | ||
| 858 | +;; What the people panel asks for, and what the conversation beside it has to | ||
| 859 | +;; be told to leave. A column with `:fill-height` and no width takes the whole | ||
| 860 | +;; row — so the panel is only ever on screen if the message list is given a | ||
| 861 | +;; width that stops short of it. | ||
| 862 | +(def ^:private users-width 180) | ||
| 863 | + | ||
| 864 | +;; What the rows under the conversation need left to them: the jump button's | ||
| 865 | +;; row, the separator, the compose bar and the air around it. The columns of | ||
| 866 | +;; the row reserve it, and so nothing inside them has to — a `:scroll` that | ||
| 867 | +;; subtracted it again inside a column already stopped short would take the | ||
| 868 | +;; same points off the list twice. | ||
| 869 | +;; | ||
| 870 | +;; Plus the reply banner's row when there is one, and the pasted picture when | ||
| 871 | +;; there is one. Those rows do move the compose bar, and should: each appears | ||
| 872 | +;; because the reader just asked for something — unlike the jump button, which | ||
| 873 | +;; appears on its own and must not shift what is under it. | ||
| 874 | +(defn- below-messages [] | ||
| 875 | + ;; 140 is that, counted: the gap under the row of columns, the jump button's | ||
| 876 | + ;; 34pt row, the separator and the two empty wrappers with a gap apiece, then | ||
| 877 | + ;; the compose row and the air under it — its own and the window's. Short by | ||
| 878 | + ;; any of it and the column runs past the bottom edge, which does not show as | ||
| 879 | + ;; a list that is too long: it shows as a compose bar sitting flat on the | ||
| 880 | + ;; bottom of the window with its margin cut off. | ||
| 881 | + (+ 140 | ||
| 882 | + (if @s/replying-to 34 0) | ||
| 883 | + (if @s/attachment 76 0))) | ||
| 884 | + | ||
| 885 | +(defn- messages-width | ||
| 886 | + "How wide the message list may be with the people panel beside it. | ||
| 887 | + | ||
| 888 | + Measured from the window rather than from what egui has left: the row is | ||
| 889 | + painted left to right, and by the time the panel is placed the list has | ||
| 890 | + already taken everything. On a wide window the chats list is holding the | ||
| 891 | + first `sidebar-width` of the window; the rest is the margins and the gap | ||
| 892 | + between the two columns." | ||
| 893 | + [] | ||
| 894 | + (let [pane (- @s/window-width (if (s/wide?) sidebar-width 0))] | ||
| 895 | + (max 240 (- pane users-width 8 28)))) | ||
| 896 | + | ||
| 897 | +(defn- member-row [{:keys [nick prefix]}] | ||
| 898 | + ;; The mode where there is one, dim: it says how someone is listed, not who | ||
| 899 | + ;; they are, and the name is what the eye is scanning for. A space where | ||
| 900 | + ;; there is none, rather than nothing at all: the label is always in the row, | ||
| 901 | + ;; so every name starts at the same place — and a column wrapped around a | ||
| 902 | + ;; label that comes and goes is a column, which sits its text against the top | ||
| 903 | + ;; of the row rather than on the line the name is on. | ||
| 904 | + ;; | ||
| 905 | + ;; The name is a button because a person is somewhere to go: pressing one | ||
| 906 | + ;; opens a conversation with them. | ||
| 907 | + [:hbox {:key nick :spacing 6 :wrap false} | ||
| 908 | + [:dim-label {:label (if (seq prefix) prefix " ")}] | ||
| 909 | + [:button {:label nick :on-click #(s/open-dm! nick)}]]) | ||
| 910 | + | ||
| 911 | +(defn users-panel | ||
| 912 | + "Who is in the channel, beside the conversation. | ||
| 913 | + | ||
| 914 | + The list is the server's — NAMES on the way in, kept up by the joins and | ||
| 915 | + parts after it — so a channel this client has never been in has nothing to | ||
| 916 | + show, and says so rather than showing an empty column." | ||
| 917 | + [name] | ||
| 918 | + (let [people (s/member-list name)] | ||
| 919 | + [:vbox {:key :users :width-request users-width :fill-height true | ||
| 920 | + :reserve (below-messages) :spacing 8} | ||
| 921 | + [:title-2 {:label (str "People " (count people))}] | ||
| 922 | + [:scroll {:scroll-key (str "users-" name) | ||
| 923 | + :orientation :vertical} | ||
| 924 | + (if (seq people) | ||
| 925 | + (for [p people] [member-row p]) | ||
| 926 | + [:dim-label {:label "Nobody listed yet."}])]])) | ||
| 927 | + | ||
| 836 | (defn chat-screen [] | 928 | (defn chat-screen [] |
| 837 | (let [name @s/current | 929 | (let [name @s/current |
| 838 | - buffer (get @s/channels name)] | 930 | + buffer (get @s/channels name) |
| 931 | + show-users? (and @s/show-users? name (str/starts-with? name "#"))] | ||
| 839 | ;; Not a :page — a page scrolls everything, which would carry the compose | 932 | ;; Not a :page — a page scrolls everything, which would carry the compose |
| 840 | ;; bar off the bottom with the backlog. The message list is the only thing | 933 | ;; bar off the bottom with the backlog. The message list is the only thing |
| 841 | ;; that scrolls, bounded so what follows it keeps its room. | 934 | ;; that scrolls, bounded so what follows it keeps its room. |
| 842 | - ;; Less room under the compose row than around it: the space a row at the | 935 | + ;; Same margin all round: the compose row's own air is what centres it in |
| 843 | - ;; bottom of a screen wants is above it, not beneath it. | 936 | + ;; the strip below the separator, and it is measured from this edge. |
| 844 | - [:vbox {:spacing 8 :margin 12 :margin-bottom 4} | 937 | + [:vbox {:spacing 8 :margin 12} |
| 845 | [:hbox {:spacing 8} | 938 | [:hbox {:spacing 8} |
| 846 | ;; The way back to the list, on a window with room for one thing at a | 939 | ;; The way back to the list, on a window with room for one thing at a |
| 847 | ;; time. Beside the list there is nothing to go back to, so the button | 940 | ;; time. Beside the list there is nothing to go back to, so the button |
| @@ -860,7 +953,15 @@ | |||
| 860 | (av/available?) | 953 | (av/available?) |
| 861 | (not (av/call-in name)) | 954 | (not (av/call-in name)) |
| 862 | (not (av/in-call?))) | 955 | (not (av/in-call?))) |
| 863 | - [:button {:label "Call" :on-click #(s/start-call! name)}])]] | 956 | + [:button {:label "Call" :on-click #(s/start-call! name)}])] |
| 957 | + ;; The people panel's switch, in a wrapper of its own for the same | ||
| 958 | + ;; reason: it is only offered in a channel, where there is a membership | ||
| 959 | + ;; to show. | ||
| 960 | + [:vbox {:key :people} | ||
| 961 | + (when (and name (str/starts-with? name "#")) | ||
| 962 | + [:button {:label (str "People " (s/member-count name)) | ||
| 963 | + :kind (when @s/show-users? :primary) | ||
| 964 | + :on-click s/toggle-users!}])]] | ||
| 864 | [error-note] | 965 | [error-note] |
| 865 | [call-bar name] | 966 | [call-bar name] |
| 866 | ;; :reserve leaves room for everything below: the jump button's row, the | 967 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -873,22 +974,28 @@ | |||
| 873 | ;; unmounts the backlog behind it; without a name of its own the position | 974 | ;; unmounts the backlog behind it; without a name of its own the position |
| 874 | ;; would come back as a fresh one, and ← Back would answer a click on a | 975 | ;; would come back as a fresh one, and ← Back would answer a click on a |
| 875 | ;; message halfway up a week of history with the top of the buffer. | 976 | ;; message halfway up a week of history with the top of the buffer. |
| 876 | - [:scroll {:scroll-key "chat-messages" | 977 | + ;; The backlog and, when it is asked for, who is in the room beside it. |
| 877 | - :orientation :vertical | 978 | + ;; The row is always there and the panel comes and goes inside a wrapper |
| 878 | - ;; Plus the reply banner's row when there is one, and the | 979 | + ;; of its own: a child that appeared and vanished would renumber the row |
| 879 | - ;; pasted picture when there is one. Those rows do move the | 980 | + ;; for the reconciler, and take the message list's scroll position with |
| 880 | - ;; compose bar, and should: each appears because the reader just | 981 | + ;; it every time the panel was toggled. |
| 881 | - ;; asked for something — unlike the jump button, which appears | 982 | + [:hbox {:spacing 8 :wrap false} |
| 882 | - ;; on its own and must not shift what is under it. | 983 | + [:vbox {:key :messages :fill-height true |
| 883 | - :reserve (+ 124 | 984 | + :reserve (below-messages) |
| 884 | - (if @s/replying-to 34 0) | 985 | + :width-request (if show-users? (messages-width) 0)} |
| 885 | - (if @s/attachment 76 0)) | 986 | + [:scroll {:scroll-key "chat-messages" |
| 886 | - :stick-to-bottom true | 987 | + :orientation :vertical |
| 887 | - :scroll-to-bottom @s/jump-tick | 988 | + :stick-to-bottom true |
| 888 | - :on-change #(reset! s/at-present? (= "end" %))} | 989 | + :scroll-to-bottom @s/jump-tick |
| 889 | - (if (seq (:messages buffer)) | 990 | + :on-change #(reset! s/at-present? (= "end" %))} |
| 890 | - (message-rows (:messages buffer)) | 991 | + (if (seq (:messages buffer)) |
| 891 | - [:dim-label {:label "Nothing here yet."}])] | 992 | + (message-rows (:messages buffer)) |
| 993 | + [:dim-label {:label "Nothing here yet."}])]] | ||
| 994 | + ;; The wrapper takes no height of its own: the panel inside it is the | ||
| 995 | + ;; column, and a fill-height wrapper around it would claim the strip the | ||
| 996 | + ;; compose bar sits in whether or not the panel was showing. | ||
| 997 | + [:vbox {:key :people-pane} | ||
| 998 | + (when show-users? [users-panel name])]] | ||
| 892 | ;; Only while it is needed, and directly above the compose bar: the way | 999 | ;; Only while it is needed, and directly above the compose bar: the way |
| 893 | ;; back to the present belongs next to the thing that puts you there. | 1000 | ;; back to the present belongs next to the thing that puts you there. |
| 894 | ;; The row keeps its height whether or not the button is in it, so the | 1001 | ;; The row keeps its height whether or not the button is in it, so the |
| @@ -923,9 +1030,13 @@ | |||
| 923 | ;; window wider than that a left-aligned row leaves it stranded in the | 1030 | ;; window wider than that a left-aligned row leaves it stranded in the |
| 924 | ;; corner of an otherwise empty bar. On a phone the row is as wide as the | 1031 | ;; corner of an otherwise empty bar. On a phone the row is as wide as the |
| 925 | ;; window and centring costs nothing. | 1032 | ;; window and centring costs nothing. |
| 926 | - ;; A little air under the row, so the line being typed does not sit flat | 1033 | + ;; Equal air above and below, so the row sits on the middle of the strip |
| 927 | - ;; on the bottom edge of the window. | 1034 | + ;; between the separator and the bottom edge rather than flat against it. |
| 928 | - [:hbox {:spacing 8 :align :center :margin-bottom 8} | 1035 | + ;; Above it is three of the column's gaps: one after the separator and one |
| 1036 | + ;; for each of the empty wrappers — the reply banner and the attachment, | ||
| 1037 | + ;; which cost a gap apiece whether or not they have anything in them. This | ||
| 1038 | + ;; margin plus the window's own is what answers them underneath. | ||
| 1039 | + [:hbox {:spacing 8 :align :center :margin-bottom 12} | ||
| 929 | ;; narrow enough that Send keeps its place on a phone-width row | 1040 | ;; narrow enough that Send keeps its place on a phone-width row |
| 930 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field | 1041 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field |
| 931 | ;; answers a paste of text with the text, and one of a picture reaches | 1042 | ;; answers a paste of text with the text, and one of a picture reaches |
| @@ -945,8 +1056,6 @@ | |||
| 945 | 1056 | ||
| 946 | ;; ---------------------------------------------------------------- split | 1057 | ;; ---------------------------------------------------------------- split |
| 947 | 1058 | ||
| 948 | -(def ^:private sidebar-width 320) | ||
| 949 | - | ||
| 950 | (defn- no-chat-pane | 1059 | (defn- no-chat-pane |
| 951 | "What fills the second pane before a conversation has been picked. The pane | 1060 | "What fills the second pane before a conversation has been picked. The pane |
| 952 | is there either way — a list that widened into two columns and back again as | 1061 | is there either way — a list that widened into two columns and back again as |
modified
src/frq/state.jolt +208 -16 | @@ -90,6 +90,13 @@ | ||
| 90 | 90 | (defonce lightbox (atom nil)) ; {:path :url} |
| 91 | 91 | (defonce join-input (atom "")) |
| 92 | 92 | |
| 93 | +;; Whether the chat screen is showing who is in the channel. Off by default: | |
| 94 | +;; the panel costs the conversation a column, and the reader is here for the | |
| 95 | +;; conversation. | |
| 96 | +(defonce show-users? (atom false)) | |
| 97 | + | |
| 98 | +(defn toggle-users! [] (swap! show-users? not)) | |
| 99 | + | |
| 93 | 100 | ;; The window's content width in points, polled from the backend a few times a |
| 94 | 101 | ;; second. The app is laid out for a phone-width window, and this is what lets |
| 95 | 102 | ;; a wide one be more than a phone with margins: past `wide-width` the channel |
| @@ -130,7 +137,7 @@ | ||
| 130 | 137 | |
| 131 | 138 | (defn connected? [] (some? @conn)) |
| 132 | 139 | |
| 133 | -(declare channel-order) | |
| 140 | +(declare channel-order request-names!) | |
| 134 | 141 | |
| 135 | 142 | (defn- remember-channels! |
| 136 | 143 | "Write the order out. Off the caller's thread: opening a channel should not |
| @@ -139,6 +146,12 @@ | ||
| 139 | 146 | [] |
| 140 | 147 | (future (store/save-channels! (channel-order)))) |
| 141 | 148 | |
| 149 | +(defn dm? | |
| 150 | + "Whether a buffer is a conversation with a person rather than a room. Every | |
| 151 | + channel name starts with `#`; what does not is somebody's nick." | |
| 152 | + [name] | |
| 153 | + (and (seq name) (not (str/starts-with? name "#")))) | |
| 154 | + | |
| 142 | 155 | (defn normalize-channel [s] |
| 143 | 156 | (let [s (str/trim (or s ""))] |
| 144 | 157 | (cond (str/blank? s) "" |
| @@ -149,7 +162,9 @@ | ||
| 149 | 162 | (if (contains? m name) |
| 150 | 163 | m |
| 151 | 164 | (assoc m name {:name name :messages [] :unread 0 |
| 152 | - :joined? false :joining? false :accessed 0}))) | |
| 165 | + :joined? false :joining? false :accessed 0 | |
| 166 | + ;; nick -> mode prefix, for the people panel | |
| 167 | + :users {}}))) | |
| 153 | 168 | |
| 154 | 169 | (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the |
| 155 | 170 | chat view re-renders without every message row watching the media cache."} |
| @@ -217,7 +232,11 @@ | ||
| 217 | 232 | (not (:joined? buffer)) |
| 218 | 233 | (not (:joining? buffer))) |
| 219 | 234 | (swap! channels #(assoc-in % [name :joining?] true)) |
| 220 | - (irc/join! @conn name)))) | |
| 235 | + (irc/join! @conn name))) | |
| 236 | + ;; Already in it, and nobody listed: the membership survived a restart the | |
| 237 | + ;; NAMES that came with it did not. | |
| 238 | + (when (:joined? (get @channels name)) | |
| 239 | + (request-names! name))) | |
| 221 | 240 | |
| 222 | 241 | (defn- parse-reactions |
| 223 | 242 | "The server's tally of what is already on a message, as |
| @@ -262,6 +281,129 @@ | ||
| 262 | 281 | msgs)) |
| 263 | 282 | m))))) |
| 264 | 283 | |
| 284 | +;; --- who is in the room ------------------------------------------------------ | |
| 285 | +;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is | |
| 286 | +;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK | |
| 287 | +;; after it. Nothing here asks who is there — being told is what membership is. | |
| 288 | + | |
| 289 | +(def ^:private mode-prefixes | |
| 290 | + "The characters a server puts in front of a nick in NAMES, and in the same | |
| 291 | + order the panel sorts them: owner, admin, op, half-op, voice." | |
| 292 | + "~&@%+") | |
| 293 | + | |
| 294 | +(defn- split-prefix | |
| 295 | + "One NAMES entry into `[prefix nick]`. A nick never starts with one of these, | |
| 296 | + so what is in front of it is a mode and not part of the name." | |
| 297 | + [entry] | |
| 298 | + (if (and (seq entry) (str/index-of mode-prefixes (subs entry 0 1))) | |
| 299 | + [(subs entry 0 1) (subs entry 1)] | |
| 300 | + ["" entry])) | |
| 301 | + | |
| 302 | +(defn- names-line | |
| 303 | + "Fold one 353 into the channel's pending list. Pending rather than live: the | |
| 304 | + reply comes in as many lines as it takes and ends with 366, and replacing | |
| 305 | + `:users` on each of them would empty the panel and refill it a name at a | |
| 306 | + time." | |
| 307 | + [channel names] | |
| 308 | + (swap! channels | |
| 309 | + (fn [m] | |
| 310 | + (reduce (fn [m entry] | |
| 311 | + (let [[prefix nick] (split-prefix entry)] | |
| 312 | + (assoc-in m [channel :names-acc nick] prefix))) | |
| 313 | + (ensure-channel m channel) | |
| 314 | + (remove str/blank? (str/split (or names "") #" ")))))) | |
| 315 | + | |
| 316 | +(defn- names-end! | |
| 317 | + "366: the pending list becomes the list." | |
| 318 | + [channel] | |
| 319 | + (swap! channels | |
| 320 | + (fn [m] | |
| 321 | + (if-let [acc (get-in m [channel :names-acc])] | |
| 322 | + (-> m (assoc-in [channel :users] acc) | |
| 323 | + (update channel dissoc :names-acc)) | |
| 324 | + m)))) | |
| 325 | + | |
| 326 | +(defn- add-user! [channel nick] | |
| 327 | + (when (and channel nick) | |
| 328 | + (swap! channels #(-> (ensure-channel % channel) | |
| 329 | + (update-in [channel :users] (fnil assoc {}) nick ""))))) | |
| 330 | + | |
| 331 | +(defn- remove-user! [channel nick] | |
| 332 | + (when (and channel nick) | |
| 333 | + (swap! channels #(update-in % [channel :users] dissoc nick)))) | |
| 334 | + | |
| 335 | +(defn- remove-user-everywhere! | |
| 336 | + "A QUIT names no channel — the person left the server, so they left every | |
| 337 | + room this client is watching them in." | |
| 338 | + [nick] | |
| 339 | + (swap! channels | |
| 340 | + (fn [m] | |
| 341 | + (reduce-kv (fn [m k v] (assoc m k (update v :users dissoc nick))) | |
| 342 | + {} m)))) | |
| 343 | + | |
| 344 | +(defn- rename-user! | |
| 345 | + "A NICK, in every channel the old name was in. Their modes come with them: | |
| 346 | + renaming is not leaving." | |
| 347 | + [old new] | |
| 348 | + (swap! channels | |
| 349 | + (fn [m] | |
| 350 | + (reduce-kv (fn [m k v] | |
| 351 | + (assoc m k | |
| 352 | + (if-let [prefix (get (:users v) old)] | |
| 353 | + (update v :users #(-> % (dissoc old) (assoc new prefix))) | |
| 354 | + v))) | |
| 355 | + {} m)))) | |
| 356 | + | |
| 357 | +(defn- apply-mode! | |
| 358 | + "A channel MODE, for the letters that change how someone is listed. `params` | |
| 359 | + is the mode string and whoever it was applied to, in order; anything else in | |
| 360 | + it — a key, a limit, a ban — names no member and is skipped." | |
| 361 | + [channel modes args] | |
| 362 | + (let [letters {\q "~" \a "&" \o "@" \h "%" \v "+"}] | |
| 363 | + (loop [chars (seq modes) args args adding? true] | |
| 364 | + (when-let [c (first chars)] | |
| 365 | + (case c | |
| 366 | + \+ (recur (rest chars) args true) | |
| 367 | + \- (recur (rest chars) args false) | |
| 368 | + (if-let [prefix (letters c)] | |
| 369 | + (do (when-let [nick (first args)] | |
| 370 | + (swap! channels | |
| 371 | + (fn [m] | |
| 372 | + (if (get-in m [channel :users nick]) | |
| 373 | + (assoc-in m [channel :users nick] (if adding? prefix "")) | |
| 374 | + m)))) | |
| 375 | + (recur (rest chars) (rest args) adding?)) | |
| 376 | + ;; A mode that takes an argument without naming a member still eats | |
| 377 | + ;; one, and reading the next letter's nick out of the wrong place | |
| 378 | + ;; would put a mode on a stranger. Only the setting form takes one. | |
| 379 | + (recur (rest chars) (if adding? (rest args) args) adding?))))))) | |
| 380 | + | |
| 381 | +(def ^:private prefix-rank | |
| 382 | + (into {"" (count mode-prefixes)} | |
| 383 | + (map-indexed (fn [i c] [(str c) i]) mode-prefixes))) | |
| 384 | + | |
| 385 | +(defn member-list | |
| 386 | + "Who is in `channel`, as `{:nick :prefix}`, ops first and then alphabetically | |
| 387 | + — the order every other client lists them in, and the one a reader scanning | |
| 388 | + for a name expects." | |
| 389 | + [channel] | |
| 390 | + (->> (get-in @channels [channel :users]) | |
| 391 | + (map (fn [[nick prefix]] {:nick nick :prefix prefix})) | |
| 392 | + (sort-by (juxt #(prefix-rank (:prefix %) 99) #(str/lower-case (:nick %)))) | |
| 393 | + vec)) | |
| 394 | + | |
| 395 | +(defn member-count [channel] | |
| 396 | + (count (get-in @channels [channel :users]))) | |
| 397 | + | |
| 398 | +(defn request-names! | |
| 399 | + "Ask who is in a channel we are already in. freeq re-joins an authenticated | |
| 400 | + user's channels at registration, which happens without a JOIN reaching this | |
| 401 | + client — and so without the NAMES that follows one." | |
| 402 | + [channel] | |
| 403 | + (when (and @conn channel (str/starts-with? channel "#") | |
| 404 | + (empty? (get-in @channels [channel :users]))) | |
| 405 | + (irc/send-line! @conn (str "NAMES " channel)))) | |
| 406 | + | |
| 265 | 407 | (declare join! join-call!) |
| 266 | 408 | |
| 267 | 409 | ;; --- calls ------------------------------------------------------------------- |
| @@ -396,13 +538,20 @@ | ||
| 396 | 538 | ;; every channel again, and saying so on top of the backlog |
| 397 | 539 | ;; already there is just a second line of noise. |
| 398 | 540 | (when fresh? (push-message! ch "*" (str "Joined " ch)))) |
| 399 | - (push-message! ch "*" (str from " joined")))) | |
| 541 | + (do (add-user! ch from) | |
| 542 | + (push-message! ch "*" (str from " joined"))))) | |
| 543 | + ;; NAMES, a line at a time. The channel is the parameter that names one: | |
| 544 | + ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that | |
| 545 | + ;; leaves the symbol out shifts everything before the list along by one. | |
| 546 | + "353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))] | |
| 547 | + (when ch (names-line ch (last params)))) | |
| 400 | 548 | ;; End of NAMES. A plain JOIN is replayed history before this arrives, so |
| 401 | 549 | ;; a channel that reaches here with nothing in it was restored rather |
| 402 | 550 | ;; than joined — freeq re-joins an authenticated user's channels at |
| 403 | 551 | ;; registration and leaves the backlog for the client to ask for. |
| 404 | 552 | "366" (let [ch (second params) |
| 405 | 553 | said (remove :system? (get-in @channels [ch :messages]))] |
| 554 | + (names-end! ch) | |
| 406 | 555 | (when (and ch @conn (empty? said)) |
| 407 | 556 | (irc/send-line! @conn |
| 408 | 557 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) |
| @@ -410,9 +559,33 @@ | ||
| 410 | 559 | "PART" (let [ch (first params)] |
| 411 | 560 | (if (= from @form-nick) |
| 412 | 561 | (swap! channels #(-> % (assoc-in [ch :joined?] false) |
| 413 | - (assoc-in [ch :joining?] false))) | |
| 414 | - (push-message! ch "*" (str from " left")))) | |
| 415 | - "QUIT" nil | |
| 562 | + (assoc-in [ch :joining?] false) | |
| 563 | + (assoc-in [ch :users] {}))) | |
| 564 | + (do (remove-user! ch from) | |
| 565 | + (push-message! ch "*" (str from " left"))))) | |
| 566 | + "KICK" (let [[ch who] params] | |
| 567 | + (if (= who @form-nick) | |
| 568 | + (swap! channels #(-> % (assoc-in [ch :joined?] false) | |
| 569 | + (assoc-in [ch :joining?] false) | |
| 570 | + (assoc-in [ch :users] {}))) | |
| 571 | + (remove-user! ch who)) | |
| 572 | + (push-message! ch "*" (str who " was kicked by " from))) | |
| 573 | + ;; A QUIT and a NICK name no channel, so both are folded into every | |
| 574 | + ;; buffer the person was listed in — and said out loud only where they | |
| 575 | + ;; were, which is what keeps a stranger's rename out of a quiet room. | |
| 576 | + "QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)] | |
| 577 | + (doseq [ch rooms] | |
| 578 | + (push-message! ch "*" (str from " quit"))) | |
| 579 | + (remove-user-everywhere! from)) | |
| 580 | + "NICK" (let [new-nick (last params) | |
| 581 | + rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)] | |
| 582 | + (when (= from @form-nick) (reset! form-nick new-nick)) | |
| 583 | + (doseq [ch rooms] | |
| 584 | + (push-message! ch "*" (str from " is now " new-nick))) | |
| 585 | + (rename-user! from new-nick)) | |
| 586 | + "MODE" (let [[target modes & args] params] | |
| 587 | + (when (str/starts-with? (or target "") "#") | |
| 588 | + (apply-mode! target modes args))) | |
| 416 | 589 | ("NOTICE" "372" "375" "376" "002" "003" "004") |
| 417 | 590 | (reset! status (or (last params) @status)) |
| 418 | 591 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, |
| @@ -449,7 +622,7 @@ | ||
| 449 | 622 | (reset! connecting? false) |
| 450 | 623 | (swap! channels |
| 451 | 624 | #(reduce-kv (fn [m k v] |
| 452 | - (assoc m k (assoc v :joined? false :joining? false))) | |
| 625 | + (assoc m k (assoc v :joined? false :joining? false :users {}))) | |
| 453 | 626 | {} %)) |
| 454 | 627 | (reset! status "Disconnected")) |
| 455 | 628 | "*ERROR*" (do (reset! error (first params)) |
| @@ -612,18 +785,35 @@ | ||
| 612 | 785 | ;; The buffers survive, the memberships do not — leaving `joined?` set would |
| 613 | 786 | ;; have the next Open show a channel nobody is in. |
| 614 | 787 | (swap! channels #(reduce-kv (fn [m k v] |
| 615 | - (assoc m k (assoc v :joined? false :joining? false))) | |
| 788 | + (assoc m k (assoc v :joined? false :joining? false :users {}))) | |
| 616 | 789 | {} %)) |
| 617 | 790 | (reset! status "Not connected") |
| 618 | 791 | (reset! screen :connect)) |
| 619 | 792 | |
| 793 | +(defn open-dm! | |
| 794 | + "Open a conversation with one person. There is nothing to join — a DM buffer | |
| 795 | + is a place to type at somebody, and it exists as soon as it is asked for. | |
| 796 | + | |
| 797 | + Our own nick is not one of them: a buffer talking to yourself would take the | |
| 798 | + place in the list of one that could answer." | |
| 799 | + [nick] | |
| 800 | + (let [nick (str/trim (or nick ""))] | |
| 801 | + (when (and (seq nick) (not= nick @form-nick)) | |
| 802 | + (open-channel! nick)))) | |
| 803 | + | |
| 620 | 804 | (defn join! [name] |
| 621 | 805 | ;; Deliberately not clearing `error` here: joining is what follows a |
| 622 | 806 | ;; successful registration, and a SASL refusal that arrived moments earlier |
| 623 | 807 | ;; is the one thing the user most needs to still be on screen. |
| 624 | - (let [ch (normalize-channel name)] | |
| 625 | - (when (seq ch) | |
| 626 | - (open-channel! ch)))) | |
| 808 | + ;; | |
| 809 | + ;; `@nick` opens a DM instead. One box for both: what the reader wants is to | |
| 810 | + ;; be somewhere, and the sigil says where — the same way it does on the wire. | |
| 811 | + (let [name (str/trim (or name ""))] | |
| 812 | + (if (str/starts-with? name "@") | |
| 813 | + (open-dm! (subs name 1)) | |
| 814 | + (let [ch (normalize-channel name)] | |
| 815 | + (when (seq ch) | |
| 816 | + (open-channel! ch)))))) | |
| 627 | 817 | |
| 628 | 818 | ;; ------------------------------------------------------------------ pasting |
| 629 | 819 | |
| @@ -1050,12 +1240,13 @@ | ||
| 1050 | 1240 | vec))) |
| 1051 | 1241 | |
| 1052 | 1242 | (defn channel-order |
| 1053 | - "Just the channel names, most recently opened first — what gets written to | |
| 1054 | - disk. Buffers never opened are left out: a DM that arrived once is not a | |
| 1055 | - place this client has been." | |
| 1243 | + "The buffer names, most recently opened first — what gets written to disk. | |
| 1244 | + Buffers never opened are left out: a DM that arrived once and was never read | |
| 1245 | + is not a place this client has been, and neither is a channel someone | |
| 1246 | + mentioned. One that was opened is, whether it is a room or a person." | |
| 1056 | 1247 | [] |
| 1057 | 1248 | (->> (vals @channels) |
| 1058 | - (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0)))) | |
| 1249 | + (filter #(pos? (:accessed % 0))) | |
| 1059 | 1250 | (sort-by #(- (:accessed % 0))) |
| 1060 | 1251 | (mapv :name))) |
| 1061 | 1252 | |
| @@ -1075,6 +1266,7 @@ | ||
| 1075 | 1266 | acc |
| 1076 | 1267 | (assoc acc name {:name name :messages [] :unread 0 |
| 1077 | 1268 | :joined? false :joining? false |
| 1269 | + :users {} | |
| 1078 | 1270 | :accessed (swap! access-tick inc)}))) |
| 1079 | 1271 | m |
| 1080 | 1272 | ordered)))) |
| @@ -90,6 +90,13 @@ | |||
| 90 | (defonce lightbox (atom nil)) ; {:path :url} | 90 | (defonce lightbox (atom nil)) ; {:path :url} |
| 91 | (defonce join-input (atom "")) | 91 | (defonce join-input (atom "")) |
| 92 | 92 | ||
| 93 | +;; Whether the chat screen is showing who is in the channel. Off by default: | ||
| 94 | +;; the panel costs the conversation a column, and the reader is here for the | ||
| 95 | +;; conversation. | ||
| 96 | +(defonce show-users? (atom false)) | ||
| 97 | + | ||
| 98 | +(defn toggle-users! [] (swap! show-users? not)) | ||
| 99 | + | ||
| 93 | ;; The window's content width in points, polled from the backend a few times a | 100 | ;; The window's content width in points, polled from the backend a few times a |
| 94 | ;; second. The app is laid out for a phone-width window, and this is what lets | 101 | ;; second. The app is laid out for a phone-width window, and this is what lets |
| 95 | ;; a wide one be more than a phone with margins: past `wide-width` the channel | 102 | ;; a wide one be more than a phone with margins: past `wide-width` the channel |
| @@ -130,7 +137,7 @@ | |||
| 130 | 137 | ||
| 131 | (defn connected? [] (some? @conn)) | 138 | (defn connected? [] (some? @conn)) |
| 132 | 139 | ||
| 133 | -(declare channel-order) | 140 | +(declare channel-order request-names!) |
| 134 | 141 | ||
| 135 | (defn- remember-channels! | 142 | (defn- remember-channels! |
| 136 | "Write the order out. Off the caller's thread: opening a channel should not | 143 | "Write the order out. Off the caller's thread: opening a channel should not |
| @@ -139,6 +146,12 @@ | |||
| 139 | [] | 146 | [] |
| 140 | (future (store/save-channels! (channel-order)))) | 147 | (future (store/save-channels! (channel-order)))) |
| 141 | 148 | ||
| 149 | +(defn dm? | ||
| 150 | + "Whether a buffer is a conversation with a person rather than a room. Every | ||
| 151 | + channel name starts with `#`; what does not is somebody's nick." | ||
| 152 | + [name] | ||
| 153 | + (and (seq name) (not (str/starts-with? name "#")))) | ||
| 154 | + | ||
| 142 | (defn normalize-channel [s] | 155 | (defn normalize-channel [s] |
| 143 | (let [s (str/trim (or s ""))] | 156 | (let [s (str/trim (or s ""))] |
| 144 | (cond (str/blank? s) "" | 157 | (cond (str/blank? s) "" |
| @@ -149,7 +162,9 @@ | |||
| 149 | (if (contains? m name) | 162 | (if (contains? m name) |
| 150 | m | 163 | m |
| 151 | (assoc m name {:name name :messages [] :unread 0 | 164 | (assoc m name {:name name :messages [] :unread 0 |
| 152 | - :joined? false :joining? false :accessed 0}))) | 165 | + :joined? false :joining? false :accessed 0 |
| 166 | + ;; nick -> mode prefix, for the people panel | ||
| 167 | + :users {}}))) | ||
| 153 | 168 | ||
| 154 | (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the | 169 | (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the |
| 155 | chat view re-renders without every message row watching the media cache."} | 170 | chat view re-renders without every message row watching the media cache."} |
| @@ -217,7 +232,11 @@ | |||
| 217 | (not (:joined? buffer)) | 232 | (not (:joined? buffer)) |
| 218 | (not (:joining? buffer))) | 233 | (not (:joining? buffer))) |
| 219 | (swap! channels #(assoc-in % [name :joining?] true)) | 234 | (swap! channels #(assoc-in % [name :joining?] true)) |
| 220 | - (irc/join! @conn name)))) | 235 | + (irc/join! @conn name))) |
| 236 | + ;; Already in it, and nobody listed: the membership survived a restart the | ||
| 237 | + ;; NAMES that came with it did not. | ||
| 238 | + (when (:joined? (get @channels name)) | ||
| 239 | + (request-names! name))) | ||
| 221 | 240 | ||
| 222 | (defn- parse-reactions | 241 | (defn- parse-reactions |
| 223 | "The server's tally of what is already on a message, as | 242 | "The server's tally of what is already on a message, as |
| @@ -262,6 +281,129 @@ | |||
| 262 | msgs)) | 281 | msgs)) |
| 263 | m))))) | 282 | m))))) |
| 264 | 283 | ||
| 284 | +;; --- who is in the room ------------------------------------------------------ | ||
| 285 | +;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is | ||
| 286 | +;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK | ||
| 287 | +;; after it. Nothing here asks who is there — being told is what membership is. | ||
| 288 | + | ||
| 289 | +(def ^:private mode-prefixes | ||
| 290 | + "The characters a server puts in front of a nick in NAMES, and in the same | ||
| 291 | + order the panel sorts them: owner, admin, op, half-op, voice." | ||
| 292 | + "~&@%+") | ||
| 293 | + | ||
| 294 | +(defn- split-prefix | ||
| 295 | + "One NAMES entry into `[prefix nick]`. A nick never starts with one of these, | ||
| 296 | + so what is in front of it is a mode and not part of the name." | ||
| 297 | + [entry] | ||
| 298 | + (if (and (seq entry) (str/index-of mode-prefixes (subs entry 0 1))) | ||
| 299 | + [(subs entry 0 1) (subs entry 1)] | ||
| 300 | + ["" entry])) | ||
| 301 | + | ||
| 302 | +(defn- names-line | ||
| 303 | + "Fold one 353 into the channel's pending list. Pending rather than live: the | ||
| 304 | + reply comes in as many lines as it takes and ends with 366, and replacing | ||
| 305 | + `:users` on each of them would empty the panel and refill it a name at a | ||
| 306 | + time." | ||
| 307 | + [channel names] | ||
| 308 | + (swap! channels | ||
| 309 | + (fn [m] | ||
| 310 | + (reduce (fn [m entry] | ||
| 311 | + (let [[prefix nick] (split-prefix entry)] | ||
| 312 | + (assoc-in m [channel :names-acc nick] prefix))) | ||
| 313 | + (ensure-channel m channel) | ||
| 314 | + (remove str/blank? (str/split (or names "") #" ")))))) | ||
| 315 | + | ||
| 316 | +(defn- names-end! | ||
| 317 | + "366: the pending list becomes the list." | ||
| 318 | + [channel] | ||
| 319 | + (swap! channels | ||
| 320 | + (fn [m] | ||
| 321 | + (if-let [acc (get-in m [channel :names-acc])] | ||
| 322 | + (-> m (assoc-in [channel :users] acc) | ||
| 323 | + (update channel dissoc :names-acc)) | ||
| 324 | + m)))) | ||
| 325 | + | ||
| 326 | +(defn- add-user! [channel nick] | ||
| 327 | + (when (and channel nick) | ||
| 328 | + (swap! channels #(-> (ensure-channel % channel) | ||
| 329 | + (update-in [channel :users] (fnil assoc {}) nick ""))))) | ||
| 330 | + | ||
| 331 | +(defn- remove-user! [channel nick] | ||
| 332 | + (when (and channel nick) | ||
| 333 | + (swap! channels #(update-in % [channel :users] dissoc nick)))) | ||
| 334 | + | ||
| 335 | +(defn- remove-user-everywhere! | ||
| 336 | + "A QUIT names no channel — the person left the server, so they left every | ||
| 337 | + room this client is watching them in." | ||
| 338 | + [nick] | ||
| 339 | + (swap! channels | ||
| 340 | + (fn [m] | ||
| 341 | + (reduce-kv (fn [m k v] (assoc m k (update v :users dissoc nick))) | ||
| 342 | + {} m)))) | ||
| 343 | + | ||
| 344 | +(defn- rename-user! | ||
| 345 | + "A NICK, in every channel the old name was in. Their modes come with them: | ||
| 346 | + renaming is not leaving." | ||
| 347 | + [old new] | ||
| 348 | + (swap! channels | ||
| 349 | + (fn [m] | ||
| 350 | + (reduce-kv (fn [m k v] | ||
| 351 | + (assoc m k | ||
| 352 | + (if-let [prefix (get (:users v) old)] | ||
| 353 | + (update v :users #(-> % (dissoc old) (assoc new prefix))) | ||
| 354 | + v))) | ||
| 355 | + {} m)))) | ||
| 356 | + | ||
| 357 | +(defn- apply-mode! | ||
| 358 | + "A channel MODE, for the letters that change how someone is listed. `params` | ||
| 359 | + is the mode string and whoever it was applied to, in order; anything else in | ||
| 360 | + it — a key, a limit, a ban — names no member and is skipped." | ||
| 361 | + [channel modes args] | ||
| 362 | + (let [letters {\q "~" \a "&" \o "@" \h "%" \v "+"}] | ||
| 363 | + (loop [chars (seq modes) args args adding? true] | ||
| 364 | + (when-let [c (first chars)] | ||
| 365 | + (case c | ||
| 366 | + \+ (recur (rest chars) args true) | ||
| 367 | + \- (recur (rest chars) args false) | ||
| 368 | + (if-let [prefix (letters c)] | ||
| 369 | + (do (when-let [nick (first args)] | ||
| 370 | + (swap! channels | ||
| 371 | + (fn [m] | ||
| 372 | + (if (get-in m [channel :users nick]) | ||
| 373 | + (assoc-in m [channel :users nick] (if adding? prefix "")) | ||
| 374 | + m)))) | ||
| 375 | + (recur (rest chars) (rest args) adding?)) | ||
| 376 | + ;; A mode that takes an argument without naming a member still eats | ||
| 377 | + ;; one, and reading the next letter's nick out of the wrong place | ||
| 378 | + ;; would put a mode on a stranger. Only the setting form takes one. | ||
| 379 | + (recur (rest chars) (if adding? (rest args) args) adding?))))))) | ||
| 380 | + | ||
| 381 | +(def ^:private prefix-rank | ||
| 382 | + (into {"" (count mode-prefixes)} | ||
| 383 | + (map-indexed (fn [i c] [(str c) i]) mode-prefixes))) | ||
| 384 | + | ||
| 385 | +(defn member-list | ||
| 386 | + "Who is in `channel`, as `{:nick :prefix}`, ops first and then alphabetically | ||
| 387 | + — the order every other client lists them in, and the one a reader scanning | ||
| 388 | + for a name expects." | ||
| 389 | + [channel] | ||
| 390 | + (->> (get-in @channels [channel :users]) | ||
| 391 | + (map (fn [[nick prefix]] {:nick nick :prefix prefix})) | ||
| 392 | + (sort-by (juxt #(prefix-rank (:prefix %) 99) #(str/lower-case (:nick %)))) | ||
| 393 | + vec)) | ||
| 394 | + | ||
| 395 | +(defn member-count [channel] | ||
| 396 | + (count (get-in @channels [channel :users]))) | ||
| 397 | + | ||
| 398 | +(defn request-names! | ||
| 399 | + "Ask who is in a channel we are already in. freeq re-joins an authenticated | ||
| 400 | + user's channels at registration, which happens without a JOIN reaching this | ||
| 401 | + client — and so without the NAMES that follows one." | ||
| 402 | + [channel] | ||
| 403 | + (when (and @conn channel (str/starts-with? channel "#") | ||
| 404 | + (empty? (get-in @channels [channel :users]))) | ||
| 405 | + (irc/send-line! @conn (str "NAMES " channel)))) | ||
| 406 | + | ||
| 265 | (declare join! join-call!) | 407 | (declare join! join-call!) |
| 266 | 408 | ||
| 267 | ;; --- calls ------------------------------------------------------------------- | 409 | ;; --- calls ------------------------------------------------------------------- |
| @@ -396,13 +538,20 @@ | |||
| 396 | ;; every channel again, and saying so on top of the backlog | 538 | ;; every channel again, and saying so on top of the backlog |
| 397 | ;; already there is just a second line of noise. | 539 | ;; already there is just a second line of noise. |
| 398 | (when fresh? (push-message! ch "*" (str "Joined " ch)))) | 540 | (when fresh? (push-message! ch "*" (str "Joined " ch)))) |
| 399 | - (push-message! ch "*" (str from " joined")))) | 541 | + (do (add-user! ch from) |
| 542 | + (push-message! ch "*" (str from " joined"))))) | ||
| 543 | + ;; NAMES, a line at a time. The channel is the parameter that names one: | ||
| 544 | + ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that | ||
| 545 | + ;; leaves the symbol out shifts everything before the list along by one. | ||
| 546 | + "353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))] | ||
| 547 | + (when ch (names-line ch (last params)))) | ||
| 400 | ;; End of NAMES. A plain JOIN is replayed history before this arrives, so | 548 | ;; End of NAMES. A plain JOIN is replayed history before this arrives, so |
| 401 | ;; a channel that reaches here with nothing in it was restored rather | 549 | ;; a channel that reaches here with nothing in it was restored rather |
| 402 | ;; than joined — freeq re-joins an authenticated user's channels at | 550 | ;; than joined — freeq re-joins an authenticated user's channels at |
| 403 | ;; registration and leaves the backlog for the client to ask for. | 551 | ;; registration and leaves the backlog for the client to ask for. |
| 404 | "366" (let [ch (second params) | 552 | "366" (let [ch (second params) |
| 405 | said (remove :system? (get-in @channels [ch :messages]))] | 553 | said (remove :system? (get-in @channels [ch :messages]))] |
| 554 | + (names-end! ch) | ||
| 406 | (when (and ch @conn (empty? said)) | 555 | (when (and ch @conn (empty? said)) |
| 407 | (irc/send-line! @conn | 556 | (irc/send-line! @conn |
| 408 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) | 557 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) |
| @@ -410,9 +559,33 @@ | |||
| 410 | "PART" (let [ch (first params)] | 559 | "PART" (let [ch (first params)] |
| 411 | (if (= from @form-nick) | 560 | (if (= from @form-nick) |
| 412 | (swap! channels #(-> % (assoc-in [ch :joined?] false) | 561 | (swap! channels #(-> % (assoc-in [ch :joined?] false) |
| 413 | - (assoc-in [ch :joining?] false))) | 562 | + (assoc-in [ch :joining?] false) |
| 414 | - (push-message! ch "*" (str from " left")))) | 563 | + (assoc-in [ch :users] {}))) |
| 415 | - "QUIT" nil | 564 | + (do (remove-user! ch from) |
| 565 | + (push-message! ch "*" (str from " left"))))) | ||
| 566 | + "KICK" (let [[ch who] params] | ||
| 567 | + (if (= who @form-nick) | ||
| 568 | + (swap! channels #(-> % (assoc-in [ch :joined?] false) | ||
| 569 | + (assoc-in [ch :joining?] false) | ||
| 570 | + (assoc-in [ch :users] {}))) | ||
| 571 | + (remove-user! ch who)) | ||
| 572 | + (push-message! ch "*" (str who " was kicked by " from))) | ||
| 573 | + ;; A QUIT and a NICK name no channel, so both are folded into every | ||
| 574 | + ;; buffer the person was listed in — and said out loud only where they | ||
| 575 | + ;; were, which is what keeps a stranger's rename out of a quiet room. | ||
| 576 | + "QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)] | ||
| 577 | + (doseq [ch rooms] | ||
| 578 | + (push-message! ch "*" (str from " quit"))) | ||
| 579 | + (remove-user-everywhere! from)) | ||
| 580 | + "NICK" (let [new-nick (last params) | ||
| 581 | + rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)] | ||
| 582 | + (when (= from @form-nick) (reset! form-nick new-nick)) | ||
| 583 | + (doseq [ch rooms] | ||
| 584 | + (push-message! ch "*" (str from " is now " new-nick))) | ||
| 585 | + (rename-user! from new-nick)) | ||
| 586 | + "MODE" (let [[target modes & args] params] | ||
| 587 | + (when (str/starts-with? (or target "") "#") | ||
| 588 | + (apply-mode! target modes args))) | ||
| 416 | ("NOTICE" "372" "375" "376" "002" "003" "004") | 589 | ("NOTICE" "372" "375" "376" "002" "003" "004") |
| 417 | (reset! status (or (last params) @status)) | 590 | (reset! status (or (last params) @status)) |
| 418 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, | 591 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, |
| @@ -449,7 +622,7 @@ | |||
| 449 | (reset! connecting? false) | 622 | (reset! connecting? false) |
| 450 | (swap! channels | 623 | (swap! channels |
| 451 | #(reduce-kv (fn [m k v] | 624 | #(reduce-kv (fn [m k v] |
| 452 | - (assoc m k (assoc v :joined? false :joining? false))) | 625 | + (assoc m k (assoc v :joined? false :joining? false :users {}))) |
| 453 | {} %)) | 626 | {} %)) |
| 454 | (reset! status "Disconnected")) | 627 | (reset! status "Disconnected")) |
| 455 | "*ERROR*" (do (reset! error (first params)) | 628 | "*ERROR*" (do (reset! error (first params)) |
| @@ -612,18 +785,35 @@ | |||
| 612 | ;; The buffers survive, the memberships do not — leaving `joined?` set would | 785 | ;; The buffers survive, the memberships do not — leaving `joined?` set would |
| 613 | ;; have the next Open show a channel nobody is in. | 786 | ;; have the next Open show a channel nobody is in. |
| 614 | (swap! channels #(reduce-kv (fn [m k v] | 787 | (swap! channels #(reduce-kv (fn [m k v] |
| 615 | - (assoc m k (assoc v :joined? false :joining? false))) | 788 | + (assoc m k (assoc v :joined? false :joining? false :users {}))) |
| 616 | {} %)) | 789 | {} %)) |
| 617 | (reset! status "Not connected") | 790 | (reset! status "Not connected") |
| 618 | (reset! screen :connect)) | 791 | (reset! screen :connect)) |
| 619 | 792 | ||
| 793 | +(defn open-dm! | ||
| 794 | + "Open a conversation with one person. There is nothing to join — a DM buffer | ||
| 795 | + is a place to type at somebody, and it exists as soon as it is asked for. | ||
| 796 | + | ||
| 797 | + Our own nick is not one of them: a buffer talking to yourself would take the | ||
| 798 | + place in the list of one that could answer." | ||
| 799 | + [nick] | ||
| 800 | + (let [nick (str/trim (or nick ""))] | ||
| 801 | + (when (and (seq nick) (not= nick @form-nick)) | ||
| 802 | + (open-channel! nick)))) | ||
| 803 | + | ||
| 620 | (defn join! [name] | 804 | (defn join! [name] |
| 621 | ;; Deliberately not clearing `error` here: joining is what follows a | 805 | ;; Deliberately not clearing `error` here: joining is what follows a |
| 622 | ;; successful registration, and a SASL refusal that arrived moments earlier | 806 | ;; successful registration, and a SASL refusal that arrived moments earlier |
| 623 | ;; is the one thing the user most needs to still be on screen. | 807 | ;; is the one thing the user most needs to still be on screen. |
| 624 | - (let [ch (normalize-channel name)] | 808 | + ;; |
| 625 | - (when (seq ch) | 809 | + ;; `@nick` opens a DM instead. One box for both: what the reader wants is to |
| 626 | - (open-channel! ch)))) | 810 | + ;; be somewhere, and the sigil says where — the same way it does on the wire. |
| 811 | + (let [name (str/trim (or name ""))] | ||
| 812 | + (if (str/starts-with? name "@") | ||
| 813 | + (open-dm! (subs name 1)) | ||
| 814 | + (let [ch (normalize-channel name)] | ||
| 815 | + (when (seq ch) | ||
| 816 | + (open-channel! ch)))))) | ||
| 627 | 817 | ||
| 628 | ;; ------------------------------------------------------------------ pasting | 818 | ;; ------------------------------------------------------------------ pasting |
| 629 | 819 | ||
| @@ -1050,12 +1240,13 @@ | |||
| 1050 | vec))) | 1240 | vec))) |
| 1051 | 1241 | ||
| 1052 | (defn channel-order | 1242 | (defn channel-order |
| 1053 | - "Just the channel names, most recently opened first — what gets written to | 1243 | + "The buffer names, most recently opened first — what gets written to disk. |
| 1054 | - disk. Buffers never opened are left out: a DM that arrived once is not a | 1244 | + Buffers never opened are left out: a DM that arrived once and was never read |
| 1055 | - place this client has been." | 1245 | + is not a place this client has been, and neither is a channel someone |
| 1246 | + mentioned. One that was opened is, whether it is a room or a person." | ||
| 1056 | [] | 1247 | [] |
| 1057 | (->> (vals @channels) | 1248 | (->> (vals @channels) |
| 1058 | - (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0)))) | 1249 | + (filter #(pos? (:accessed % 0))) |
| 1059 | (sort-by #(- (:accessed % 0))) | 1250 | (sort-by #(- (:accessed % 0))) |
| 1060 | (mapv :name))) | 1251 | (mapv :name))) |
| 1061 | 1252 | ||
| @@ -1075,6 +1266,7 @@ | |||
| 1075 | acc | 1266 | acc |
| 1076 | (assoc acc name {:name name :messages [] :unread 0 | 1267 | (assoc acc name {:name name :messages [] :unread 0 |
| 1077 | :joined? false :joining? false | 1268 | :joined? false :joining? false |
| 1269 | + :users {} | ||
| 1078 | :accessed (swap! access-tick inc)}))) | 1270 | :accessed (swap! access-tick inc)}))) |
| 1079 | m | 1271 | m |
| 1080 | ordered)))) | 1272 | ordered)))) |