nandi/frqpublic Fork 0
83cf1c7
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.

Show who is in the channel, and talk to one of them

A channel's membership was never read: NAMES arrived and was dropped, QUIT
and NICK did nothing at all. Each buffer now carries the list the server
gives it — nick to mode prefix — kept up by the joins, parts, kicks, mode
changes and renames after it, and cleared when the connection goes.

The panel sits beside the backlog behind a People switch in the header. It
has to be given a width the other way round from a split: the message column
is told where to stop, since a column with :fill-height and no width takes
the whole row and paints the panel off the edge of the window.

A name in it is a button, because a person is somewhere to go. Direct
messages worked on the way in and had no way in from here: opening one is
now a press on a name, or `@nick` in the same box that joins a channel, and
an opened one is remembered across runs the way a channel is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T19:11:15-07:00 Browse files
83cf1c7 parent: bab5e3c
modified src/frq/app.jolt +98 -23
@@ -148,7 +148,12 @@
148148 ;; `:wrap false` on the badges: they sit beside each other or not at all.
149149 [:title-2 {:label name}]
150150 [:hbox {:spacing 12 :wrap false}
151- [:status {:label (if (:joined? buffer) "joined" "not joined")
151+ ;; A DM has no membership to report there is nothing to be in so the
152+ ;; badge says what the buffer is instead of answering a question nobody
153+ ;; asked of it.
154+ [:status {:label (cond (s/dm? name) "direct message"
155+ (:joined? buffer) "joined"
156+ :else "not joined")
152157 :live (boolean (:joined? buffer))}]
153158 (when (pos? unread) [:label {:label (str "" unread)}])]
154159 [:dim-label {:label (s/last-preview buffer)}]
@@ -167,12 +172,14 @@
167172 [:card {}
168173 [:vbox {:spacing 8}
169174 [:hbox {:spacing 8}
175+ ;; Both kinds of conversation through one box: `#room` joins a
176+ ;; channel, `@nick` opens a message to a person.
170177 [:entry {:text @s/join-input
171178 :width-request 380
172- :placeholder "#channel"
179+ :placeholder "#channel or @nick"
173180 :on-change #(reset! s/join-input %)
174181 :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}]
175- [:button {:label "Join"
182+ [:button {:label (if (str/starts-with? @s/join-input "@") "Message" "Join")
176183 :kind :primary
177184 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]
178185 ;; The clear button only shows while there is something to clear: an
@@ -704,9 +711,61 @@
704711 (when (and (empty? dirs) (empty? files))
705712 [:dim-label {:label "No pictures here that this app can read."}])]]]]))
706713
714+(def ^:private sidebar-width 320)
715+
716+;; What the people panel asks for, and what the conversation beside it has to
717+;; be told to leave. A column with `:fill-height` and no width takes the whole
718+;; row so the panel is only ever on screen if the message list is given a
719+;; width that stops short of it.
720+(def ^:private users-width 180)
721+
722+(defn- messages-width
723+ "How wide the message list may be with the people panel beside it.
724+
725+ Measured from the window rather than from what egui has left: the row is
726+ painted left to right, and by the time the panel is placed the list has
727+ already taken everything. On a wide window the chats list is holding the
728+ first `sidebar-width` of the window; the rest is the margins and the gap
729+ between the two columns."
730+ []
731+ (let [pane (- @s/window-width (if (s/wide?) sidebar-width 0))]
732+ (max 240 (- pane users-width 8 28))))
733+
734+(defn- member-row [{:keys [nick prefix]}]
735+ ;; The mode where there is one, dim: it says how someone is listed, not who
736+ ;; they are, and the name is what the eye is scanning for. A space where
737+ ;; there is none, rather than nothing at all: the label is always in the row,
738+ ;; so every name starts at the same place and a column wrapped around a
739+ ;; label that comes and goes is a column, which sits its text against the top
740+ ;; of the row rather than on the line the name is on.
741+ ;;
742+ ;; The name is a button because a person is somewhere to go: pressing one
743+ ;; opens a conversation with them.
744+ [:hbox {:key nick :spacing 6 :wrap false}
745+ [:dim-label {:label (if (seq prefix) prefix " ")}]
746+ [:button {:label nick :on-click #(s/open-dm! nick)}]])
747+
748+(defn users-panel
749+ "Who is in the channel, beside the conversation.
750+
751+ The list is the server's — NAMES on the way in, kept up by the joins and
752+ parts after it so a channel this client has never been in has nothing to
753+ show, and says so rather than showing an empty column."
754+ [name]
755+ (let [people (s/member-list name)]
756+ [:vbox {:key :users :width-request users-width :fill-height true :spacing 8}
757+ [:title-2 {:label (str "People " (count people))}]
758+ [:scroll {:scroll-key (str "users-" name)
759+ :orientation :vertical
760+ :reserve 124}
761+ (if (seq people)
762+ (for [p people] [member-row p])
763+ [:dim-label {:label "Nobody listed yet."}])]]))
764+
707765 (defn chat-screen []
708766 (let [name @s/current
709- buffer (get @s/channels name)]
767+ buffer (get @s/channels name)
768+ show-users? (and @s/show-users? name (str/starts-with? name "#"))]
710769 ;; Not a :page a page scrolls everything, which would carry the compose
711770 ;; bar off the bottom with the backlog. The message list is the only thing
712771 ;; that scrolls, bounded so what follows it keeps its room.
@@ -731,7 +790,15 @@
731790 (av/available?)
732791 (not (av/call-in name))
733792 (not (av/in-call?)))
734- [:button {:label "Call" :on-click #(s/start-call! name)}])]]
793+ [:button {:label "Call" :on-click #(s/start-call! name)}])]
794+ ;; The people panel's switch, in a wrapper of its own for the same
795+ ;; reason: it is only offered in a channel, where there is a membership
796+ ;; to show.
797+ [:vbox {:key :people}
798+ (when (and name (str/starts-with? name "#"))
799+ [:button {:label (str "People " (s/member-count name))
800+ :kind (when @s/show-users? :primary)
801+ :on-click s/toggle-users!}])]]
735802 [error-note]
736803 [call-bar name]
737804 ;; :reserve leaves room for everything below: the jump button's row, the
@@ -744,22 +811,32 @@
744811 ;; unmounts the backlog behind it; without a name of its own the position
745812 ;; would come back as a fresh one, and Back would answer a click on a
746813 ;; message halfway up a week of history with the top of the buffer.
747- [:scroll {:scroll-key "chat-messages"
748- :orientation :vertical
749- ;; Plus the reply banner's row when there is one, and the
750- ;; pasted picture when there is one. Those rows do move the
751- ;; compose bar, and should: each appears because the reader just
752- ;; asked for something unlike the jump button, which appears
753- ;; on its own and must not shift what is under it.
754- :reserve (+ 124
755- (if @s/replying-to 34 0)
756- (if @s/attachment 76 0))
757- :stick-to-bottom true
758- :scroll-to-bottom @s/jump-tick
759- :on-change #(reset! s/at-present? (= "end" %))}
760- (if (seq (:messages buffer))
761- (message-rows (:messages buffer))
762- [:dim-label {:label "Nothing here yet."}])]
814+ ;; The backlog and, when it is asked for, who is in the room beside it.
815+ ;; The row is always there and the panel comes and goes inside a wrapper
816+ ;; of its own: a child that appeared and vanished would renumber the row
817+ ;; for the reconciler, and take the message list's scroll position with
818+ ;; it every time the panel was toggled.
819+ [:hbox {:spacing 8 :wrap false}
820+ [:vbox {:key :messages :fill-height true
821+ :width-request (if show-users? (messages-width) 0)}
822+ [:scroll {:scroll-key "chat-messages"
823+ :orientation :vertical
824+ ;; Plus the reply banner's row when there is one, and the
825+ ;; pasted picture when there is one. Those rows do move the
826+ ;; compose bar, and should: each appears because the reader just
827+ ;; asked for something unlike the jump button, which appears
828+ ;; on its own and must not shift what is under it.
829+ :reserve (+ 124
830+ (if @s/replying-to 34 0)
831+ (if @s/attachment 76 0))
832+ :stick-to-bottom true
833+ :scroll-to-bottom @s/jump-tick
834+ :on-change #(reset! s/at-present? (= "end" %))}
835+ (if (seq (:messages buffer))
836+ (message-rows (:messages buffer))
837+ [:dim-label {:label "Nothing here yet."}])]]
838+ [:vbox {:key :people-pane :fill-height true}
839+ (when show-users? [users-panel name])]]
763840 ;; Only while it is needed, and directly above the compose bar: the way
764841 ;; back to the present belongs next to the thing that puts you there.
765842 ;; The row keeps its height whether or not the button is in it, so the
@@ -816,8 +893,6 @@
816893
817894 ;; ---------------------------------------------------------------- split
818895
819-(def ^:private sidebar-width 320)
820-
821896 (defn- no-chat-pane
822897 "What fills the second pane before a conversation has been picked. The pane
823898 is there either way a list that widened into two columns and back again as
@@ -148,7 +148,12 @@
148 ;; `:wrap false` on the badges: they sit beside each other or not at all.148 ;; `:wrap false` on the badges: they sit beside each other or not at all.
149 [:title-2 {:label name}]149 [:title-2 {:label name}]
150 [:hbox {:spacing 12 :wrap false}150 [:hbox {:spacing 12 :wrap false}
151- [:status {:label (if (:joined? buffer) "joined" "not joined")151+ ;; A DM has no membership to report there is nothing to be in so the
152+ ;; badge says what the buffer is instead of answering a question nobody
153+ ;; asked of it.
154+ [:status {:label (cond (s/dm? name) "direct message"
155+ (:joined? buffer) "joined"
156+ :else "not joined")
152 :live (boolean (:joined? buffer))}]157 :live (boolean (:joined? buffer))}]
153 (when (pos? unread) [:label {:label (str "" unread)}])]158 (when (pos? unread) [:label {:label (str "" unread)}])]
154 [:dim-label {:label (s/last-preview buffer)}]159 [:dim-label {:label (s/last-preview buffer)}]
@@ -167,12 +172,14 @@
167 [:card {}172 [:card {}
168 [:vbox {:spacing 8}173 [:vbox {:spacing 8}
169 [:hbox {:spacing 8}174 [:hbox {:spacing 8}
175+ ;; Both kinds of conversation through one box: `#room` joins a
176+ ;; channel, `@nick` opens a message to a person.
170 [:entry {:text @s/join-input177 [:entry {:text @s/join-input
171 :width-request 380178 :width-request 380
172- :placeholder "#channel"179+ :placeholder "#channel or @nick"
173 :on-change #(reset! s/join-input %)180 :on-change #(reset! s/join-input %)
174 :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}]181 :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}]
175- [:button {:label "Join"182+ [:button {:label (if (str/starts-with? @s/join-input "@") "Message" "Join")
176 :kind :primary183 :kind :primary
177 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]184 :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]
178 ;; The clear button only shows while there is something to clear: an185 ;; The clear button only shows while there is something to clear: an
@@ -704,9 +711,61 @@
704 (when (and (empty? dirs) (empty? files))711 (when (and (empty? dirs) (empty? files))
705 [:dim-label {:label "No pictures here that this app can read."}])]]]]))712 [:dim-label {:label "No pictures here that this app can read."}])]]]]))
706 713
714+(def ^:private sidebar-width 320)
715+
716+;; What the people panel asks for, and what the conversation beside it has to
717+;; be told to leave. A column with `:fill-height` and no width takes the whole
718+;; row so the panel is only ever on screen if the message list is given a
719+;; width that stops short of it.
720+(def ^:private users-width 180)
721+
722+(defn- messages-width
723+ "How wide the message list may be with the people panel beside it.
724+
725+ Measured from the window rather than from what egui has left: the row is
726+ painted left to right, and by the time the panel is placed the list has
727+ already taken everything. On a wide window the chats list is holding the
728+ first `sidebar-width` of the window; the rest is the margins and the gap
729+ between the two columns."
730+ []
731+ (let [pane (- @s/window-width (if (s/wide?) sidebar-width 0))]
732+ (max 240 (- pane users-width 8 28))))
733+
734+(defn- member-row [{:keys [nick prefix]}]
735+ ;; The mode where there is one, dim: it says how someone is listed, not who
736+ ;; they are, and the name is what the eye is scanning for. A space where
737+ ;; there is none, rather than nothing at all: the label is always in the row,
738+ ;; so every name starts at the same place and a column wrapped around a
739+ ;; label that comes and goes is a column, which sits its text against the top
740+ ;; of the row rather than on the line the name is on.
741+ ;;
742+ ;; The name is a button because a person is somewhere to go: pressing one
743+ ;; opens a conversation with them.
744+ [:hbox {:key nick :spacing 6 :wrap false}
745+ [:dim-label {:label (if (seq prefix) prefix " ")}]
746+ [:button {:label nick :on-click #(s/open-dm! nick)}]])
747+
748+(defn users-panel
749+ "Who is in the channel, beside the conversation.
750+
751+ The list is the server's — NAMES on the way in, kept up by the joins and
752+ parts after it so a channel this client has never been in has nothing to
753+ show, and says so rather than showing an empty column."
754+ [name]
755+ (let [people (s/member-list name)]
756+ [:vbox {:key :users :width-request users-width :fill-height true :spacing 8}
757+ [:title-2 {:label (str "People " (count people))}]
758+ [:scroll {:scroll-key (str "users-" name)
759+ :orientation :vertical
760+ :reserve 124}
761+ (if (seq people)
762+ (for [p people] [member-row p])
763+ [:dim-label {:label "Nobody listed yet."}])]]))
764+
707 (defn chat-screen []765 (defn chat-screen []
708 (let [name @s/current766 (let [name @s/current
709- buffer (get @s/channels name)]767+ buffer (get @s/channels name)
768+ show-users? (and @s/show-users? name (str/starts-with? name "#"))]
710 ;; Not a :page a page scrolls everything, which would carry the compose769 ;; Not a :page a page scrolls everything, which would carry the compose
711 ;; bar off the bottom with the backlog. The message list is the only thing770 ;; bar off the bottom with the backlog. The message list is the only thing
712 ;; that scrolls, bounded so what follows it keeps its room.771 ;; that scrolls, bounded so what follows it keeps its room.
@@ -731,7 +790,15 @@
731 (av/available?)790 (av/available?)
732 (not (av/call-in name))791 (not (av/call-in name))
733 (not (av/in-call?)))792 (not (av/in-call?)))
734- [:button {:label "Call" :on-click #(s/start-call! name)}])]]793+ [:button {:label "Call" :on-click #(s/start-call! name)}])]
794+ ;; The people panel's switch, in a wrapper of its own for the same
795+ ;; reason: it is only offered in a channel, where there is a membership
796+ ;; to show.
797+ [:vbox {:key :people}
798+ (when (and name (str/starts-with? name "#"))
799+ [:button {:label (str "People " (s/member-count name))
800+ :kind (when @s/show-users? :primary)
801+ :on-click s/toggle-users!}])]]
735 [error-note]802 [error-note]
736 [call-bar name]803 [call-bar name]
737 ;; :reserve leaves room for everything below: the jump button's row, the804 ;; :reserve leaves room for everything below: the jump button's row, the
@@ -744,22 +811,32 @@
744 ;; unmounts the backlog behind it; without a name of its own the position811 ;; unmounts the backlog behind it; without a name of its own the position
745 ;; would come back as a fresh one, and Back would answer a click on a812 ;; would come back as a fresh one, and Back would answer a click on a
746 ;; message halfway up a week of history with the top of the buffer.813 ;; message halfway up a week of history with the top of the buffer.
747- [:scroll {:scroll-key "chat-messages"814+ ;; The backlog and, when it is asked for, who is in the room beside it.
748- :orientation :vertical815+ ;; The row is always there and the panel comes and goes inside a wrapper
749- ;; Plus the reply banner's row when there is one, and the816+ ;; of its own: a child that appeared and vanished would renumber the row
750- ;; pasted picture when there is one. Those rows do move the817+ ;; for the reconciler, and take the message list's scroll position with
751- ;; compose bar, and should: each appears because the reader just818+ ;; it every time the panel was toggled.
752- ;; asked for something unlike the jump button, which appears819+ [:hbox {:spacing 8 :wrap false}
753- ;; on its own and must not shift what is under it.820+ [:vbox {:key :messages :fill-height true
754- :reserve (+ 124821+ :width-request (if show-users? (messages-width) 0)}
755- (if @s/replying-to 34 0)822+ [:scroll {:scroll-key "chat-messages"
756- (if @s/attachment 76 0))823+ :orientation :vertical
757- :stick-to-bottom true824+ ;; Plus the reply banner's row when there is one, and the
758- :scroll-to-bottom @s/jump-tick825+ ;; pasted picture when there is one. Those rows do move the
759- :on-change #(reset! s/at-present? (= "end" %))}826+ ;; compose bar, and should: each appears because the reader just
760- (if (seq (:messages buffer))827+ ;; asked for something unlike the jump button, which appears
761- (message-rows (:messages buffer))828+ ;; on its own and must not shift what is under it.
762- [:dim-label {:label "Nothing here yet."}])]829+ :reserve (+ 124
830+ (if @s/replying-to 34 0)
831+ (if @s/attachment 76 0))
832+ :stick-to-bottom true
833+ :scroll-to-bottom @s/jump-tick
834+ :on-change #(reset! s/at-present? (= "end" %))}
835+ (if (seq (:messages buffer))
836+ (message-rows (:messages buffer))
837+ [:dim-label {:label "Nothing here yet."}])]]
838+ [:vbox {:key :people-pane :fill-height true}
839+ (when show-users? [users-panel name])]]
763 ;; Only while it is needed, and directly above the compose bar: the way840 ;; Only while it is needed, and directly above the compose bar: the way
764 ;; back to the present belongs next to the thing that puts you there.841 ;; back to the present belongs next to the thing that puts you there.
765 ;; The row keeps its height whether or not the button is in it, so the842 ;; The row keeps its height whether or not the button is in it, so the
@@ -816,8 +893,6 @@
816 893
817 ;; ---------------------------------------------------------------- split894 ;; ---------------------------------------------------------------- split
818 895
819-(def ^:private sidebar-width 320)
820-
821 (defn- no-chat-pane896 (defn- no-chat-pane
822 "What fills the second pane before a conversation has been picked. The pane897 "What fills the second pane before a conversation has been picked. The pane
823 is there either way a list that widened into two columns and back again as898 is there either way a list that widened into two columns and back again as
modified src/frq/state.jolt +208 -16
@@ -89,6 +89,13 @@
8989 (defonce lightbox (atom nil)) ; {:path :url}
9090 (defonce join-input (atom ""))
9191
92+;; Whether the chat screen is showing who is in the channel. Off by default:
93+;; the panel costs the conversation a column, and the reader is here for the
94+;; conversation.
95+(defonce show-users? (atom false))
96+
97+(defn toggle-users! [] (swap! show-users? not))
98+
9299 ;; The window's content width in points, polled from the backend a few times a
93100 ;; second. The app is laid out for a phone-width window, and this is what lets
94101 ;; a wide one be more than a phone with margins: past `wide-width` the channel
@@ -129,7 +136,7 @@
129136
130137 (defn connected? [] (some? @conn))
131138
132-(declare channel-order)
139+(declare channel-order request-names!)
133140
134141 (defn- remember-channels!
135142 "Write the order out. Off the caller's thread: opening a channel should not
@@ -138,6 +145,12 @@
138145 []
139146 (future (store/save-channels! (channel-order))))
140147
148+(defn dm?
149+ "Whether a buffer is a conversation with a person rather than a room. Every
150+ channel name starts with `#`; what does not is somebody's nick."
151+ [name]
152+ (and (seq name) (not (str/starts-with? name "#"))))
153+
141154 (defn normalize-channel [s]
142155 (let [s (str/trim (or s ""))]
143156 (cond (str/blank? s) ""
@@ -148,7 +161,9 @@
148161 (if (contains? m name)
149162 m
150163 (assoc m name {:name name :messages [] :unread 0
151- :joined? false :joining? false :accessed 0})))
164+ :joined? false :joining? false :accessed 0
165+ ;; nick -> mode prefix, for the people panel
166+ :users {}})))
152167
153168 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
154169 chat view re-renders without every message row watching the media cache."}
@@ -216,7 +231,11 @@
216231 (not (:joined? buffer))
217232 (not (:joining? buffer)))
218233 (swap! channels #(assoc-in % [name :joining?] true))
219- (irc/join! @conn name))))
234+ (irc/join! @conn name)))
235+ ;; Already in it, and nobody listed: the membership survived a restart the
236+ ;; NAMES that came with it did not.
237+ (when (:joined? (get @channels name))
238+ (request-names! name)))
220239
221240 (defn- parse-reactions
222241 "The server's tally of what is already on a message, as
@@ -261,6 +280,129 @@
261280 msgs))
262281 m)))))
263282
283+;; --- who is in the room ------------------------------------------------------
284+;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is
285+;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK
286+;; after it. Nothing here asks who is there — being told is what membership is.
287+
288+(def ^:private mode-prefixes
289+ "The characters a server puts in front of a nick in NAMES, and in the same
290+ order the panel sorts them: owner, admin, op, half-op, voice."
291+ "~&@%+")
292+
293+(defn- split-prefix
294+ "One NAMES entry into `[prefix nick]`. A nick never starts with one of these,
295+ so what is in front of it is a mode and not part of the name."
296+ [entry]
297+ (if (and (seq entry) (str/index-of mode-prefixes (subs entry 0 1)))
298+ [(subs entry 0 1) (subs entry 1)]
299+ ["" entry]))
300+
301+(defn- names-line
302+ "Fold one 353 into the channel's pending list. Pending rather than live: the
303+ reply comes in as many lines as it takes and ends with 366, and replacing
304+ `:users` on each of them would empty the panel and refill it a name at a
305+ time."
306+ [channel names]
307+ (swap! channels
308+ (fn [m]
309+ (reduce (fn [m entry]
310+ (let [[prefix nick] (split-prefix entry)]
311+ (assoc-in m [channel :names-acc nick] prefix)))
312+ (ensure-channel m channel)
313+ (remove str/blank? (str/split (or names "") #" "))))))
314+
315+(defn- names-end!
316+ "366: the pending list becomes the list."
317+ [channel]
318+ (swap! channels
319+ (fn [m]
320+ (if-let [acc (get-in m [channel :names-acc])]
321+ (-> m (assoc-in [channel :users] acc)
322+ (update channel dissoc :names-acc))
323+ m))))
324+
325+(defn- add-user! [channel nick]
326+ (when (and channel nick)
327+ (swap! channels #(-> (ensure-channel % channel)
328+ (update-in [channel :users] (fnil assoc {}) nick "")))))
329+
330+(defn- remove-user! [channel nick]
331+ (when (and channel nick)
332+ (swap! channels #(update-in % [channel :users] dissoc nick))))
333+
334+(defn- remove-user-everywhere!
335+ "A QUIT names no channel — the person left the server, so they left every
336+ room this client is watching them in."
337+ [nick]
338+ (swap! channels
339+ (fn [m]
340+ (reduce-kv (fn [m k v] (assoc m k (update v :users dissoc nick)))
341+ {} m))))
342+
343+(defn- rename-user!
344+ "A NICK, in every channel the old name was in. Their modes come with them:
345+ renaming is not leaving."
346+ [old new]
347+ (swap! channels
348+ (fn [m]
349+ (reduce-kv (fn [m k v]
350+ (assoc m k
351+ (if-let [prefix (get (:users v) old)]
352+ (update v :users #(-> % (dissoc old) (assoc new prefix)))
353+ v)))
354+ {} m))))
355+
356+(defn- apply-mode!
357+ "A channel MODE, for the letters that change how someone is listed. `params`
358+ is the mode string and whoever it was applied to, in order; anything else in
359+ it — a key, a limit, a ban — names no member and is skipped."
360+ [channel modes args]
361+ (let [letters {\q "~" \a "&" \o "@" \h "%" \v "+"}]
362+ (loop [chars (seq modes) args args adding? true]
363+ (when-let [c (first chars)]
364+ (case c
365+ \+ (recur (rest chars) args true)
366+ \- (recur (rest chars) args false)
367+ (if-let [prefix (letters c)]
368+ (do (when-let [nick (first args)]
369+ (swap! channels
370+ (fn [m]
371+ (if (get-in m [channel :users nick])
372+ (assoc-in m [channel :users nick] (if adding? prefix ""))
373+ m))))
374+ (recur (rest chars) (rest args) adding?))
375+ ;; A mode that takes an argument without naming a member still eats
376+ ;; one, and reading the next letter's nick out of the wrong place
377+ ;; would put a mode on a stranger. Only the setting form takes one.
378+ (recur (rest chars) (if adding? (rest args) args) adding?)))))))
379+
380+(def ^:private prefix-rank
381+ (into {"" (count mode-prefixes)}
382+ (map-indexed (fn [i c] [(str c) i]) mode-prefixes)))
383+
384+(defn member-list
385+ "Who is in `channel`, as `{:nick :prefix}`, ops first and then alphabetically
386+ — the order every other client lists them in, and the one a reader scanning
387+ for a name expects."
388+ [channel]
389+ (->> (get-in @channels [channel :users])
390+ (map (fn [[nick prefix]] {:nick nick :prefix prefix}))
391+ (sort-by (juxt #(prefix-rank (:prefix %) 99) #(str/lower-case (:nick %))))
392+ vec))
393+
394+(defn member-count [channel]
395+ (count (get-in @channels [channel :users])))
396+
397+(defn request-names!
398+ "Ask who is in a channel we are already in. freeq re-joins an authenticated
399+ user's channels at registration, which happens without a JOIN reaching this
400+ client — and so without the NAMES that follows one."
401+ [channel]
402+ (when (and @conn channel (str/starts-with? channel "#")
403+ (empty? (get-in @channels [channel :users])))
404+ (irc/send-line! @conn (str "NAMES " channel))))
405+
264406 (declare join! join-call!)
265407
266408 ;; --- calls -------------------------------------------------------------------
@@ -390,13 +532,20 @@
390532 ;; every channel again, and saying so on top of the backlog
391533 ;; already there is just a second line of noise.
392534 (when fresh? (push-message! ch "*" (str "Joined " ch))))
393- (push-message! ch "*" (str from " joined"))))
535+ (do (add-user! ch from)
536+ (push-message! ch "*" (str from " joined")))))
537+ ;; NAMES, a line at a time. The channel is the parameter that names one:
538+ ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that
539+ ;; leaves the symbol out shifts everything before the list along by one.
540+ "353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))]
541+ (when ch (names-line ch (last params))))
394542 ;; End of NAMES. A plain JOIN is replayed history before this arrives, so
395543 ;; a channel that reaches here with nothing in it was restored rather
396544 ;; than joined freeq re-joins an authenticated user's channels at
397545 ;; registration and leaves the backlog for the client to ask for.
398546 "366" (let [ch (second params)
399547 said (remove :system? (get-in @channels [ch :messages]))]
548+ (names-end! ch)
400549 (when (and ch @conn (empty? said))
401550 (irc/send-line! @conn
402551 (str "CHATHISTORY LATEST " ch " * " history-limit))))
@@ -404,9 +553,33 @@
404553 "PART" (let [ch (first params)]
405554 (if (= from @form-nick)
406555 (swap! channels #(-> % (assoc-in [ch :joined?] false)
407- (assoc-in [ch :joining?] false)))
408- (push-message! ch "*" (str from " left"))))
409- "QUIT" nil
556+ (assoc-in [ch :joining?] false)
557+ (assoc-in [ch :users] {})))
558+ (do (remove-user! ch from)
559+ (push-message! ch "*" (str from " left")))))
560+ "KICK" (let [[ch who] params]
561+ (if (= who @form-nick)
562+ (swap! channels #(-> % (assoc-in [ch :joined?] false)
563+ (assoc-in [ch :joining?] false)
564+ (assoc-in [ch :users] {})))
565+ (remove-user! ch who))
566+ (push-message! ch "*" (str who " was kicked by " from)))
567+ ;; A QUIT and a NICK name no channel, so both are folded into every
568+ ;; buffer the person was listed in — and said out loud only where they
569+ ;; were, which is what keeps a stranger's rename out of a quiet room.
570+ "QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
571+ (doseq [ch rooms]
572+ (push-message! ch "*" (str from " quit")))
573+ (remove-user-everywhere! from))
574+ "NICK" (let [new-nick (last params)
575+ rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
576+ (when (= from @form-nick) (reset! form-nick new-nick))
577+ (doseq [ch rooms]
578+ (push-message! ch "*" (str from " is now " new-nick)))
579+ (rename-user! from new-nick))
580+ "MODE" (let [[target modes & args] params]
581+ (when (str/starts-with? (or target "") "#")
582+ (apply-mode! target modes args)))
410583 ("NOTICE" "372" "375" "376" "002" "003" "004")
411584 (reset! status (or (last params) @status))
412585 ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
@@ -439,7 +612,7 @@
439612 (reset! connecting? false)
440613 (swap! channels
441614 #(reduce-kv (fn [m k v]
442- (assoc m k (assoc v :joined? false :joining? false)))
615+ (assoc m k (assoc v :joined? false :joining? false :users {})))
443616 {} %))
444617 (reset! status "Disconnected"))
445618 "*ERROR*" (do (reset! error (first params))
@@ -602,18 +775,35 @@
602775 ;; The buffers survive, the memberships do not leaving `joined?` set would
603776 ;; have the next Open show a channel nobody is in.
604777 (swap! channels #(reduce-kv (fn [m k v]
605- (assoc m k (assoc v :joined? false :joining? false)))
778+ (assoc m k (assoc v :joined? false :joining? false :users {})))
606779 {} %))
607780 (reset! status "Not connected")
608781 (reset! screen :connect))
609782
783+(defn open-dm!
784+ "Open a conversation with one person. There is nothing to join — a DM buffer
785+ is a place to type at somebody, and it exists as soon as it is asked for.
786+
787+ Our own nick is not one of them: a buffer talking to yourself would take the
788+ place in the list of one that could answer."
789+ [nick]
790+ (let [nick (str/trim (or nick ""))]
791+ (when (and (seq nick) (not= nick @form-nick))
792+ (open-channel! nick))))
793+
610794 (defn join! [name]
611795 ;; Deliberately not clearing `error` here: joining is what follows a
612796 ;; successful registration, and a SASL refusal that arrived moments earlier
613797 ;; is the one thing the user most needs to still be on screen.
614- (let [ch (normalize-channel name)]
615- (when (seq ch)
616- (open-channel! ch))))
798+ ;;
799+ ;; `@nick` opens a DM instead. One box for both: what the reader wants is to
800+ ;; be somewhere, and the sigil says where the same way it does on the wire.
801+ (let [name (str/trim (or name ""))]
802+ (if (str/starts-with? name "@")
803+ (open-dm! (subs name 1))
804+ (let [ch (normalize-channel name)]
805+ (when (seq ch)
806+ (open-channel! ch))))))
617807
618808 ;; ------------------------------------------------------------------ pasting
619809
@@ -1022,12 +1212,13 @@
10221212 vec)))
10231213
10241214 (defn channel-order
1025- "Just the channel names, most recently opened first — what gets written to
1026- disk. Buffers never opened are left out: a DM that arrived once is not a
1027- place this client has been."
1215+ "The buffer names, most recently opened first — what gets written to disk.
1216+ Buffers never opened are left out: a DM that arrived once and was never read
1217+ is not a place this client has been, and neither is a channel someone
1218+ mentioned. One that was opened is, whether it is a room or a person."
10281219 []
10291220 (->> (vals @channels)
1030- (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0))))
1221+ (filter #(pos? (:accessed % 0)))
10311222 (sort-by #(- (:accessed % 0)))
10321223 (mapv :name)))
10331224
@@ -1047,6 +1238,7 @@
10471238 acc
10481239 (assoc acc name {:name name :messages [] :unread 0
10491240 :joined? false :joining? false
1241+ :users {}
10501242 :accessed (swap! access-tick inc)})))
10511243 m
10521244 ordered))))
@@ -89,6 +89,13 @@
89 (defonce lightbox (atom nil)) ; {:path :url}89 (defonce lightbox (atom nil)) ; {:path :url}
90 (defonce join-input (atom ""))90 (defonce join-input (atom ""))
91 91
92+;; Whether the chat screen is showing who is in the channel. Off by default:
93+;; the panel costs the conversation a column, and the reader is here for the
94+;; conversation.
95+(defonce show-users? (atom false))
96+
97+(defn toggle-users! [] (swap! show-users? not))
98+
92 ;; The window's content width in points, polled from the backend a few times a99 ;; The window's content width in points, polled from the backend a few times a
93 ;; second. The app is laid out for a phone-width window, and this is what lets100 ;; second. The app is laid out for a phone-width window, and this is what lets
94 ;; a wide one be more than a phone with margins: past `wide-width` the channel101 ;; a wide one be more than a phone with margins: past `wide-width` the channel
@@ -129,7 +136,7 @@
129 136
130 (defn connected? [] (some? @conn))137 (defn connected? [] (some? @conn))
131 138
132-(declare channel-order)139+(declare channel-order request-names!)
133 140
134 (defn- remember-channels!141 (defn- remember-channels!
135 "Write the order out. Off the caller's thread: opening a channel should not142 "Write the order out. Off the caller's thread: opening a channel should not
@@ -138,6 +145,12 @@
138 []145 []
139 (future (store/save-channels! (channel-order))))146 (future (store/save-channels! (channel-order))))
140 147
148+(defn dm?
149+ "Whether a buffer is a conversation with a person rather than a room. Every
150+ channel name starts with `#`; what does not is somebody's nick."
151+ [name]
152+ (and (seq name) (not (str/starts-with? name "#"))))
153+
141 (defn normalize-channel [s]154 (defn normalize-channel [s]
142 (let [s (str/trim (or s ""))]155 (let [s (str/trim (or s ""))]
143 (cond (str/blank? s) ""156 (cond (str/blank? s) ""
@@ -148,7 +161,9 @@
148 (if (contains? m name)161 (if (contains? m name)
149 m162 m
150 (assoc m name {:name name :messages [] :unread 0163 (assoc m name {:name name :messages [] :unread 0
151- :joined? false :joining? false :accessed 0})))164+ :joined? false :joining? false :accessed 0
165+ ;; nick -> mode prefix, for the people panel
166+ :users {}})))
152 167
153 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the168 (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
154 chat view re-renders without every message row watching the media cache."}169 chat view re-renders without every message row watching the media cache."}
@@ -216,7 +231,11 @@
216 (not (:joined? buffer))231 (not (:joined? buffer))
217 (not (:joining? buffer)))232 (not (:joining? buffer)))
218 (swap! channels #(assoc-in % [name :joining?] true))233 (swap! channels #(assoc-in % [name :joining?] true))
219- (irc/join! @conn name))))234+ (irc/join! @conn name)))
235+ ;; Already in it, and nobody listed: the membership survived a restart the
236+ ;; NAMES that came with it did not.
237+ (when (:joined? (get @channels name))
238+ (request-names! name)))
220 239
221 (defn- parse-reactions240 (defn- parse-reactions
222 "The server's tally of what is already on a message, as241 "The server's tally of what is already on a message, as
@@ -261,6 +280,129 @@
261 msgs))280 msgs))
262 m)))))281 m)))))
263 282
283+;; --- who is in the room ------------------------------------------------------
284+;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is
285+;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK
286+;; after it. Nothing here asks who is there — being told is what membership is.
287+
288+(def ^:private mode-prefixes
289+ "The characters a server puts in front of a nick in NAMES, and in the same
290+ order the panel sorts them: owner, admin, op, half-op, voice."
291+ "~&@%+")
292+
293+(defn- split-prefix
294+ "One NAMES entry into `[prefix nick]`. A nick never starts with one of these,
295+ so what is in front of it is a mode and not part of the name."
296+ [entry]
297+ (if (and (seq entry) (str/index-of mode-prefixes (subs entry 0 1)))
298+ [(subs entry 0 1) (subs entry 1)]
299+ ["" entry]))
300+
301+(defn- names-line
302+ "Fold one 353 into the channel's pending list. Pending rather than live: the
303+ reply comes in as many lines as it takes and ends with 366, and replacing
304+ `:users` on each of them would empty the panel and refill it a name at a
305+ time."
306+ [channel names]
307+ (swap! channels
308+ (fn [m]
309+ (reduce (fn [m entry]
310+ (let [[prefix nick] (split-prefix entry)]
311+ (assoc-in m [channel :names-acc nick] prefix)))
312+ (ensure-channel m channel)
313+ (remove str/blank? (str/split (or names "") #" "))))))
314+
315+(defn- names-end!
316+ "366: the pending list becomes the list."
317+ [channel]
318+ (swap! channels
319+ (fn [m]
320+ (if-let [acc (get-in m [channel :names-acc])]
321+ (-> m (assoc-in [channel :users] acc)
322+ (update channel dissoc :names-acc))
323+ m))))
324+
325+(defn- add-user! [channel nick]
326+ (when (and channel nick)
327+ (swap! channels #(-> (ensure-channel % channel)
328+ (update-in [channel :users] (fnil assoc {}) nick "")))))
329+
330+(defn- remove-user! [channel nick]
331+ (when (and channel nick)
332+ (swap! channels #(update-in % [channel :users] dissoc nick))))
333+
334+(defn- remove-user-everywhere!
335+ "A QUIT names no channel — the person left the server, so they left every
336+ room this client is watching them in."
337+ [nick]
338+ (swap! channels
339+ (fn [m]
340+ (reduce-kv (fn [m k v] (assoc m k (update v :users dissoc nick)))
341+ {} m))))
342+
343+(defn- rename-user!
344+ "A NICK, in every channel the old name was in. Their modes come with them:
345+ renaming is not leaving."
346+ [old new]
347+ (swap! channels
348+ (fn [m]
349+ (reduce-kv (fn [m k v]
350+ (assoc m k
351+ (if-let [prefix (get (:users v) old)]
352+ (update v :users #(-> % (dissoc old) (assoc new prefix)))
353+ v)))
354+ {} m))))
355+
356+(defn- apply-mode!
357+ "A channel MODE, for the letters that change how someone is listed. `params`
358+ is the mode string and whoever it was applied to, in order; anything else in
359+ it — a key, a limit, a ban — names no member and is skipped."
360+ [channel modes args]
361+ (let [letters {\q "~" \a "&" \o "@" \h "%" \v "+"}]
362+ (loop [chars (seq modes) args args adding? true]
363+ (when-let [c (first chars)]
364+ (case c
365+ \+ (recur (rest chars) args true)
366+ \- (recur (rest chars) args false)
367+ (if-let [prefix (letters c)]
368+ (do (when-let [nick (first args)]
369+ (swap! channels
370+ (fn [m]
371+ (if (get-in m [channel :users nick])
372+ (assoc-in m [channel :users nick] (if adding? prefix ""))
373+ m))))
374+ (recur (rest chars) (rest args) adding?))
375+ ;; A mode that takes an argument without naming a member still eats
376+ ;; one, and reading the next letter's nick out of the wrong place
377+ ;; would put a mode on a stranger. Only the setting form takes one.
378+ (recur (rest chars) (if adding? (rest args) args) adding?)))))))
379+
380+(def ^:private prefix-rank
381+ (into {"" (count mode-prefixes)}
382+ (map-indexed (fn [i c] [(str c) i]) mode-prefixes)))
383+
384+(defn member-list
385+ "Who is in `channel`, as `{:nick :prefix}`, ops first and then alphabetically
386+ — the order every other client lists them in, and the one a reader scanning
387+ for a name expects."
388+ [channel]
389+ (->> (get-in @channels [channel :users])
390+ (map (fn [[nick prefix]] {:nick nick :prefix prefix}))
391+ (sort-by (juxt #(prefix-rank (:prefix %) 99) #(str/lower-case (:nick %))))
392+ vec))
393+
394+(defn member-count [channel]
395+ (count (get-in @channels [channel :users])))
396+
397+(defn request-names!
398+ "Ask who is in a channel we are already in. freeq re-joins an authenticated
399+ user's channels at registration, which happens without a JOIN reaching this
400+ client — and so without the NAMES that follows one."
401+ [channel]
402+ (when (and @conn channel (str/starts-with? channel "#")
403+ (empty? (get-in @channels [channel :users])))
404+ (irc/send-line! @conn (str "NAMES " channel))))
405+
264 (declare join! join-call!)406 (declare join! join-call!)
265 407
266 ;; --- calls -------------------------------------------------------------------408 ;; --- calls -------------------------------------------------------------------
@@ -390,13 +532,20 @@
390 ;; every channel again, and saying so on top of the backlog532 ;; every channel again, and saying so on top of the backlog
391 ;; already there is just a second line of noise.533 ;; already there is just a second line of noise.
392 (when fresh? (push-message! ch "*" (str "Joined " ch))))534 (when fresh? (push-message! ch "*" (str "Joined " ch))))
393- (push-message! ch "*" (str from " joined"))))535+ (do (add-user! ch from)
536+ (push-message! ch "*" (str from " joined")))))
537+ ;; NAMES, a line at a time. The channel is the parameter that names one:
538+ ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that
539+ ;; leaves the symbol out shifts everything before the list along by one.
540+ "353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))]
541+ (when ch (names-line ch (last params))))
394 ;; End of NAMES. A plain JOIN is replayed history before this arrives, so542 ;; End of NAMES. A plain JOIN is replayed history before this arrives, so
395 ;; a channel that reaches here with nothing in it was restored rather543 ;; a channel that reaches here with nothing in it was restored rather
396 ;; than joined freeq re-joins an authenticated user's channels at544 ;; than joined freeq re-joins an authenticated user's channels at
397 ;; registration and leaves the backlog for the client to ask for.545 ;; registration and leaves the backlog for the client to ask for.
398 "366" (let [ch (second params)546 "366" (let [ch (second params)
399 said (remove :system? (get-in @channels [ch :messages]))]547 said (remove :system? (get-in @channels [ch :messages]))]
548+ (names-end! ch)
400 (when (and ch @conn (empty? said))549 (when (and ch @conn (empty? said))
401 (irc/send-line! @conn550 (irc/send-line! @conn
402 (str "CHATHISTORY LATEST " ch " * " history-limit))))551 (str "CHATHISTORY LATEST " ch " * " history-limit))))
@@ -404,9 +553,33 @@
404 "PART" (let [ch (first params)]553 "PART" (let [ch (first params)]
405 (if (= from @form-nick)554 (if (= from @form-nick)
406 (swap! channels #(-> % (assoc-in [ch :joined?] false)555 (swap! channels #(-> % (assoc-in [ch :joined?] false)
407- (assoc-in [ch :joining?] false)))556+ (assoc-in [ch :joining?] false)
408- (push-message! ch "*" (str from " left"))))557+ (assoc-in [ch :users] {})))
409- "QUIT" nil558+ (do (remove-user! ch from)
559+ (push-message! ch "*" (str from " left")))))
560+ "KICK" (let [[ch who] params]
561+ (if (= who @form-nick)
562+ (swap! channels #(-> % (assoc-in [ch :joined?] false)
563+ (assoc-in [ch :joining?] false)
564+ (assoc-in [ch :users] {})))
565+ (remove-user! ch who))
566+ (push-message! ch "*" (str who " was kicked by " from)))
567+ ;; A QUIT and a NICK name no channel, so both are folded into every
568+ ;; buffer the person was listed in — and said out loud only where they
569+ ;; were, which is what keeps a stranger's rename out of a quiet room.
570+ "QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
571+ (doseq [ch rooms]
572+ (push-message! ch "*" (str from " quit")))
573+ (remove-user-everywhere! from))
574+ "NICK" (let [new-nick (last params)
575+ rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
576+ (when (= from @form-nick) (reset! form-nick new-nick))
577+ (doseq [ch rooms]
578+ (push-message! ch "*" (str from " is now " new-nick)))
579+ (rename-user! from new-nick))
580+ "MODE" (let [[target modes & args] params]
581+ (when (str/starts-with? (or target "") "#")
582+ (apply-mode! target modes args)))
410 ("NOTICE" "372" "375" "376" "002" "003" "004")583 ("NOTICE" "372" "375" "376" "002" "003" "004")
411 (reset! status (or (last params) @status))584 (reset! status (or (last params) @status))
412 ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,585 ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
@@ -439,7 +612,7 @@
439 (reset! connecting? false)612 (reset! connecting? false)
440 (swap! channels613 (swap! channels
441 #(reduce-kv (fn [m k v]614 #(reduce-kv (fn [m k v]
442- (assoc m k (assoc v :joined? false :joining? false)))615+ (assoc m k (assoc v :joined? false :joining? false :users {})))
443 {} %))616 {} %))
444 (reset! status "Disconnected"))617 (reset! status "Disconnected"))
445 "*ERROR*" (do (reset! error (first params))618 "*ERROR*" (do (reset! error (first params))
@@ -602,18 +775,35 @@
602 ;; The buffers survive, the memberships do not leaving `joined?` set would775 ;; The buffers survive, the memberships do not leaving `joined?` set would
603 ;; have the next Open show a channel nobody is in.776 ;; have the next Open show a channel nobody is in.
604 (swap! channels #(reduce-kv (fn [m k v]777 (swap! channels #(reduce-kv (fn [m k v]
605- (assoc m k (assoc v :joined? false :joining? false)))778+ (assoc m k (assoc v :joined? false :joining? false :users {})))
606 {} %))779 {} %))
607 (reset! status "Not connected")780 (reset! status "Not connected")
608 (reset! screen :connect))781 (reset! screen :connect))
609 782
783+(defn open-dm!
784+ "Open a conversation with one person. There is nothing to join — a DM buffer
785+ is a place to type at somebody, and it exists as soon as it is asked for.
786+
787+ Our own nick is not one of them: a buffer talking to yourself would take the
788+ place in the list of one that could answer."
789+ [nick]
790+ (let [nick (str/trim (or nick ""))]
791+ (when (and (seq nick) (not= nick @form-nick))
792+ (open-channel! nick))))
793+
610 (defn join! [name]794 (defn join! [name]
611 ;; Deliberately not clearing `error` here: joining is what follows a795 ;; Deliberately not clearing `error` here: joining is what follows a
612 ;; successful registration, and a SASL refusal that arrived moments earlier796 ;; successful registration, and a SASL refusal that arrived moments earlier
613 ;; is the one thing the user most needs to still be on screen.797 ;; is the one thing the user most needs to still be on screen.
614- (let [ch (normalize-channel name)]798+ ;;
615- (when (seq ch)799+ ;; `@nick` opens a DM instead. One box for both: what the reader wants is to
616- (open-channel! ch))))800+ ;; be somewhere, and the sigil says where the same way it does on the wire.
801+ (let [name (str/trim (or name ""))]
802+ (if (str/starts-with? name "@")
803+ (open-dm! (subs name 1))
804+ (let [ch (normalize-channel name)]
805+ (when (seq ch)
806+ (open-channel! ch))))))
617 807
618 ;; ------------------------------------------------------------------ pasting808 ;; ------------------------------------------------------------------ pasting
619 809
@@ -1022,12 +1212,13 @@
1022 vec)))1212 vec)))
1023 1213
1024 (defn channel-order1214 (defn channel-order
1025- "Just the channel names, most recently opened first — what gets written to1215+ "The buffer names, most recently opened first — what gets written to disk.
1026- disk. Buffers never opened are left out: a DM that arrived once is not a1216+ Buffers never opened are left out: a DM that arrived once and was never read
1027- place this client has been."1217+ is not a place this client has been, and neither is a channel someone
1218+ mentioned. One that was opened is, whether it is a room or a person."
1028 []1219 []
1029 (->> (vals @channels)1220 (->> (vals @channels)
1030- (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0))))1221+ (filter #(pos? (:accessed % 0)))
1031 (sort-by #(- (:accessed % 0)))1222 (sort-by #(- (:accessed % 0)))
1032 (mapv :name)))1223 (mapv :name)))
1033 1224
@@ -1047,6 +1238,7 @@
1047 acc1238 acc
1048 (assoc acc name {:name name :messages [] :unread 01239 (assoc acc name {:name name :messages [] :unread 0
1049 :joined? false :joining? false1240 :joined? false :joining? false
1241+ :users {}
1050 :accessed (swap! access-tick inc)})))1242 :accessed (swap! access-tick inc)})))
1051 m1243 m
1052 ordered))))1244 ordered))))