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

Keep the Flutter room list between runs

The Flutter half registered with the Bluesky handle as its IRC nick while
the sign-in had already put the derived *nick* in `form-nick`. So the
channel called us alice.bsky.social and the client thought it was alice,
and every "is this me?" test against a prefix came back false: our own
JOIN echo stopped setting `:joined?`, every room wore a "not joined"
badge, Open re-sent a JOIN for a room we were already in, and `mine?`
never recognised our own lines. It dials with the nick now, as the
desktop does.

Under that was the missing half of the room list. `frq.store` already
reads and writes rooms.edn and is already shared, but only `frq.state`
ever called it — this side started every launch with an empty list,
adopted whatever the server announced, and asked for #test on top.

So: read the file at startup as empty buffers in their saved order, write
it when the list changes, ask to be in what it says at 001, and land on
the room the reader left rather than #test. With that the desktop's
strictness can come across too — a JOIN for a room the file does not
hold is the server inventing a membership, and we part it again, unless
this is the first session and the file has yet to be told anything.

Three more divergences the port exposed:

- `:joining?`, because the saved list is asked for at 001 and the last
  room opened straight after, and a second JOIN in that window is what
  makes the server replay nothing.
- Memberships now drop on a lost connection. Left set, they made a
  reconnect ask for nothing.
- `:leave-channel!` PARTs channels only and clears `current`; the Join
  box goes through `open-room!`, so `@nick` opens a DM and a typed room
  exists before its own echo is judged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T07:43:09-07:00 Browse files
