nandi/frqpublic Fork 0
f3d5db6
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Merge remote-tracking branch 'origin/main' into claude/emoji-reacts-irc-webview-c28ce6

nandi committed 2026-08-30T19:45:23-07:00 Browse files
f3d5db6 parents: 767b3bf 4256fb3
modified src/frq/app.jolt +140 -31
@@ -149,12 +149,32 @@
149149 ;; `:wrap false` on the badges: they sit beside each other or not at all.
150150 [:title-2 {:label name}]
151151 [: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")
153158 :live (boolean (:joined? buffer))}]
154159 (when (pos? unread) [:label {:label (str "" unread)}])]
155160 [:dim-label {:label (s/last-preview buffer)}]
156161 [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}]]))
157162
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+
158178 (defn chats-screen []
159179 (let [buffers (s/channel-list)]
160180 [:page {:max-width 620}
@@ -171,12 +191,14 @@
171191 [:card {}
172192 [:vbox {:spacing 8}
173193 [:hbox {:spacing 8}
194+ ;; Both kinds of conversation through one box: `#room` joins a
195+ ;; channel, `@nick` opens a message to a person.
174196 [:entry {:text @s/join-input
175- :width-request 380
176- :placeholder "#channel"
197+ :width-request (control-entry-width)
198+ :placeholder "#channel or @nick"
177199 :on-change #(reset! s/join-input %)
178200 :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")
180202 :kind :primary
181203 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]
182204 ;; The clear button only shows while there is something to clear: an
@@ -184,7 +206,7 @@
184206 ;; one that stopped working.
185207 [:hbox {:spacing 8}
186208 [:entry {:text @s/search
187- :width-request 380
209+ :width-request (control-entry-width)
188210 :placeholder "Search channels"
189211 :on-change #(reset! s/search %)}]
190212 (when (seq @s/search)
@@ -833,15 +855,86 @@
833855 (when (and (empty? dirs) (empty? files))
834856 [:dim-label {:label "No pictures here that this app can read."}])]]]]))
835857
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+
836928 (defn chat-screen []
837929 (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 "#"))]
839932 ;; Not a :page a page scrolls everything, which would carry the compose
840933 ;; bar off the bottom with the backlog. The message list is the only thing
841934 ;; 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}
845938 [:hbox {:spacing 8}
846939 ;; The way back to the list, on a window with room for one thing at a
847940 ;; time. Beside the list there is nothing to go back to, so the button
@@ -860,7 +953,15 @@
860953 (av/available?)
861954 (not (av/call-in name))
862955 (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!}])]]
864965 [error-note]
865966 [call-bar name]
866967 ;; :reserve leaves room for everything below: the jump button's row, the
@@ -873,22 +974,28 @@
873974 ;; unmounts the backlog behind it; without a name of its own the position
874975 ;; would come back as a fresh one, and Back would answer a click on a
875976 ;; 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])]]
892999 ;; Only while it is needed, and directly above the compose bar: the way
8931000 ;; back to the present belongs next to the thing that puts you there.
8941001 ;; The row keeps its height whether or not the button is in it, so the
@@ -923,9 +1030,13 @@
9231030 ;; window wider than that a left-aligned row leaves it stranded in the
9241031 ;; corner of an otherwise empty bar. On a phone the row is as wide as the
9251032 ;; 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}
9291040 ;; narrow enough that Send keeps its place on a phone-width row
9301041 ;; A picture is pasted where everything else is typed: Ctrl+V. The field
9311042 ;; answers a paste of text with the text, and one of a picture reaches
@@ -945,8 +1056,6 @@
9451056
9461057 ;; ---------------------------------------------------------------- split
9471058
948-(def ^:private sidebar-width 320)
949-
9501059 (defn- no-chat-pane
9511060 "What fills the second pane before a conversation has been picked. The pane
9521061 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-input196 [:entry {:text @s/join-input
175- :width-request 380197+ :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 :primary202 :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: an204 ;; 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/search208 [:entry {:text @s/search
187- :width-request 380209+ :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/current929 (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 compose932 ;; 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 thing933 ;; 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 the935+ ;; 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 a939 ;; 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 button940 ;; 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, the967 ;; :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 position974 ;; 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 a975 ;; 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 :vertical978+ ;; 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 the979+ ;; of its own: a child that appeared and vanished would renumber the row
879- ;; pasted picture when there is one. Those rows do move the980+ ;; for the reconciler, and take the message list's scroll position with
880- ;; compose bar, and should: each appears because the reader just981+ ;; it every time the panel was toggled.
881- ;; asked for something unlike the jump button, which appears982+ [: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 (+ 124984+ :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 true987+ :orientation :vertical
887- :scroll-to-bottom @s/jump-tick988+ :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 way999 ;; 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 the1001 ;; 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 the1030 ;; 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 the1031 ;; 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 flat1033+ ;; 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 row1040 ;; 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 field1041 ;; 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 reaches1042 ;; answers a paste of text with the text, and one of a picture reaches
@@ -945,8 +1056,6 @@
945 1056
946 ;; ---------------------------------------------------------------- split1057 ;; ---------------------------------------------------------------- split
947 1058
948-(def ^:private sidebar-width 320)
949-
950 (defn- no-chat-pane1059 (defn- no-chat-pane
951 "What fills the second pane before a conversation has been picked. The pane1060 "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 as1061 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 @@
9090 (defonce lightbox (atom nil)) ; {:path :url}
9191 (defonce join-input (atom ""))
9292
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+
93100 ;; The window's content width in points, polled from the backend a few times a
94101 ;; second. The app is laid out for a phone-width window, and this is what lets
95102 ;; a wide one be more than a phone with margins: past `wide-width` the channel
@@ -130,7 +137,7 @@
130137
131138 (defn connected? [] (some? @conn))
132139
133-(declare channel-order)
140+(declare channel-order request-names!)
134141
135142 (defn- remember-channels!
136143 "Write the order out. Off the caller's thread: opening a channel should not
@@ -139,6 +146,12 @@
139146 []
140147 (future (store/save-channels! (channel-order))))
141148
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+
142155 (defn normalize-channel [s]
143156 (let [s (str/trim (or s ""))]
144157 (cond (str/blank? s) ""
@@ -149,7 +162,9 @@
149162 (if (contains? m name)
150163 m
151164 (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 {}})))
153168
154169 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
155170 chat view re-renders without every message row watching the media cache."}
@@ -217,7 +232,11 @@
217232 (not (:joined? buffer))
218233 (not (:joining? buffer)))
219234 (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)))
221240
222241 (defn- parse-reactions
223242 "The server's tally of what is already on a message, as
@@ -262,6 +281,129 @@
262281 msgs))
263282 m)))))
264283
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+
265407 (declare join! join-call!)
266408
267409 ;; --- calls -------------------------------------------------------------------
@@ -396,13 +538,20 @@
396538 ;; every channel again, and saying so on top of the backlog
397539 ;; already there is just a second line of noise.
398540 (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))))
400548 ;; End of NAMES. A plain JOIN is replayed history before this arrives, so
401549 ;; a channel that reaches here with nothing in it was restored rather
402550 ;; than joined freeq re-joins an authenticated user's channels at
403551 ;; registration and leaves the backlog for the client to ask for.
404552 "366" (let [ch (second params)
405553 said (remove :system? (get-in @channels [ch :messages]))]
554+ (names-end! ch)
406555 (when (and ch @conn (empty? said))
407556 (irc/send-line! @conn
408557 (str "CHATHISTORY LATEST " ch " * " history-limit))))
@@ -410,9 +559,33 @@
410559 "PART" (let [ch (first params)]
411560 (if (= from @form-nick)
412561 (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)))
416589 ("NOTICE" "372" "375" "376" "002" "003" "004")
417590 (reset! status (or (last params) @status))
418591 ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
@@ -449,7 +622,7 @@
449622 (reset! connecting? false)
450623 (swap! channels
451624 #(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 {})))
453626 {} %))
454627 (reset! status "Disconnected"))
455628 "*ERROR*" (do (reset! error (first params))
@@ -612,18 +785,35 @@
612785 ;; The buffers survive, the memberships do not leaving `joined?` set would
613786 ;; have the next Open show a channel nobody is in.
614787 (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 {})))
616789 {} %))
617790 (reset! status "Not connected")
618791 (reset! screen :connect))
619792
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+
620804 (defn join! [name]
621805 ;; Deliberately not clearing `error` here: joining is what follows a
622806 ;; successful registration, and a SASL refusal that arrived moments earlier
623807 ;; 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))))))
627817
628818 ;; ------------------------------------------------------------------ pasting
629819
@@ -1050,12 +1240,13 @@
10501240 vec)))
10511241
10521242 (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."
10561247 []
10571248 (->> (vals @channels)
1058- (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0))))
1249+ (filter #(pos? (:accessed % 0)))
10591250 (sort-by #(- (:accessed % 0)))
10601251 (mapv :name)))
10611252
@@ -1075,6 +1266,7 @@
10751266 acc
10761267 (assoc acc name {:name name :messages [] :unread 0
10771268 :joined? false :joining? false
1269+ :users {}
10781270 :accessed (swap! access-tick inc)})))
10791271 m
10801272 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 a100 ;; 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 lets101 ;; 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 channel102 ;; 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 not143 "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 m163 m
151 (assoc m name {:name name :messages [] :unread 0164 (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 the169 (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-reactions241 (defn- parse-reactions
223 "The server's tally of what is already on a message, as242 "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 backlog538 ;; 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, so548 ;; 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 rather549 ;; a channel that reaches here with nothing in it was restored rather
402 ;; than joined freeq re-joins an authenticated user's channels at550 ;; 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! @conn556 (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" nil564+ (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! channels623 (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 would785 ;; 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 a805 ;; Deliberately not clearing `error` here: joining is what follows a
622 ;; successful registration, and a SASL refusal that arrived moments earlier806 ;; 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 ;; ------------------------------------------------------------------ pasting818 ;; ------------------------------------------------------------------ pasting
629 819
@@ -1050,12 +1240,13 @@
1050 vec)))1240 vec)))
1051 1241
1052 (defn channel-order1242 (defn channel-order
1053- "Just the channel names, most recently opened first — what gets written to1243+ "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 a1244+ 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 acc1266 acc
1076 (assoc acc name {:name name :messages [] :unread 01267 (assoc acc name {:name name :messages [] :unread 0
1077 :joined? false :joining? false1268 :joined? false :joining? false
1269+ :users {}
1078 :accessed (swap! access-tick inc)})))1270 :accessed (swap! access-tick inc)})))
1079 m1271 m
1080 ordered))))1272 ordered))))