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>
83cf1c7 parent: bab5e3c modified
src/frq/app.jolt +98 -23 | @@ -148,7 +148,12 @@ | ||
| 148 | 148 | ;; `:wrap false` on the badges: they sit beside each other or not at all. |
| 149 | 149 | [:title-2 {:label name}] |
| 150 | 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 | 157 | :live (boolean (:joined? buffer))}] |
| 153 | 158 | (when (pos? unread) [:label {:label (str "● " unread)}])] |
| 154 | 159 | [:dim-label {:label (s/last-preview buffer)}] |
| @@ -167,12 +172,14 @@ | ||
| 167 | 172 | [:card {} |
| 168 | 173 | [:vbox {:spacing 8} |
| 169 | 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 | 177 | [:entry {:text @s/join-input |
| 171 | 178 | :width-request 380 |
| 172 | - :placeholder "#channel" | |
| 179 | + :placeholder "#channel or @nick" | |
| 173 | 180 | :on-change #(reset! s/join-input %) |
| 174 | 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 | 183 | :kind :primary |
| 177 | 184 | :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]] |
| 178 | 185 | ;; The clear button only shows while there is something to clear: an |
| @@ -704,9 +711,61 @@ | ||
| 704 | 711 | (when (and (empty? dirs) (empty? files)) |
| 705 | 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 | 765 | (defn chat-screen [] |
| 708 | 766 | (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 | 769 | ;; Not a :page — a page scrolls everything, which would carry the compose |
| 711 | 770 | ;; bar off the bottom with the backlog. The message list is the only thing |
| 712 | 771 | ;; that scrolls, bounded so what follows it keeps its room. |
| @@ -731,7 +790,15 @@ | ||
| 731 | 790 | (av/available?) |
| 732 | 791 | (not (av/call-in name)) |
| 733 | 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 | 802 | [error-note] |
| 736 | 803 | [call-bar name] |
| 737 | 804 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -744,22 +811,32 @@ | ||
| 744 | 811 | ;; unmounts the backlog behind it; without a name of its own the position |
| 745 | 812 | ;; would come back as a fresh one, and ← Back would answer a click on a |
| 746 | 813 | ;; 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])]] | |
| 763 | 840 | ;; Only while it is needed, and directly above the compose bar: the way |
| 764 | 841 | ;; back to the present belongs next to the thing that puts you there. |
| 765 | 842 | ;; The row keeps its height whether or not the button is in it, so the |
| @@ -816,8 +893,6 @@ | ||
| 816 | 893 | |
| 817 | 894 | ;; ---------------------------------------------------------------- split |
| 818 | 895 | |
| 819 | -(def ^:private sidebar-width 320) | |
| 820 | - | |
| 821 | 896 | (defn- no-chat-pane |
| 822 | 897 | "What fills the second pane before a conversation has been picked. The pane |
| 823 | 898 | 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-input | 177 | [:entry {:text @s/join-input |
| 171 | :width-request 380 | 178 | :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 :primary | 183 | :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: an | 185 | ;; 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/current | 766 | (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 compose | 769 | ;; 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 thing | 770 | ;; 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, the | 804 | ;; :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 position | 811 | ;; 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 a | 812 | ;; 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 :vertical | 815 | + ;; 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 the | 816 | + ;; of its own: a child that appeared and vanished would renumber the row |
| 750 | - ;; pasted picture when there is one. Those rows do move the | 817 | + ;; for the reconciler, and take the message list's scroll position with |
| 751 | - ;; compose bar, and should: each appears because the reader just | 818 | + ;; it every time the panel was toggled. |
| 752 | - ;; asked for something — unlike the jump button, which appears | 819 | + [: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 (+ 124 | 821 | + :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 true | 824 | + ;; Plus the reply banner's row when there is one, and the |
| 758 | - :scroll-to-bottom @s/jump-tick | 825 | + ;; 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 way | 840 | ;; 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 the | 842 | ;; The row keeps its height whether or not the button is in it, so the |
| @@ -816,8 +893,6 @@ | |||
| 816 | 893 | ||
| 817 | ;; ---------------------------------------------------------------- split | 894 | ;; ---------------------------------------------------------------- split |
| 818 | 895 | ||
| 819 | -(def ^:private sidebar-width 320) | ||
| 820 | - | ||
| 821 | (defn- no-chat-pane | 896 | (defn- no-chat-pane |
| 822 | "What fills the second pane before a conversation has been picked. The pane | 897 | "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 as | 898 | 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 @@ | ||
| 89 | 89 | (defonce lightbox (atom nil)) ; {:path :url} |
| 90 | 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 | 99 | ;; The window's content width in points, polled from the backend a few times a |
| 93 | 100 | ;; second. The app is laid out for a phone-width window, and this is what lets |
| 94 | 101 | ;; a wide one be more than a phone with margins: past `wide-width` the channel |
| @@ -129,7 +136,7 @@ | ||
| 129 | 136 | |
| 130 | 137 | (defn connected? [] (some? @conn)) |
| 131 | 138 | |
| 132 | -(declare channel-order) | |
| 139 | +(declare channel-order request-names!) | |
| 133 | 140 | |
| 134 | 141 | (defn- remember-channels! |
| 135 | 142 | "Write the order out. Off the caller's thread: opening a channel should not |
| @@ -138,6 +145,12 @@ | ||
| 138 | 145 | [] |
| 139 | 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 | 154 | (defn normalize-channel [s] |
| 142 | 155 | (let [s (str/trim (or s ""))] |
| 143 | 156 | (cond (str/blank? s) "" |
| @@ -148,7 +161,9 @@ | ||
| 148 | 161 | (if (contains? m name) |
| 149 | 162 | m |
| 150 | 163 | (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 | 168 | (defonce ^{:doc "Bumped whenever a fetched image becomes available, so the |
| 154 | 169 | chat view re-renders without every message row watching the media cache."} |
| @@ -216,7 +231,11 @@ | ||
| 216 | 231 | (not (:joined? buffer)) |
| 217 | 232 | (not (:joining? buffer))) |
| 218 | 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 | 240 | (defn- parse-reactions |
| 222 | 241 | "The server's tally of what is already on a message, as |
| @@ -261,6 +280,129 @@ | ||
| 261 | 280 | msgs)) |
| 262 | 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 | 406 | (declare join! join-call!) |
| 265 | 407 | |
| 266 | 408 | ;; --- calls ------------------------------------------------------------------- |
| @@ -390,13 +532,20 @@ | ||
| 390 | 532 | ;; every channel again, and saying so on top of the backlog |
| 391 | 533 | ;; already there is just a second line of noise. |
| 392 | 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 | 542 | ;; End of NAMES. A plain JOIN is replayed history before this arrives, so |
| 395 | 543 | ;; a channel that reaches here with nothing in it was restored rather |
| 396 | 544 | ;; than joined — freeq re-joins an authenticated user's channels at |
| 397 | 545 | ;; registration and leaves the backlog for the client to ask for. |
| 398 | 546 | "366" (let [ch (second params) |
| 399 | 547 | said (remove :system? (get-in @channels [ch :messages]))] |
| 548 | + (names-end! ch) | |
| 400 | 549 | (when (and ch @conn (empty? said)) |
| 401 | 550 | (irc/send-line! @conn |
| 402 | 551 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) |
| @@ -404,9 +553,33 @@ | ||
| 404 | 553 | "PART" (let [ch (first params)] |
| 405 | 554 | (if (= from @form-nick) |
| 406 | 555 | (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))) | |
| 410 | 583 | ("NOTICE" "372" "375" "376" "002" "003" "004") |
| 411 | 584 | (reset! status (or (last params) @status)) |
| 412 | 585 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, |
| @@ -439,7 +612,7 @@ | ||
| 439 | 612 | (reset! connecting? false) |
| 440 | 613 | (swap! channels |
| 441 | 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 | 617 | (reset! status "Disconnected")) |
| 445 | 618 | "*ERROR*" (do (reset! error (first params)) |
| @@ -602,18 +775,35 @@ | ||
| 602 | 775 | ;; The buffers survive, the memberships do not — leaving `joined?` set would |
| 603 | 776 | ;; have the next Open show a channel nobody is in. |
| 604 | 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 | 780 | (reset! status "Not connected") |
| 608 | 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 | 794 | (defn join! [name] |
| 611 | 795 | ;; Deliberately not clearing `error` here: joining is what follows a |
| 612 | 796 | ;; successful registration, and a SASL refusal that arrived moments earlier |
| 613 | 797 | ;; 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)))))) | |
| 617 | 807 | |
| 618 | 808 | ;; ------------------------------------------------------------------ pasting |
| 619 | 809 | |
| @@ -1022,12 +1212,13 @@ | ||
| 1022 | 1212 | vec))) |
| 1023 | 1213 | |
| 1024 | 1214 | (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." | |
| 1028 | 1219 | [] |
| 1029 | 1220 | (->> (vals @channels) |
| 1030 | - (filter #(and (str/starts-with? (:name %) "#") (pos? (:accessed % 0)))) | |
| 1221 | + (filter #(pos? (:accessed % 0))) | |
| 1031 | 1222 | (sort-by #(- (:accessed % 0))) |
| 1032 | 1223 | (mapv :name))) |
| 1033 | 1224 | |
| @@ -1047,6 +1238,7 @@ | ||
| 1047 | 1238 | acc |
| 1048 | 1239 | (assoc acc name {:name name :messages [] :unread 0 |
| 1049 | 1240 | :joined? false :joining? false |
| 1241 | + :users {} | |
| 1050 | 1242 | :accessed (swap! access-tick inc)}))) |
| 1051 | 1243 | m |
| 1052 | 1244 | 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 a | 99 | ;; 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 lets | 100 | ;; 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 channel | 101 | ;; 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 not | 142 | "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 | m | 162 | m |
| 150 | (assoc m name {:name name :messages [] :unread 0 | 163 | (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 the | 168 | (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-reactions | 240 | (defn- parse-reactions |
| 222 | "The server's tally of what is already on a message, as | 241 | "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 backlog | 532 | ;; 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, so | 542 | ;; 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 rather | 543 | ;; a channel that reaches here with nothing in it was restored rather |
| 396 | ;; than joined — freeq re-joins an authenticated user's channels at | 544 | ;; 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! @conn | 550 | (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" nil | 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))) | ||
| 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! channels | 613 | (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 would | 775 | ;; 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 a | 795 | ;; Deliberately not clearing `error` here: joining is what follows a |
| 612 | ;; successful registration, and a SASL refusal that arrived moments earlier | 796 | ;; 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 | ;; ------------------------------------------------------------------ pasting | 808 | ;; ------------------------------------------------------------------ pasting |
| 619 | 809 | ||
| @@ -1022,12 +1212,13 @@ | |||
| 1022 | vec))) | 1212 | vec))) |
| 1023 | 1213 | ||
| 1024 | (defn channel-order | 1214 | (defn channel-order |
| 1025 | - "Just the channel names, most recently opened first — what gets written to | 1215 | + "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 a | 1216 | + 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 | acc | 1238 | acc |
| 1048 | (assoc acc name {:name name :messages [] :unread 0 | 1239 | (assoc acc name {:name name :messages [] :unread 0 |
| 1049 | :joined? false :joining? false | 1240 | :joined? false :joining? false |
| 1241 | + :users {} | ||
| 1050 | :accessed (swap! access-tick inc)}))) | 1242 | :accessed (swap! access-tick inc)}))) |
| 1051 | m | 1243 | m |
| 1052 | ordered)))) | 1244 | ordered)))) |