Do not let a room we refused build itself back out of the echo
Refusing a membership the file does not claim sends PART, and the server echoes it back. `assoc-in` on a channel that is not in the map does not fail — it invents one, a buffer holding a `:joined?` and nothing else, no name and no unread — so the echo of leaving put the room straight back in the list, where drawing it threw on `(pos? nil)` and took the client down a frame later. Four ways in, and the fourth is why guarding the first three was not enough: `push-message!` builds the buffer it is handed through `ensure-channel`, so even "eve left" re-creates the room it is about. PART and KICK for our own nick are now conditional on holding the room, `remove-user!` is too, and the join/part/kick lines about other people are only pushed into rooms we have. A room we are in still reports its comings and goings; a room we are not is silence. `conversation-row` also defaults the count it reads. Not the fix — everything that makes a buffer goes through `ensure-channel` — but the list draws whatever is in the atom, and a half-made room arriving there should be a row that reads as quiet rather than the frame that killed the client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2ccf442 parent: 88741e9 modified
src/frq/app.jolt +5 -1 | @@ -179,7 +179,11 @@ | ||
| 179 | 179 | |
| 180 | 180 | (defn conversation-row [buffer] |
| 181 | 181 | (let [name (:name buffer) |
| 182 | - unread (:unread buffer)] | |
| 182 | + ;; Defaulted rather than assumed. Everything that builds a buffer | |
| 183 | + ;; goes through `ensure-channel`, but the list draws whatever is in | |
| 184 | + ;; the atom, and a half-made room reaching here should be a row that | |
| 185 | + ;; reads as quiet rather than the frame that killed the client. | |
| 186 | + unread (:unread buffer 0)] | |
| 183 | 187 | [:card {:key name} |
| 184 | 188 | ;; The name gets the line, and membership and unread count the one under |
| 185 | 189 | ;; it. All three on one line is what the list pane has no room for: the |
| @@ -179,7 +179,11 @@ | |||
| 179 | 179 | ||
| 180 | (defn conversation-row [buffer] | 180 | (defn conversation-row [buffer] |
| 181 | (let [name (:name buffer) | 181 | (let [name (:name buffer) |
| 182 | - unread (:unread buffer)] | 182 | + ;; Defaulted rather than assumed. Everything that builds a buffer |
| 183 | + ;; goes through `ensure-channel`, but the list draws whatever is in | ||
| 184 | + ;; the atom, and a half-made room reaching here should be a row that | ||
| 185 | + ;; reads as quiet rather than the frame that killed the client. | ||
| 186 | + unread (:unread buffer 0)] | ||
| 183 | [:card {:key name} | 187 | [:card {:key name} |
| 184 | ;; The name gets the line, and membership and unread count the one under | 188 | ;; The name gets the line, and membership and unread count the one under |
| 185 | ;; it. All three on one line is what the list pane has no room for: the | 189 | ;; it. All three on one line is what the list pane has no room for: the |
modified
src/frq/state.jolt +33 -14 | @@ -667,7 +667,9 @@ | ||
| 667 | 667 | |
| 668 | 668 | (defn- remove-user! [channel nick] |
| 669 | 669 | (when (and channel nick) |
| 670 | - (swap! channels #(update-in % [channel :users] dissoc nick)))) | |
| 670 | + (swap! channels #(if (contains? % channel) | |
| 671 | + (update-in % [channel :users] dissoc nick) | |
| 672 | + %)))) | |
| 671 | 673 | |
| 672 | 674 | (defn- remove-user-everywhere! |
| 673 | 675 | "A QUIT names no channel — the person left the server, so they left every |
| @@ -926,9 +928,14 @@ | ||
| 926 | 928 | ;; Adopted or asked for, it is ours now and the file should |
| 927 | 929 | ;; say so before the next connect judges it. |
| 928 | 930 | (remember-rooms! true))) |
| 929 | - (do (add-user! ch from) | |
| 930 | - (when-not @hide-join-part? | |
| 931 | - (push-message! ch "*" (str from " joined")))))) | |
| 931 | + ;; Somebody else arriving in a room we do not hold is not a | |
| 932 | + ;; reason to start holding it: `add-user!` and `push-message!` | |
| 933 | + ;; both build the buffer they are given, so either one would | |
| 934 | + ;; put the refused room back in the list. | |
| 935 | + (when (contains? @channels ch) | |
| 936 | + (add-user! ch from) | |
| 937 | + (when-not @hide-join-part? | |
| 938 | + (push-message! ch "*" (str from " joined")))))) | |
| 932 | 939 | ;; NAMES, a line at a time. The channel is the parameter that names one: |
| 933 | 940 | ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that |
| 934 | 941 | ;; leaves the symbol out shifts everything before the list along by one. |
| @@ -945,21 +952,33 @@ | ||
| 945 | 952 | (irc/send-line! @conn |
| 946 | 953 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) |
| 947 | 954 | |
| 955 | + ;; `assoc-in` on a channel that is not there does not fail, it invents | |
| 956 | + ;; one — a buffer with a `:joined?` and nothing else, no name and no | |
| 957 | + ;; unread, which the room list then tries to draw. That is not | |
| 958 | + ;; hypothetical now: refusing a room the file does not claim sends PART, | |
| 959 | + ;; and the server echoes it straight back at us. A membership changing in | |
| 960 | + ;; a room we do not hold is nothing to record. | |
| 948 | 961 | "PART" (let [ch (first params)] |
| 949 | 962 | (if (= from @form-nick) |
| 950 | - (swap! channels #(-> % (assoc-in [ch :joined?] false) | |
| 951 | - (assoc-in [ch :joining?] false) | |
| 952 | - (assoc-in [ch :users] {}))) | |
| 953 | - (do (remove-user! ch from) | |
| 954 | - (when-not @hide-join-part? | |
| 955 | - (push-message! ch "*" (str from " left")))))) | |
| 963 | + (swap! channels #(if (contains? % ch) | |
| 964 | + (-> % (assoc-in [ch :joined?] false) | |
| 965 | + (assoc-in [ch :joining?] false) | |
| 966 | + (assoc-in [ch :users] {})) | |
| 967 | + %)) | |
| 968 | + (when (contains? @channels ch) | |
| 969 | + (remove-user! ch from) | |
| 970 | + (when-not @hide-join-part? | |
| 971 | + (push-message! ch "*" (str from " left")))))) | |
| 956 | 972 | "KICK" (let [[ch who] params] |
| 957 | 973 | (if (= who @form-nick) |
| 958 | - (swap! channels #(-> % (assoc-in [ch :joined?] false) | |
| 959 | - (assoc-in [ch :joining?] false) | |
| 960 | - (assoc-in [ch :users] {}))) | |
| 974 | + (swap! channels #(if (contains? % ch) | |
| 975 | + (-> % (assoc-in [ch :joined?] false) | |
| 976 | + (assoc-in [ch :joining?] false) | |
| 977 | + (assoc-in [ch :users] {})) | |
| 978 | + %)) | |
| 961 | 979 | (remove-user! ch who)) |
| 962 | - (push-message! ch "*" (str who " was kicked by " from))) | |
| 980 | + (when (contains? @channels ch) | |
| 981 | + (push-message! ch "*" (str who " was kicked by " from)))) | |
| 963 | 982 | ;; A QUIT and a NICK name no channel, so both are folded into every |
| 964 | 983 | ;; buffer the person was listed in — and said out loud only where they |
| 965 | 984 | ;; were, which is what keeps a stranger's rename out of a quiet room. |
| @@ -667,7 +667,9 @@ | |||
| 667 | 667 | ||
| 668 | (defn- remove-user! [channel nick] | 668 | (defn- remove-user! [channel nick] |
| 669 | (when (and channel nick) | 669 | (when (and channel nick) |
| 670 | - (swap! channels #(update-in % [channel :users] dissoc nick)))) | 670 | + (swap! channels #(if (contains? % channel) |
| 671 | + (update-in % [channel :users] dissoc nick) | ||
| 672 | + %)))) | ||
| 671 | 673 | ||
| 672 | (defn- remove-user-everywhere! | 674 | (defn- remove-user-everywhere! |
| 673 | "A QUIT names no channel — the person left the server, so they left every | 675 | "A QUIT names no channel — the person left the server, so they left every |
| @@ -926,9 +928,14 @@ | |||
| 926 | ;; Adopted or asked for, it is ours now and the file should | 928 | ;; Adopted or asked for, it is ours now and the file should |
| 927 | ;; say so before the next connect judges it. | 929 | ;; say so before the next connect judges it. |
| 928 | (remember-rooms! true))) | 930 | (remember-rooms! true))) |
| 929 | - (do (add-user! ch from) | 931 | + ;; Somebody else arriving in a room we do not hold is not a |
| 930 | - (when-not @hide-join-part? | 932 | + ;; reason to start holding it: `add-user!` and `push-message!` |
| 931 | - (push-message! ch "*" (str from " joined")))))) | 933 | + ;; both build the buffer they are given, so either one would |
| 934 | + ;; put the refused room back in the list. | ||
| 935 | + (when (contains? @channels ch) | ||
| 936 | + (add-user! ch from) | ||
| 937 | + (when-not @hide-join-part? | ||
| 938 | + (push-message! ch "*" (str from " joined")))))) | ||
| 932 | ;; NAMES, a line at a time. The channel is the parameter that names one: | 939 | ;; NAMES, a line at a time. The channel is the parameter that names one: |
| 933 | ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that | 940 | ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that |
| 934 | ;; leaves the symbol out shifts everything before the list along by one. | 941 | ;; leaves the symbol out shifts everything before the list along by one. |
| @@ -945,21 +952,33 @@ | |||
| 945 | (irc/send-line! @conn | 952 | (irc/send-line! @conn |
| 946 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) | 953 | (str "CHATHISTORY LATEST " ch " * " history-limit)))) |
| 947 | 954 | ||
| 955 | + ;; `assoc-in` on a channel that is not there does not fail, it invents | ||
| 956 | + ;; one — a buffer with a `:joined?` and nothing else, no name and no | ||
| 957 | + ;; unread, which the room list then tries to draw. That is not | ||
| 958 | + ;; hypothetical now: refusing a room the file does not claim sends PART, | ||
| 959 | + ;; and the server echoes it straight back at us. A membership changing in | ||
| 960 | + ;; a room we do not hold is nothing to record. | ||
| 948 | "PART" (let [ch (first params)] | 961 | "PART" (let [ch (first params)] |
| 949 | (if (= from @form-nick) | 962 | (if (= from @form-nick) |
| 950 | - (swap! channels #(-> % (assoc-in [ch :joined?] false) | 963 | + (swap! channels #(if (contains? % ch) |
| 951 | - (assoc-in [ch :joining?] false) | 964 | + (-> % (assoc-in [ch :joined?] false) |
| 952 | - (assoc-in [ch :users] {}))) | 965 | + (assoc-in [ch :joining?] false) |
| 953 | - (do (remove-user! ch from) | 966 | + (assoc-in [ch :users] {})) |
| 954 | - (when-not @hide-join-part? | 967 | + %)) |
| 955 | - (push-message! ch "*" (str from " left")))))) | 968 | + (when (contains? @channels ch) |
| 969 | + (remove-user! ch from) | ||
| 970 | + (when-not @hide-join-part? | ||
| 971 | + (push-message! ch "*" (str from " left")))))) | ||
| 956 | "KICK" (let [[ch who] params] | 972 | "KICK" (let [[ch who] params] |
| 957 | (if (= who @form-nick) | 973 | (if (= who @form-nick) |
| 958 | - (swap! channels #(-> % (assoc-in [ch :joined?] false) | 974 | + (swap! channels #(if (contains? % ch) |
| 959 | - (assoc-in [ch :joining?] false) | 975 | + (-> % (assoc-in [ch :joined?] false) |
| 960 | - (assoc-in [ch :users] {}))) | 976 | + (assoc-in [ch :joining?] false) |
| 977 | + (assoc-in [ch :users] {})) | ||
| 978 | + %)) | ||
| 961 | (remove-user! ch who)) | 979 | (remove-user! ch who)) |
| 962 | - (push-message! ch "*" (str who " was kicked by " from))) | 980 | + (when (contains? @channels ch) |
| 981 | + (push-message! ch "*" (str who " was kicked by " from)))) | ||
| 963 | ;; A QUIT and a NICK name no channel, so both are folded into every | 982 | ;; A QUIT and a NICK name no channel, so both are folded into every |
| 964 | ;; buffer the person was listed in — and said out loud only where they | 983 | ;; buffer the person was listed in — and said out loud only where they |
| 965 | ;; were, which is what keeps a stranger's rename out of a quiet room. | 984 | ;; were, which is what keeps a stranger's rename out of a quiet room. |