c945c1f parent: 479ec83
modified flutter/src/frq/main.cljd +303 -42
@@ -129,6 +129,198 @@
129129 (reset! cells/channels (:channels out))
130130 (:result out)))
131131
132+;; ------------------------------------------------------------------- rooms
133+;;
134+;; `rooms.edn` is the authority for what rooms there are, and until now only
135+;; the desktop kept one: this half started every launch with an empty list,
136+;; adopted whatever the server announced, and asked for `#test` on top. Which
137+;; looked like the room list forgetting itself between runs, because it was.
138+;;
139+;; The file and its format are `frq.store`, which is already shared the port
140+;; is the half that was missing here: read it at startup, write it when the
141+;; list changes, and ask to be in what it says at 001.
142+
143+(def ^:private auto-join
144+ "The room a client with nothing saved lands in, as on the desktop."
145+ "#test")
146+
147+;; Whether `rooms.edn` is the authority yet.
148+;;
149+;; It is not, the first time this version runs: freeq re-joins an
150+;; authenticated user's channels at registration, so on connect the server
151+;; announces every room it has you in and a client that parted everything
152+;; not already in its file would walk out of all of them before the file had
153+;; ever been told they existed. So the first connect adopts what the server
154+;; says and writes it down, and every connect after that is the strict one.
155+(defonce ^:private room-list-owned? (atom false))
156+
157+;; And whether this session is the adopting one, decided at 001.
158+(defonce ^:private adopting-rooms? (atom false))
159+
160+;; What the record on disk says that this client has no cell for: the read
161+;; markers, mostly. Flutter has no unread machinery yet, so writing only the
162+;; fields it knows would quietly drop the marker a desktop run had put there
163+;; and hand the reader their whole history as new. Read once, merged back
164+;; under whatever we do know.
165+(defonce ^:private room-extras (atom {}))
166+
167+(defonce ^:private rooms-saved-at (atom 0))
168+
169+(defn- save-prefs!
170+ "Keep the one flag that has to outlive the run: whether this client has
171+ taken the room list over from the server. Merged into whatever else
172+ `prefs.edn` holds the display toggles are the desktop's to write, and a
173+ whole-map write from here would drop them."
174+ []
175+ (store/save-prefs! (assoc (store/load-prefs)
176+ :room-list-owned? @room-list-owned?))
177+ nil)
178+
179+(defn- restore-prefs! []
180+ (reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))
181+ nil)
182+
183+(defn- room-records
184+ "The rooms as they go on disk: most recently used first, each carrying
185+ whatever the file already said about it that this client does not hold."
186+ []
187+ (->> (vals @cells/channels)
188+ (sort-by #(- (:accessed % 0)))
189+ (mapv (fn [b]
190+ (let [name (:name b)]
191+ (merge (get @room-extras name)
192+ {:name name
193+ :kind (or (:kind b)
194+ (if (rooms/dm? name) :dm :channel))}))))))
195+
196+(defn- remember-rooms!
197+ "Write the room list out. Throttled, because a room's `:accessed` moves
198+ every time one is opened and the write is synchronous here `force?` is for
199+ the moments worth paying for, which is the list itself changing.
200+
201+ A late write costs at most the rooms touched since the last one, which come
202+ back in a slightly stale order. Losing one entirely is the thing to avoid,
203+ which is what `force?` is for."
204+ ([] (remember-rooms! false))
205+ ([force?]
206+ (let [now (clock/now-ms)]
207+ (when (or force? (> (- now @rooms-saved-at) 5000))
208+ (reset! rooms-saved-at now)
209+ (store/save-rooms! (room-records)))
210+ nil)))
211+
212+(defn- restore-rooms!
213+ "Bring back the rooms of earlier runs, in the order they were last used.
214+
215+ Empty buffers, not memberships: opening one is what joins it, and the list
216+ is the part worth keeping the messages in them come from the server, which
217+ replays them on the CHATHISTORY this client asks for at 366.
218+
219+ `:accessed` is an ascending count over the file read backwards, so the saved
220+ order survives and a room opened *this* run stamped with the wall clock
221+ still sorts above every one of them."
222+ []
223+ (when-let [saved (seq (store/load-rooms))]
224+ (reset! room-extras
225+ (into {} (map (fn [r] [(:name r) (dissoc r :name :kind)]) saved)))
226+ (swap! cells/channels
227+ (fn [m]
228+ (reduce (fn [acc [i room]]
229+ (let [name (:name room)]
230+ (if (contains? acc name)
231+ acc
232+ (assoc acc name
233+ {:name name
234+ :messages []
235+ :unread 0
236+ :joined? false
237+ :users {}
238+ :kind (or (:kind room)
239+ (if (rooms/dm? name) :dm :channel))
240+ :accessed (inc i)}))))
241+ m
242+ ;; Oldest first, so the count ascends with the order.
243+ (map-indexed vector (reverse saved)))))
244+ (count saved)))
245+
246+(defn- ask-to-join!
247+ "Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo
248+ takes a round trip: a second JOIN sent in the meantime is what makes the
249+ server replay nothing, and with the saved list asked for at 001 and the last
250+ room opened straight after, that window is every launch."
251+ [name]
252+ (when-let [c @conn]
253+ (when (and (.startsWith (str name) "#")
254+ (let [b (get @cells/channels name)]
255+ (and (not (:joined? b)) (not (:joining? b)))))
256+ (swap! cells/channels assoc-in [name :joining?] true)
257+ (net/send-line! c (str "JOIN " name))))
258+ nil)
259+
260+(defn- open-room!
261+ "Show a buffer, joining it on the way. A row can outlive the membership
262+ behind it a disconnect drops every channel and the buffer stays so
263+ opening one is a request to be in it.
264+
265+ Ensuring the buffer *before* the JOIN is what keeps the strict rule in the
266+ JOIN handler from throwing the room straight back out: a channel we asked
267+ for is one the list already holds by the time the echo lands."
268+ [name]
269+ (reset! cells/current name)
270+ ;; An edit belongs to a line in the buffer being left: carried across, the
271+ ;; next Send would rewrite a message nobody in this room can see. The box
272+ ;; empties with it, because what is in it is a copy of that line
273+ ;; `frq.state/open-channel!` does this too.
274+ (when (and @cells/editing (not= name (:channel @cells/editing)))
275+ (reset! cells/editing nil)
276+ (reset! cells/draft ""))
277+ (swap! cells/channels update name
278+ #(merge {:name name :messages [] :unread 0
279+ :kind (if (rooms/dm? name) :dm :channel)}
280+ %))
281+ ;; The wall clock, so a room opened this run sorts above every one
282+ ;; `restore-rooms!` counted in.
283+ (swap! cells/channels assoc-in [name :accessed] (clock/now-ms))
284+ (reset! cells/screen :chat)
285+ ;; Worth a write of its own: the order the list is read in is the order
286+ ;; rooms were last opened, and this is the moment it changes.
287+ (remember-rooms! true)
288+ (ask-to-join! name)
289+ nil)
290+
291+(defn- join-saved-rooms!
292+ "Ask to be in every channel the file says we are in.
293+
294+ The server re-joins an authenticated user's channels itself and gets it
295+ wrong in both directions it forgets rooms and announces ones that are not
296+ ours. This is the half that answers the forgetting. A JOIN for a channel the
297+ server has already put us in is answered with the membership we already
298+ have, so asking twice costs nothing.
299+
300+ DMs are not asked for: there is nothing to join in a conversation with a
301+ person, the buffer is the whole of it."
302+ []
303+ (when @conn
304+ (doseq [[name _] @cells/channels] (ask-to-join! name))
305+ nil))
306+
307+(defn- forget-memberships!
308+ "The buffers survive a lost connection, the memberships do not.
309+
310+ Leaving `:joined?` set across a drop is what made a reconnect ask for
311+ nothing: `ask-to-join!` reads it, every room still claimed to be joined, and
312+ the list came back full of rooms we were no longer in. The users go with it
313+ the roll is the server's and it will send a fresh one with the NAMES that
314+ follow the re-JOIN."
315+ []
316+ (swap! cells/channels
317+ (fn [m]
318+ (reduce-kv (fn [acc k v]
319+ (assoc acc k (assoc v :joined? false :joining? false
320+ :users {})))
321+ {} m)))
322+ nil)
323+
132324 (def ^:private history-limit
133325 "How many lines of backlog to ask a room for, as `frq.state` asks for them."
134326 100)
@@ -150,14 +342,32 @@
150342 (cond
151343 (= "JOIN" cmd)
152344 (let [name (first params)]
153- (swap! cells/channels
154- #(-> (ensure % name)
155- (members/add-user name who)
156- (cond-> me? (update name merge {:joined? true
157- :accessed (clock/now-ms)}))))
158- ;; Only our own JOIN opens the room. Someone else arriving in a
159- ;; channel used to move the reader into it.
160- (when me? (reset! cells/current name)))
345+ (if me?
346+ ;; Ours if the file says so restored at startup, or asked for
347+ ;; since. Anything else is the server putting us somewhere we did
348+ ;; not ask to be, which it does: it announces memberships that are
349+ ;; not real, and adding them is how a list nobody chose fills up.
350+ ;;
351+ ;; So we leave again, unless this is the session that is still
352+ ;; adopting on the first connect the file has not been told
353+ ;; anything yet, and parting then would be leaving every room we are
354+ ;; actually in.
355+ (if (and (not (contains? @cells/channels name))
356+ (not @adopting-rooms?))
357+ (when-let [c @conn] (net/send-line! c (str "PART " name)))
358+ (do (swap! cells/channels
359+ #(-> (ensure % name)
360+ (members/add-user name who)
361+ (update name merge {:joined? true
362+ :joining? false})))
363+ ;; Adopted or asked for, it is ours now and the file should
364+ ;; say so before the next connect judges it.
365+ (remember-rooms! true)))
366+ ;; Somebody else arriving in a room we do not hold is not a reason
367+ ;; to start holding it: `add-user` builds the buffer it is given,
368+ ;; which would put a refused room straight back in the list.
369+ (when (contains? @cells/channels name)
370+ (swap! cells/channels members/add-user name who))))
161371
162372 ;; 353 is the roll, in as many lines as it takes; 366 ends it.
163373 (= "353" cmd)
@@ -280,7 +490,12 @@
280490 add (swap! cells/channels reactions/update-reaction
281491 buffer msgid add who true)
282492 gone (swap! cells/channels reactions/update-reaction
283- buffer msgid gone who false))))))
493+ buffer msgid gone who false))))
494+ ;; Whatever that message did to the list, write it down. Throttled, so a
495+ ;; busy channel does not cost a file write per line but a DM arriving
496+ ;; from someone new is a room that exists only here until this runs, and
497+ ;; the old client lost exactly those between runs.
498+ (remember-rooms!)))
284499
285500 (defn- note! [m]
286501 (reset! last-line (str (:command m) " " (last (:params m))))
@@ -492,8 +707,37 @@
492707 ;; and looks exactly like a
493708 ;; failure.
494709 (reset! cells/screen :chats)
495- (when-let [c @conn]
496- (net/send-line! c "JOIN #test")))
710+ ;; This session decides once
711+ ;; whether it is the one that
712+ ;; takes the room list over
713+ ;; from the server. Before the
714+ ;; flag is set the file has
715+ ;; never been told what we are
716+ ;; in, so the server's answer
717+ ;; is adopted rather than
718+ ;; argued with.
719+ (reset! adopting-rooms?
720+ (not @room-list-owned?))
721+ (when-not @room-list-owned?
722+ (reset! room-list-owned? true)
723+ (save-prefs!))
724+ ;; What the file says we are in,
725+ ;; we ask to be in. A room the
726+ ;; server has forgotten is one
727+ ;; that would otherwise quietly
728+ ;; stop existing.
729+ (join-saved-rooms!)
730+ ;; And back where the reader
731+ ;; left off, rather than into
732+ ;; #test on top of a list they
733+ ;; already had. The chats
734+ ;; screen stays underneath, so
735+ ;; Back goes to the list rather
736+ ;; than out of the app.
737+ (if-let [last-room
738+ (:name (first (rooms/channel-list)))]
739+ (actions/open-channel! last-room)
740+ (actions/join! auto-join)))
497741
498742 (contains? registration-failed cmd)
499743 (fail! (if (= "433" cmd)
@@ -507,6 +751,7 @@
507751 (reset! cells/status (str "" cmd)))))
508752 :on-close (fn [why]
509753 (reset! conn nil)
754+ (forget-memberships!)
510755 ;; Say what the last thing seen was. A
511756 ;; connection that registers and then
512757 ;; drops is a different bug from one
@@ -521,7 +766,16 @@
521766 ;; CAP first, then registration the order the server expects, and the
522767 ;; order `frq.irc` uses.
523768 (net/send-line! sock "CAP LS 302")
524- (let [nick (if-let [h (:handle @cells/session)] h @cells/form-nick)]
769+ ;; `form-nick` and nothing else. The handle went on the wire here,
770+ ;; and the sign-in had already put the *nick* in the cell so the
771+ ;; channel called us alice.bsky.social while the client thought it
772+ ;; was alice, and every test of "is this me?" against a prefix came
773+ ;; back false. Our own JOIN echo stopped setting `:joined?`, so
774+ ;; every room wore a "not joined" badge and Open re-sent a JOIN for
775+ ;; a room we were already in. The DID is the identity; the nick is
776+ ;; only what the channel calls us, and both halves have to agree on
777+ ;; it. Same nick the desktop dials with.
778+ (let [nick (str @cells/form-nick)]
525779 (net/send-line! sock (str "NICK " nick))
526780 (net/send-line! sock (str "USER " nick " 0 * :frq"))))
527781 ;; Object, not Exception. Dart keeps Error and Exception in separate
@@ -534,6 +788,7 @@
534788 (reset! closing? true)
535789 (when-let [c @conn] (net/close! c))
536790 (reset! conn nil)
791+ (forget-memberships!)
537792 (reset! cells/connecting? false)
538793 (reset! cells/status "Not connected")
539794 (reset! cells/screen :connect))
@@ -771,6 +1026,11 @@
7711026 ;; Before any widget is built: a cell that changes before its watch is on
7721027 ;; is a change the screen never hears about.
7731028 (watch-cells!)
1029+ ;; The room list of earlier runs, and the flag that says whether it is the
1030+ ;; authority yet. Before the saved session, because that connects on its
1031+ ;; own the moment it is restored and 001 reads both of these.
1032+ (restore-prefs!)
1033+ (restore-rooms!)
7741034 ;; A sign-in that already happened. Only the durable broker token comes
7751035 ;; back the connection mints a fresh web-token from it so this is not
7761036 ;; a session, it is the means to ask for one.
@@ -800,13 +1060,38 @@
8001060 {:connect! connect!
8011061 :disconnect! disconnect!
8021062 :connected? (fn [] (some? @conn))
1063+ ;; The Join box, and the same one box for both things the desktop's
1064+ ;; `join!` does: `@nick` opens a DM, anything else is a channel and
1065+ ;; gets its `#` if it came without one. Through `open-room!` rather
1066+ ;; than straight onto the wire, because a bare JOIN left the buffer
1067+ ;; unbuilt and the rule in the JOIN handler would then read our own
1068+ ;; echo as a room the server had invented and part it again.
8031069 :join! (fn [name]
804- (when-let [c @conn]
805- (when (seq (str name))
806- (net/send-line! c (str "JOIN " name)))))
1070+ (let [name (.trim (str name))]
1071+ (cond
1072+ (.startsWith name "@")
1073+ (let [who (.substring name 1)]
1074+ (when (seq who) (open-room! who)))
1075+
1076+ (seq name)
1077+ (open-room! (if (.startsWith name "#") name (str "#" name)))
1078+
1079+ :else nil)))
8071080 :leave-channel! (fn [name]
808- (when-let [c @conn] (net/send-line! c (str "PART " name)))
809- (swap! cells/channels dissoc name))
1081+ ;; Channels only, as on the desktop. There is nothing
1082+ ;; to part in a conversation with a person the
1083+ ;; buffer is the whole of it and `PART alice` is a
1084+ ;; line the server answers with an error.
1085+ (when (and @conn (.startsWith (str name) "#"))
1086+ (net/send-line! @conn (str "PART " name)))
1087+ (swap! cells/channels dissoc name)
1088+ (when (= name @cells/current)
1089+ (reset! cells/current nil)
1090+ (reset! cells/screen :chats))
1091+ ;; The only way a room leaves the file. Everything
1092+ ;; else adds one, so without this the list is a thing
1093+ ;; that only grows.
1094+ (remember-rooms! true))
8101095 :send-draft! send-draft!
8111096
8121097 ;; Pictures. The chooser is the platform's, so the browsing screen the
@@ -953,31 +1238,7 @@
9531238 (reset! cells/broker-token nil)
9541239 (reset! cells/session nil))
9551240 :jump-to-present! (fn [] (reset! cells/at-present? true))
956- :open-channel! (fn [name]
957- (reset! cells/current name)
958- ;; An edit belongs to a line in the buffer being left:
959- ;; carried across, the next Send would rewrite a
960- ;; message nobody in this room can see. The box empties
961- ;; with it, because what is in it is a copy of that
962- ;; line `frq.state/open-channel!` does this too.
963- (when (and @cells/editing
964- (not= name (:channel @cells/editing)))
965- (reset! cells/editing nil)
966- (reset! cells/draft ""))
967- ;; `frq.state/open-channel!` does this and more: it
968- ;; marks the buffer read, remembers the room list and
969- ;; asks for NAMES. What it also does, and what made
970- ;; Open look like a no-op here, is go to the
971- ;; conversation and join it on the way, because a row
972- ;; can outlive the membership behind it.
973- (swap! cells/channels update name
974- #(merge {:name name :messages [] :unread 0} %))
975- (reset! cells/screen :chat)
976- (let [buffer (get @cells/channels name)]
977- (when (and @conn
978- (.startsWith (str name) "#")
979- (not (:joined? buffer)))
980- (net/send-line! @conn (str "JOIN " name)))))})
1241+ :open-channel! open-room!})
9811242 (f/run
9821243 (m/MaterialApp .title "frq" .theme (t/app-theme))
9831244 .home
@@ -129,6 +129,198 @@
129 (reset! cells/channels (:channels out))129 (reset! cells/channels (:channels out))
130 (:result out)))130 (:result out)))
131 131
132+;; ------------------------------------------------------------------- rooms
133+;;
134+;; `rooms.edn` is the authority for what rooms there are, and until now only
135+;; the desktop kept one: this half started every launch with an empty list,
136+;; adopted whatever the server announced, and asked for `#test` on top. Which
137+;; looked like the room list forgetting itself between runs, because it was.
138+;;
139+;; The file and its format are `frq.store`, which is already shared the port
140+;; is the half that was missing here: read it at startup, write it when the
141+;; list changes, and ask to be in what it says at 001.
142+
143+(def ^:private auto-join
144+ "The room a client with nothing saved lands in, as on the desktop."
145+ "#test")
146+
147+;; Whether `rooms.edn` is the authority yet.
148+;;
149+;; It is not, the first time this version runs: freeq re-joins an
150+;; authenticated user's channels at registration, so on connect the server
151+;; announces every room it has you in and a client that parted everything
152+;; not already in its file would walk out of all of them before the file had
153+;; ever been told they existed. So the first connect adopts what the server
154+;; says and writes it down, and every connect after that is the strict one.
155+(defonce ^:private room-list-owned? (atom false))
156+
157+;; And whether this session is the adopting one, decided at 001.
158+(defonce ^:private adopting-rooms? (atom false))
159+
160+;; What the record on disk says that this client has no cell for: the read
161+;; markers, mostly. Flutter has no unread machinery yet, so writing only the
162+;; fields it knows would quietly drop the marker a desktop run had put there
163+;; and hand the reader their whole history as new. Read once, merged back
164+;; under whatever we do know.
165+(defonce ^:private room-extras (atom {}))
166+
167+(defonce ^:private rooms-saved-at (atom 0))
168+
169+(defn- save-prefs!
170+ "Keep the one flag that has to outlive the run: whether this client has
171+ taken the room list over from the server. Merged into whatever else
172+ `prefs.edn` holds the display toggles are the desktop's to write, and a
173+ whole-map write from here would drop them."
174+ []
175+ (store/save-prefs! (assoc (store/load-prefs)
176+ :room-list-owned? @room-list-owned?))
177+ nil)
178+
179+(defn- restore-prefs! []
180+ (reset! room-list-owned? (boolean (:room-list-owned? (store/load-prefs))))
181+ nil)
182+
183+(defn- room-records
184+ "The rooms as they go on disk: most recently used first, each carrying
185+ whatever the file already said about it that this client does not hold."
186+ []
187+ (->> (vals @cells/channels)
188+ (sort-by #(- (:accessed % 0)))
189+ (mapv (fn [b]
190+ (let [name (:name b)]
191+ (merge (get @room-extras name)
192+ {:name name
193+ :kind (or (:kind b)
194+ (if (rooms/dm? name) :dm :channel))}))))))
195+
196+(defn- remember-rooms!
197+ "Write the room list out. Throttled, because a room's `:accessed` moves
198+ every time one is opened and the write is synchronous here `force?` is for
199+ the moments worth paying for, which is the list itself changing.
200+
201+ A late write costs at most the rooms touched since the last one, which come
202+ back in a slightly stale order. Losing one entirely is the thing to avoid,
203+ which is what `force?` is for."
204+ ([] (remember-rooms! false))
205+ ([force?]
206+ (let [now (clock/now-ms)]
207+ (when (or force? (> (- now @rooms-saved-at) 5000))
208+ (reset! rooms-saved-at now)
209+ (store/save-rooms! (room-records)))
210+ nil)))
211+
212+(defn- restore-rooms!
213+ "Bring back the rooms of earlier runs, in the order they were last used.
214+
215+ Empty buffers, not memberships: opening one is what joins it, and the list
216+ is the part worth keeping the messages in them come from the server, which
217+ replays them on the CHATHISTORY this client asks for at 366.
218+
219+ `:accessed` is an ascending count over the file read backwards, so the saved
220+ order survives and a room opened *this* run stamped with the wall clock
221+ still sorts above every one of them."
222+ []
223+ (when-let [saved (seq (store/load-rooms))]
224+ (reset! room-extras
225+ (into {} (map (fn [r] [(:name r) (dissoc r :name :kind)]) saved)))
226+ (swap! cells/channels
227+ (fn [m]
228+ (reduce (fn [acc [i room]]
229+ (let [name (:name room)]
230+ (if (contains? acc name)
231+ acc
232+ (assoc acc name
233+ {:name name
234+ :messages []
235+ :unread 0
236+ :joined? false
237+ :users {}
238+ :kind (or (:kind room)
239+ (if (rooms/dm? name) :dm :channel))
240+ :accessed (inc i)}))))
241+ m
242+ ;; Oldest first, so the count ascends with the order.
243+ (map-indexed vector (reverse saved)))))
244+ (count saved)))
245+
246+(defn- ask-to-join!
247+ "Send the JOIN, once. `:joining?` as well as `:joined?`, because the echo
248+ takes a round trip: a second JOIN sent in the meantime is what makes the
249+ server replay nothing, and with the saved list asked for at 001 and the last
250+ room opened straight after, that window is every launch."
251+ [name]
252+ (when-let [c @conn]
253+ (when (and (.startsWith (str name) "#")
254+ (let [b (get @cells/channels name)]
255+ (and (not (:joined? b)) (not (:joining? b)))))
256+ (swap! cells/channels assoc-in [name :joining?] true)
257+ (net/send-line! c (str "JOIN " name))))
258+ nil)
259+
260+(defn- open-room!
261+ "Show a buffer, joining it on the way. A row can outlive the membership
262+ behind it a disconnect drops every channel and the buffer stays so
263+ opening one is a request to be in it.
264+
265+ Ensuring the buffer *before* the JOIN is what keeps the strict rule in the
266+ JOIN handler from throwing the room straight back out: a channel we asked
267+ for is one the list already holds by the time the echo lands."
268+ [name]
269+ (reset! cells/current name)
270+ ;; An edit belongs to a line in the buffer being left: carried across, the
271+ ;; next Send would rewrite a message nobody in this room can see. The box
272+ ;; empties with it, because what is in it is a copy of that line
273+ ;; `frq.state/open-channel!` does this too.
274+ (when (and @cells/editing (not= name (:channel @cells/editing)))
275+ (reset! cells/editing nil)
276+ (reset! cells/draft ""))
277+ (swap! cells/channels update name
278+ #(merge {:name name :messages [] :unread 0
279+ :kind (if (rooms/dm? name) :dm :channel)}
280+ %))
281+ ;; The wall clock, so a room opened this run sorts above every one
282+ ;; `restore-rooms!` counted in.
283+ (swap! cells/channels assoc-in [name :accessed] (clock/now-ms))
284+ (reset! cells/screen :chat)
285+ ;; Worth a write of its own: the order the list is read in is the order
286+ ;; rooms were last opened, and this is the moment it changes.
287+ (remember-rooms! true)
288+ (ask-to-join! name)
289+ nil)
290+
291+(defn- join-saved-rooms!
292+ "Ask to be in every channel the file says we are in.
293+
294+ The server re-joins an authenticated user's channels itself and gets it
295+ wrong in both directions it forgets rooms and announces ones that are not
296+ ours. This is the half that answers the forgetting. A JOIN for a channel the
297+ server has already put us in is answered with the membership we already
298+ have, so asking twice costs nothing.
299+
300+ DMs are not asked for: there is nothing to join in a conversation with a
301+ person, the buffer is the whole of it."
302+ []
303+ (when @conn
304+ (doseq [[name _] @cells/channels] (ask-to-join! name))
305+ nil))
306+
307+(defn- forget-memberships!
308+ "The buffers survive a lost connection, the memberships do not.
309+
310+ Leaving `:joined?` set across a drop is what made a reconnect ask for
311+ nothing: `ask-to-join!` reads it, every room still claimed to be joined, and
312+ the list came back full of rooms we were no longer in. The users go with it
313+ the roll is the server's and it will send a fresh one with the NAMES that
314+ follow the re-JOIN."
315+ []
316+ (swap! cells/channels
317+ (fn [m]
318+ (reduce-kv (fn [acc k v]
319+ (assoc acc k (assoc v :joined? false :joining? false
320+ :users {})))
321+ {} m)))
322+ nil)
323+
132 (def ^:private history-limit324 (def ^:private history-limit
133 "How many lines of backlog to ask a room for, as `frq.state` asks for them."325 "How many lines of backlog to ask a room for, as `frq.state` asks for them."
134 100)326 100)
@@ -150,14 +342,32 @@
150 (cond342 (cond
151 (= "JOIN" cmd)343 (= "JOIN" cmd)
152 (let [name (first params)]344 (let [name (first params)]
153- (swap! cells/channels345+ (if me?
154- #(-> (ensure % name)346+ ;; Ours if the file says so restored at startup, or asked for
155- (members/add-user name who)347+ ;; since. Anything else is the server putting us somewhere we did
156- (cond-> me? (update name merge {:joined? true348+ ;; not ask to be, which it does: it announces memberships that are
157- :accessed (clock/now-ms)}))))349+ ;; not real, and adding them is how a list nobody chose fills up.
158- ;; Only our own JOIN opens the room. Someone else arriving in a350+ ;;
159- ;; channel used to move the reader into it.351+ ;; So we leave again, unless this is the session that is still
160- (when me? (reset! cells/current name)))352+ ;; adopting on the first connect the file has not been told
353+ ;; anything yet, and parting then would be leaving every room we are
354+ ;; actually in.
355+ (if (and (not (contains? @cells/channels name))
356+ (not @adopting-rooms?))
357+ (when-let [c @conn] (net/send-line! c (str "PART " name)))
358+ (do (swap! cells/channels
359+ #(-> (ensure % name)
360+ (members/add-user name who)
361+ (update name merge {:joined? true
362+ :joining? false})))
363+ ;; Adopted or asked for, it is ours now and the file should
364+ ;; say so before the next connect judges it.
365+ (remember-rooms! true)))
366+ ;; Somebody else arriving in a room we do not hold is not a reason
367+ ;; to start holding it: `add-user` builds the buffer it is given,
368+ ;; which would put a refused room straight back in the list.
369+ (when (contains? @cells/channels name)
370+ (swap! cells/channels members/add-user name who))))
161 371
162 ;; 353 is the roll, in as many lines as it takes; 366 ends it.372 ;; 353 is the roll, in as many lines as it takes; 366 ends it.
163 (= "353" cmd)373 (= "353" cmd)
@@ -280,7 +490,12 @@
280 add (swap! cells/channels reactions/update-reaction490 add (swap! cells/channels reactions/update-reaction
281 buffer msgid add who true)491 buffer msgid add who true)
282 gone (swap! cells/channels reactions/update-reaction492 gone (swap! cells/channels reactions/update-reaction
283- buffer msgid gone who false))))))493+ buffer msgid gone who false))))
494+ ;; Whatever that message did to the list, write it down. Throttled, so a
495+ ;; busy channel does not cost a file write per line but a DM arriving
496+ ;; from someone new is a room that exists only here until this runs, and
497+ ;; the old client lost exactly those between runs.
498+ (remember-rooms!)))
284 499
285 (defn- note! [m]500 (defn- note! [m]
286 (reset! last-line (str (:command m) " " (last (:params m))))501 (reset! last-line (str (:command m) " " (last (:params m))))
@@ -492,8 +707,37 @@
492 ;; and looks exactly like a707 ;; and looks exactly like a
493 ;; failure.708 ;; failure.
494 (reset! cells/screen :chats)709 (reset! cells/screen :chats)
495- (when-let [c @conn]710+ ;; This session decides once
496- (net/send-line! c "JOIN #test")))711+ ;; whether it is the one that
712+ ;; takes the room list over
713+ ;; from the server. Before the
714+ ;; flag is set the file has
715+ ;; never been told what we are
716+ ;; in, so the server's answer
717+ ;; is adopted rather than
718+ ;; argued with.
719+ (reset! adopting-rooms?
720+ (not @room-list-owned?))
721+ (when-not @room-list-owned?
722+ (reset! room-list-owned? true)
723+ (save-prefs!))
724+ ;; What the file says we are in,
725+ ;; we ask to be in. A room the
726+ ;; server has forgotten is one
727+ ;; that would otherwise quietly
728+ ;; stop existing.
729+ (join-saved-rooms!)
730+ ;; And back where the reader
731+ ;; left off, rather than into
732+ ;; #test on top of a list they
733+ ;; already had. The chats
734+ ;; screen stays underneath, so
735+ ;; Back goes to the list rather
736+ ;; than out of the app.
737+ (if-let [last-room
738+ (:name (first (rooms/channel-list)))]
739+ (actions/open-channel! last-room)
740+ (actions/join! auto-join)))
497 741
498 (contains? registration-failed cmd)742 (contains? registration-failed cmd)
499 (fail! (if (= "433" cmd)743 (fail! (if (= "433" cmd)
@@ -507,6 +751,7 @@
507 (reset! cells/status (str "" cmd)))))751 (reset! cells/status (str "" cmd)))))
508 :on-close (fn [why]752 :on-close (fn [why]
509 (reset! conn nil)753 (reset! conn nil)
754+ (forget-memberships!)
510 ;; Say what the last thing seen was. A755 ;; Say what the last thing seen was. A
511 ;; connection that registers and then756 ;; connection that registers and then
512 ;; drops is a different bug from one757 ;; drops is a different bug from one
@@ -521,7 +766,16 @@
521 ;; CAP first, then registration the order the server expects, and the766 ;; CAP first, then registration the order the server expects, and the
522 ;; order `frq.irc` uses.767 ;; order `frq.irc` uses.
523 (net/send-line! sock "CAP LS 302")768 (net/send-line! sock "CAP LS 302")
524- (let [nick (if-let [h (:handle @cells/session)] h @cells/form-nick)]769+ ;; `form-nick` and nothing else. The handle went on the wire here,
770+ ;; and the sign-in had already put the *nick* in the cell so the
771+ ;; channel called us alice.bsky.social while the client thought it
772+ ;; was alice, and every test of "is this me?" against a prefix came
773+ ;; back false. Our own JOIN echo stopped setting `:joined?`, so
774+ ;; every room wore a "not joined" badge and Open re-sent a JOIN for
775+ ;; a room we were already in. The DID is the identity; the nick is
776+ ;; only what the channel calls us, and both halves have to agree on
777+ ;; it. Same nick the desktop dials with.
778+ (let [nick (str @cells/form-nick)]
525 (net/send-line! sock (str "NICK " nick))779 (net/send-line! sock (str "NICK " nick))
526 (net/send-line! sock (str "USER " nick " 0 * :frq"))))780 (net/send-line! sock (str "USER " nick " 0 * :frq"))))
527 ;; Object, not Exception. Dart keeps Error and Exception in separate781 ;; Object, not Exception. Dart keeps Error and Exception in separate
@@ -534,6 +788,7 @@
534 (reset! closing? true)788 (reset! closing? true)
535 (when-let [c @conn] (net/close! c))789 (when-let [c @conn] (net/close! c))
536 (reset! conn nil)790 (reset! conn nil)
791+ (forget-memberships!)
537 (reset! cells/connecting? false)792 (reset! cells/connecting? false)
538 (reset! cells/status "Not connected")793 (reset! cells/status "Not connected")
539 (reset! cells/screen :connect))794 (reset! cells/screen :connect))
@@ -771,6 +1026,11 @@
771 ;; Before any widget is built: a cell that changes before its watch is on1026 ;; Before any widget is built: a cell that changes before its watch is on
772 ;; is a change the screen never hears about.1027 ;; is a change the screen never hears about.
773 (watch-cells!)1028 (watch-cells!)
1029+ ;; The room list of earlier runs, and the flag that says whether it is the
1030+ ;; authority yet. Before the saved session, because that connects on its
1031+ ;; own the moment it is restored and 001 reads both of these.
1032+ (restore-prefs!)
1033+ (restore-rooms!)
774 ;; A sign-in that already happened. Only the durable broker token comes1034 ;; A sign-in that already happened. Only the durable broker token comes
775 ;; back the connection mints a fresh web-token from it so this is not1035 ;; back the connection mints a fresh web-token from it so this is not
776 ;; a session, it is the means to ask for one.1036 ;; a session, it is the means to ask for one.
@@ -800,13 +1060,38 @@
800 {:connect! connect!1060 {:connect! connect!
801 :disconnect! disconnect!1061 :disconnect! disconnect!
802 :connected? (fn [] (some? @conn))1062 :connected? (fn [] (some? @conn))
1063+ ;; The Join box, and the same one box for both things the desktop's
1064+ ;; `join!` does: `@nick` opens a DM, anything else is a channel and
1065+ ;; gets its `#` if it came without one. Through `open-room!` rather
1066+ ;; than straight onto the wire, because a bare JOIN left the buffer
1067+ ;; unbuilt and the rule in the JOIN handler would then read our own
1068+ ;; echo as a room the server had invented and part it again.
803 :join! (fn [name]1069 :join! (fn [name]
804- (when-let [c @conn]1070+ (let [name (.trim (str name))]
805- (when (seq (str name))1071+ (cond
806- (net/send-line! c (str "JOIN " name)))))1072+ (.startsWith name "@")
1073+ (let [who (.substring name 1)]
1074+ (when (seq who) (open-room! who)))
1075+
1076+ (seq name)
1077+ (open-room! (if (.startsWith name "#") name (str "#" name)))
1078+
1079+ :else nil)))
807 :leave-channel! (fn [name]1080 :leave-channel! (fn [name]
808- (when-let [c @conn] (net/send-line! c (str "PART " name)))1081+ ;; Channels only, as on the desktop. There is nothing
809- (swap! cells/channels dissoc name))1082+ ;; to part in a conversation with a person the
1083+ ;; buffer is the whole of it and `PART alice` is a
1084+ ;; line the server answers with an error.
1085+ (when (and @conn (.startsWith (str name) "#"))
1086+ (net/send-line! @conn (str "PART " name)))
1087+ (swap! cells/channels dissoc name)
1088+ (when (= name @cells/current)
1089+ (reset! cells/current nil)
1090+ (reset! cells/screen :chats))
1091+ ;; The only way a room leaves the file. Everything
1092+ ;; else adds one, so without this the list is a thing
1093+ ;; that only grows.
1094+ (remember-rooms! true))
810 :send-draft! send-draft!1095 :send-draft! send-draft!
811 1096
812 ;; Pictures. The chooser is the platform's, so the browsing screen the1097 ;; Pictures. The chooser is the platform's, so the browsing screen the
@@ -953,31 +1238,7 @@
953 (reset! cells/broker-token nil)1238 (reset! cells/broker-token nil)
954 (reset! cells/session nil))1239 (reset! cells/session nil))
955 :jump-to-present! (fn [] (reset! cells/at-present? true))1240 :jump-to-present! (fn [] (reset! cells/at-present? true))
956- :open-channel! (fn [name]1241+ :open-channel! open-room!})
957- (reset! cells/current name)
958- ;; An edit belongs to a line in the buffer being left:
959- ;; carried across, the next Send would rewrite a
960- ;; message nobody in this room can see. The box empties
961- ;; with it, because what is in it is a copy of that
962- ;; line `frq.state/open-channel!` does this too.
963- (when (and @cells/editing
964- (not= name (:channel @cells/editing)))
965- (reset! cells/editing nil)
966- (reset! cells/draft ""))
967- ;; `frq.state/open-channel!` does this and more: it
968- ;; marks the buffer read, remembers the room list and
969- ;; asks for NAMES. What it also does, and what made
970- ;; Open look like a no-op here, is go to the
971- ;; conversation and join it on the way, because a row
972- ;; can outlive the membership behind it.
973- (swap! cells/channels update name
974- #(merge {:name name :messages [] :unread 0} %))
975- (reset! cells/screen :chat)
976- (let [buffer (get @cells/channels name)]
977- (when (and @conn
978- (.startsWith (str name) "#")
979- (not (:joined? buffer)))
980- (net/send-line! @conn (str "JOIN " name)))))})
981 (f/run1242 (f/run
982 (m/MaterialApp .title "frq" .theme (t/app-theme))1243 (m/MaterialApp .title "frq" .theme (t/app-theme))
983 .home1244 .home