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

Stop a Flutter edit posting the line a second time

The PRIVMSG handler's `if` was one-armed by a paren: the revision branch's
closers took the `if` with them, so the append that should have been its else
arm was a sibling and ran for every message. An edit was folded into the line
it names, correctly, and then posted again at the bottom as a new row.

The append is the else arm now, and the revision branch is indented so that
is visible — the shape is what hid this.

And not a line we already hold, either. The server hands the same message
over more than once and `frq.state/push-message!` has always dropped the
repeats; that rule is `frq.rooms/seen-message?` now, shared, so this half
stops doubling a room on every replay and folds a collapsed `+freeq.at/edited`
row into the line it belongs to rather than appending it.

Two more in the same branch: the row a rewrite older than the backlog leaves
behind was built without `:actor`, so it lost its face; and the DM buffer
lacked the desktop's self-echo case, which filed our own echoed DM — and any
edit of it — under our own nick instead of the conversation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T09:20:48-07:00 Browse files
8229078 parent: f2e12af
modified common/frq/rooms.cljc +31 -0
@@ -71,6 +71,37 @@
7171 (str/lower-case (or me "")))))
7272
7373
74+(defn seen-message?
75+ "Whether this buffer already holds the line that has just arrived.
76+
77+ The server hands the same message over more than once: a JOIN replays the
78+ backlog, a CHATHISTORY replays it again, and a line can have arrived live
79+ before either. The msgid is the message's identity and it survives every
80+ revision, so holding the copy we have is what keeps a rejoin from doubling
81+ the buffer — and what keeps a replayed *pre-edit* row from landing under a
82+ line already showing the current text.
83+
84+ And sometimes a line is replayed with no tags at all — no msgid to know it
85+ by and no time to place it. That line has no identity, so left alone it
86+ arrives new on every rejoin, appended again and stamped `now`, which is a
87+ room that can never be finished reading. What it does have is a sender and
88+ words, which for an untagged line is identity enough. The cost is that the
89+ same person saying the same thing twice — both times untagged — shows once.
90+ Ours and the system's are left out of it: those have no msgid either, and a
91+ second \"ok\" from this client, or a second \"alice joined\", is a real event
92+ rather than a replay."
93+ [msgs id from text me]
94+ (boolean
95+ (if id
96+ (some #(= id (:id %)) msgs)
97+ (and (not= "*" from)
98+ (not= (str/lower-case (or from "")) (str/lower-case (or me "")))
99+ (some #(and (nil? (:id %))
100+ (= from (:from %))
101+ (= text (:text %)))
102+ msgs)))))
103+
104+
74105 ;; How many lines the overview holds in all.
75106 (def overview-limit 100)
76107
@@ -71,6 +71,37 @@
71 (str/lower-case (or me "")))))71 (str/lower-case (or me "")))))
72 72
73 73
74+(defn seen-message?
75+ "Whether this buffer already holds the line that has just arrived.
76+
77+ The server hands the same message over more than once: a JOIN replays the
78+ backlog, a CHATHISTORY replays it again, and a line can have arrived live
79+ before either. The msgid is the message's identity and it survives every
80+ revision, so holding the copy we have is what keeps a rejoin from doubling
81+ the buffer — and what keeps a replayed *pre-edit* row from landing under a
82+ line already showing the current text.
83+
84+ And sometimes a line is replayed with no tags at all — no msgid to know it
85+ by and no time to place it. That line has no identity, so left alone it
86+ arrives new on every rejoin, appended again and stamped `now`, which is a
87+ room that can never be finished reading. What it does have is a sender and
88+ words, which for an untagged line is identity enough. The cost is that the
89+ same person saying the same thing twice — both times untagged — shows once.
90+ Ours and the system's are left out of it: those have no msgid either, and a
91+ second \"ok\" from this client, or a second \"alice joined\", is a real event
92+ rather than a replay."
93+ [msgs id from text me]
94+ (boolean
95+ (if id
96+ (some #(= id (:id %)) msgs)
97+ (and (not= "*" from)
98+ (not= (str/lower-case (or from "")) (str/lower-case (or me "")))
99+ (some #(and (nil? (:id %))
100+ (= from (:from %))
101+ (= text (:text %)))
102+ msgs)))))
103+
104+
74 ;; How many lines the overview holds in all.105 ;; How many lines the overview holds in all.
75 (def overview-limit 100)106 (def overview-limit 100)
76 107
modified flutter/src/frq/main.cljd +45 -19
@@ -388,7 +388,13 @@
388388 (let [tags (:tags m)
389389 target (first params)
390390 text (last params)
391- name (if (rooms/dm? target) who target)
391+ ;; A DM addressed to us belongs in a buffer named for the sender,
392+ ;; not for our own nick except when the sender is us:
393+ ;; `echo-message` sends our own DM back, and the buffer it belongs
394+ ;; to is the one we sent it to. `frq.state` names it the same way.
395+ name (if (and (rooms/dm? target) (not= who (str @cells/form-nick)))
396+ who
397+ target)
392398 edit-of (or (irc/tag-value tags "+draft/edit")
393399 (irc/tag-value tags "+edit"))
394400 ;; When the server says the line was written, or now when it says
@@ -418,16 +424,40 @@
418424 (when (= :absent (edit-message! name edit-of who text))
419425 (swap! cells/channels
420426 #(-> (rooms/ensure-channel % name)
421- (update-in [name :messages] conj
422- {:from who
423- :text text
424- :images (images-in text)
425- :did (:account m)
426- :id edit-of
427- :at at
428- :edited? true})
429- settle))))
430- (swap! cells/channels
427+ (update-in [name :messages] conj
428+ {:from who
429+ :text text
430+ :images (images-in text)
431+ :did (:account m)
432+ :id edit-of
433+ :at at
434+ :actor (profile/actor (:account m) who)
435+ :edited? true})
436+ settle)))
437+ ;; And a line that is not a revision is a new one but only the
438+ ;; else arm of that `if`, which is what this was not. The append sat
439+ ;; a paren outside the whole thing and ran for every PRIVMSG, so an
440+ ;; edit was folded into the line it names *and* posted again at the
441+ ;; bottom: the duplicate the reader saw.
442+ ;;
443+ ;; And not one we already hold either. The server hands the same
444+ ;; message over more than once a JOIN replays the backlog, a
445+ ;; CHATHISTORY replays it again, and a line can have arrived live
446+ ;; before either which is the rest of what was doubling rooms
447+ ;; here. `frq.rooms/seen-message?` is that rule, shared with the
448+ ;; desktop, which has always applied it.
449+ (let [msgid (irc/tag-value tags "msgid")
450+ ;; What the server says about a line it has already collapsed:
451+ ;; replay sends the current text and no `+draft/edit` to hint
452+ ;; that it is not the original. This tag is the only trace.
453+ edited? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
454+ (if (rooms/seen-message? (get-in @cells/channels [name :messages])
455+ msgid who text (str @cells/form-nick))
456+ ;; The copy we hold is the older wording and this is the server
457+ ;; saying so. Same message, later word: take the text rather
458+ ;; than the arrival order, and no second row either way.
459+ (when edited? (edit-message! name msgid who text))
460+ (swap! cells/channels
431461 #(-> (rooms/ensure-channel % name)
432462 (update-in [name :messages] conj
433463 ;; The msgid is what everything after a message
@@ -446,25 +476,21 @@
446476 ;; is handle-shaped. A guest has neither and gets
447477 ;; no lookup, which is the honest answer.
448478 :actor (profile/actor (:account m) who)
449- :id (irc/tag-value tags "msgid")
479+ :id msgid
450480 ;; The server canonicalises +draft/reply to
451481 ;; +reply; a client that sent the draft name may
452482 ;; still reach us before it does.
453483 :reply-to (or (irc/tag-value tags "+reply")
454484 (irc/tag-value tags "+draft/reply"))
485+ ;; A line can arrive already rewritten.
486+ :edited? edited?
455487 ;; What is already on it, so a reconnect does not
456488 ;; start every message empty.
457- ;; What the server says about a line it has
458- ;; already collapsed: replay sends the current
459- ;; text and no `+draft/edit` to hint that it is
460- ;; not the original. This tag is the only trace.
461- :edited? (= "1" (irc/tag-value tags
462- "+freeq.at/edited"))
463489 :reactions (reactions/parse-tally
464490 (irc/tag-value tags
465491 "+freeq.at/reactions"))})
466492 (assoc-in [name :last-activity] at)
467- settle)))
493+ settle))))))
468494
469495 ;; A message that is only tags. A reaction is the one this reads:
470496 ;; `+react` puts an emoji on the message `+reply` names, and the
@@ -388,7 +388,13 @@
388 (let [tags (:tags m)388 (let [tags (:tags m)
389 target (first params)389 target (first params)
390 text (last params)390 text (last params)
391- name (if (rooms/dm? target) who target)391+ ;; A DM addressed to us belongs in a buffer named for the sender,
392+ ;; not for our own nick except when the sender is us:
393+ ;; `echo-message` sends our own DM back, and the buffer it belongs
394+ ;; to is the one we sent it to. `frq.state` names it the same way.
395+ name (if (and (rooms/dm? target) (not= who (str @cells/form-nick)))
396+ who
397+ target)
392 edit-of (or (irc/tag-value tags "+draft/edit")398 edit-of (or (irc/tag-value tags "+draft/edit")
393 (irc/tag-value tags "+edit"))399 (irc/tag-value tags "+edit"))
394 ;; When the server says the line was written, or now when it says400 ;; When the server says the line was written, or now when it says
@@ -418,16 +424,40 @@
418 (when (= :absent (edit-message! name edit-of who text))424 (when (= :absent (edit-message! name edit-of who text))
419 (swap! cells/channels425 (swap! cells/channels
420 #(-> (rooms/ensure-channel % name)426 #(-> (rooms/ensure-channel % name)
421- (update-in [name :messages] conj427+ (update-in [name :messages] conj
422- {:from who428+ {:from who
423- :text text429+ :text text
424- :images (images-in text)430+ :images (images-in text)
425- :did (:account m)431+ :did (:account m)
426- :id edit-of432+ :id edit-of
427- :at at433+ :at at
428- :edited? true})434+ :actor (profile/actor (:account m) who)
429- settle))))435+ :edited? true})
430- (swap! cells/channels436+ settle)))
437+ ;; And a line that is not a revision is a new one but only the
438+ ;; else arm of that `if`, which is what this was not. The append sat
439+ ;; a paren outside the whole thing and ran for every PRIVMSG, so an
440+ ;; edit was folded into the line it names *and* posted again at the
441+ ;; bottom: the duplicate the reader saw.
442+ ;;
443+ ;; And not one we already hold either. The server hands the same
444+ ;; message over more than once a JOIN replays the backlog, a
445+ ;; CHATHISTORY replays it again, and a line can have arrived live
446+ ;; before either which is the rest of what was doubling rooms
447+ ;; here. `frq.rooms/seen-message?` is that rule, shared with the
448+ ;; desktop, which has always applied it.
449+ (let [msgid (irc/tag-value tags "msgid")
450+ ;; What the server says about a line it has already collapsed:
451+ ;; replay sends the current text and no `+draft/edit` to hint
452+ ;; that it is not the original. This tag is the only trace.
453+ edited? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
454+ (if (rooms/seen-message? (get-in @cells/channels [name :messages])
455+ msgid who text (str @cells/form-nick))
456+ ;; The copy we hold is the older wording and this is the server
457+ ;; saying so. Same message, later word: take the text rather
458+ ;; than the arrival order, and no second row either way.
459+ (when edited? (edit-message! name msgid who text))
460+ (swap! cells/channels
431 #(-> (rooms/ensure-channel % name)461 #(-> (rooms/ensure-channel % name)
432 (update-in [name :messages] conj462 (update-in [name :messages] conj
433 ;; The msgid is what everything after a message463 ;; The msgid is what everything after a message
@@ -446,25 +476,21 @@
446 ;; is handle-shaped. A guest has neither and gets476 ;; is handle-shaped. A guest has neither and gets
447 ;; no lookup, which is the honest answer.477 ;; no lookup, which is the honest answer.
448 :actor (profile/actor (:account m) who)478 :actor (profile/actor (:account m) who)
449- :id (irc/tag-value tags "msgid")479+ :id msgid
450 ;; The server canonicalises +draft/reply to480 ;; The server canonicalises +draft/reply to
451 ;; +reply; a client that sent the draft name may481 ;; +reply; a client that sent the draft name may
452 ;; still reach us before it does.482 ;; still reach us before it does.
453 :reply-to (or (irc/tag-value tags "+reply")483 :reply-to (or (irc/tag-value tags "+reply")
454 (irc/tag-value tags "+draft/reply"))484 (irc/tag-value tags "+draft/reply"))
485+ ;; A line can arrive already rewritten.
486+ :edited? edited?
455 ;; What is already on it, so a reconnect does not487 ;; What is already on it, so a reconnect does not
456 ;; start every message empty.488 ;; start every message empty.
457- ;; What the server says about a line it has
458- ;; already collapsed: replay sends the current
459- ;; text and no `+draft/edit` to hint that it is
460- ;; not the original. This tag is the only trace.
461- :edited? (= "1" (irc/tag-value tags
462- "+freeq.at/edited"))
463 :reactions (reactions/parse-tally489 :reactions (reactions/parse-tally
464 (irc/tag-value tags490 (irc/tag-value tags
465 "+freeq.at/reactions"))})491 "+freeq.at/reactions"))})
466 (assoc-in [name :last-activity] at)492 (assoc-in [name :last-activity] at)
467- settle)))493+ settle))))))
468 494
469 ;; A message that is only tags. A reaction is the one this reads:495 ;; A message that is only tags. A reaction is the one this reads:
470 ;; `+react` puts an emoji on the message `+reply` names, and the496 ;; `+react` puts an emoji on the message `+reply` names, and the
modified src/frq/state.clj +7 -32
@@ -318,38 +318,13 @@
318318 (fn [m]
319319 (let [m (ensure-channel m channel)
320320 viewing? (and (chat-visible?) (= channel @current))
321- ;; The server hands the same message over more than once: a
322- ;; JOIN replays the backlog, the CHATHISTORY we ask for
323- ;; replays it again, and a line can have arrived live before
324- ;; either. The msgid is the message's identity and it
325- ;; survives every revision, so holding the copy we have is
326- ;; what keeps a rejoin from doubling the buffer — and what
327- ;; keeps a replayed *pre-edit* row from landing under a line
328- ;; already showing the current text.
329- ;; And sometimes it replays a line with no tags at all —
330- ;; no msgid to know it by and no time to place it. That
331- ;; line has no identity, so it arrives new on every rejoin:
332- ;; appended again, timestamped `now` because there is
333- ;; nothing else to timestamp it with, and therefore always
334- ;; newer than the read marker. Left alone it is a buffer
335- ;; that grows a copy per reconnect and a room that cannot
336- ;; be finished reading.
337- ;;
338- ;; What it does have is a sender and words, which for an
339- ;; untagged line is identity enough. The cost is that the
340- ;; same person saying the same thing twice — both times
341- ;; untagged — shows once. Ours and the system's are left
342- ;; out of it: those have no msgid either, and a second
343- ;; "ok" from this client, or a second "alice joined", is
344- ;; a real event rather than a replay.
345- seen? (if id
346- (some #(= id (:id %)) (get-in m [channel :messages]))
347- (and (not= "*" from)
348- (not= from @form-nick)
349- (some #(and (nil? (:id %))
350- (= from (:from %))
351- (= text (:text %)))
352- (get-in m [channel :messages]))))]
321+ ;; And not a second copy of one we already hold: the
322+ ;; server hands the same message over more than once, and
323+ ;; `frq.rooms/seen-message?` is the whole of that rule —
324+ ;; shared, because the Flutter half was appending every
325+ ;; replay this drops.
326+ seen? (rooms/seen-message? (get-in m [channel :messages])
327+ id from text @form-nick)]
353328 (cond
354329 ;; The copy we already hold is the pre-edit one, and this is
355330 ;; the server's collapsed row saying so. Same message, later
@@ -318,38 +318,13 @@
318 (fn [m]318 (fn [m]
319 (let [m (ensure-channel m channel)319 (let [m (ensure-channel m channel)
320 viewing? (and (chat-visible?) (= channel @current))320 viewing? (and (chat-visible?) (= channel @current))
321- ;; The server hands the same message over more than once: a321+ ;; And not a second copy of one we already hold: the
322- ;; JOIN replays the backlog, the CHATHISTORY we ask for322+ ;; server hands the same message over more than once, and
323- ;; replays it again, and a line can have arrived live before323+ ;; `frq.rooms/seen-message?` is the whole of that rule —
324- ;; either. The msgid is the message's identity and it324+ ;; shared, because the Flutter half was appending every
325- ;; survives every revision, so holding the copy we have is325+ ;; replay this drops.
326- ;; what keeps a rejoin from doubling the buffer — and what326+ seen? (rooms/seen-message? (get-in m [channel :messages])
327- ;; keeps a replayed *pre-edit* row from landing under a line327+ id from text @form-nick)]
328- ;; already showing the current text.
329- ;; And sometimes it replays a line with no tags at all —
330- ;; no msgid to know it by and no time to place it. That
331- ;; line has no identity, so it arrives new on every rejoin:
332- ;; appended again, timestamped `now` because there is
333- ;; nothing else to timestamp it with, and therefore always
334- ;; newer than the read marker. Left alone it is a buffer
335- ;; that grows a copy per reconnect and a room that cannot
336- ;; be finished reading.
337- ;;
338- ;; What it does have is a sender and words, which for an
339- ;; untagged line is identity enough. The cost is that the
340- ;; same person saying the same thing twice — both times
341- ;; untagged — shows once. Ours and the system's are left
342- ;; out of it: those have no msgid either, and a second
343- ;; "ok" from this client, or a second "alice joined", is
344- ;; a real event rather than a replay.
345- seen? (if id
346- (some #(= id (:id %)) (get-in m [channel :messages]))
347- (and (not= "*" from)
348- (not= from @form-nick)
349- (some #(and (nil? (:id %))
350- (= from (:from %))
351- (= text (:text %)))
352- (get-in m [channel :messages]))))]
353 (cond328 (cond
354 ;; The copy we already hold is the pre-edit one, and this is329 ;; The copy we already hold is the pre-edit one, and this is
355 ;; the server's collapsed row saying so. Same message, later330 ;; the server's collapsed row saying so. Same message, later