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>
8229078 parent: f2e12af modified
common/frq/rooms.cljc +31 -0 | @@ -71,6 +71,37 @@ | ||
| 71 | 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 | 105 | ;; How many lines the overview holds in all. |
| 75 | 106 | (def overview-limit 100) |
| 76 | 107 | |
| @@ -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 @@ | ||
| 388 | 388 | (let [tags (:tags m) |
| 389 | 389 | target (first params) |
| 390 | 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 | 398 | edit-of (or (irc/tag-value tags "+draft/edit") |
| 393 | 399 | (irc/tag-value tags "+edit")) |
| 394 | 400 | ;; When the server says the line was written, or now when it says |
| @@ -418,16 +424,40 @@ | ||
| 418 | 424 | (when (= :absent (edit-message! name edit-of who text)) |
| 419 | 425 | (swap! cells/channels |
| 420 | 426 | #(-> (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 | |
| 431 | 461 | #(-> (rooms/ensure-channel % name) |
| 432 | 462 | (update-in [name :messages] conj |
| 433 | 463 | ;; The msgid is what everything after a message |
| @@ -446,25 +476,21 @@ | ||
| 446 | 476 | ;; is handle-shaped. A guest has neither and gets |
| 447 | 477 | ;; no lookup, which is the honest answer. |
| 448 | 478 | :actor (profile/actor (:account m) who) |
| 449 | - :id (irc/tag-value tags "msgid") | |
| 479 | + :id msgid | |
| 450 | 480 | ;; The server canonicalises +draft/reply to |
| 451 | 481 | ;; +reply; a client that sent the draft name may |
| 452 | 482 | ;; still reach us before it does. |
| 453 | 483 | :reply-to (or (irc/tag-value tags "+reply") |
| 454 | 484 | (irc/tag-value tags "+draft/reply")) |
| 485 | + ;; A line can arrive already rewritten. | |
| 486 | + :edited? edited? | |
| 455 | 487 | ;; What is already on it, so a reconnect does not |
| 456 | 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 | 489 | :reactions (reactions/parse-tally |
| 464 | 490 | (irc/tag-value tags |
| 465 | 491 | "+freeq.at/reactions"))}) |
| 466 | 492 | (assoc-in [name :last-activity] at) |
| 467 | - settle))) | |
| 493 | + settle)))))) | |
| 468 | 494 | |
| 469 | 495 | ;; A message that is only tags. A reaction is the one this reads: |
| 470 | 496 | ;; `+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 says | 400 | ;; 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/channels | 425 | (swap! cells/channels |
| 420 | #(-> (rooms/ensure-channel % name) | 426 | #(-> (rooms/ensure-channel % name) |
| 421 | - (update-in [name :messages] conj | 427 | + (update-in [name :messages] conj |
| 422 | - {:from who | 428 | + {:from who |
| 423 | - :text text | 429 | + :text text |
| 424 | - :images (images-in text) | 430 | + :images (images-in text) |
| 425 | - :did (:account m) | 431 | + :did (:account m) |
| 426 | - :id edit-of | 432 | + :id edit-of |
| 427 | - :at at | 433 | + :at at |
| 428 | - :edited? true}) | 434 | + :actor (profile/actor (:account m) who) |
| 429 | - settle)))) | 435 | + :edited? true}) |
| 430 | - (swap! cells/channels | 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 | ||
| 431 | #(-> (rooms/ensure-channel % name) | 461 | #(-> (rooms/ensure-channel % name) |
| 432 | (update-in [name :messages] conj | 462 | (update-in [name :messages] conj |
| 433 | ;; The msgid is what everything after a message | 463 | ;; The msgid is what everything after a message |
| @@ -446,25 +476,21 @@ | |||
| 446 | ;; is handle-shaped. A guest has neither and gets | 476 | ;; 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 to | 480 | ;; The server canonicalises +draft/reply to |
| 451 | ;; +reply; a client that sent the draft name may | 481 | ;; +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 not | 487 | ;; 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-tally | 489 | :reactions (reactions/parse-tally |
| 464 | (irc/tag-value tags | 490 | (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 the | 496 | ;; `+react` puts an emoji on the message `+reply` names, and the |
modified
src/frq/state.clj +7 -32 | @@ -318,38 +318,13 @@ | ||
| 318 | 318 | (fn [m] |
| 319 | 319 | (let [m (ensure-channel m channel) |
| 320 | 320 | 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)] | |
| 353 | 328 | (cond |
| 354 | 329 | ;; The copy we already hold is the pre-edit one, and this is |
| 355 | 330 | ;; 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: a | 321 | + ;; And not a second copy of one we already hold: the |
| 322 | - ;; JOIN replays the backlog, the CHATHISTORY we ask for | 322 | + ;; server hands the same message over more than once, and |
| 323 | - ;; replays it again, and a line can have arrived live before | 323 | + ;; `frq.rooms/seen-message?` is the whole of that rule — |
| 324 | - ;; either. The msgid is the message's identity and it | 324 | + ;; shared, because the Flutter half was appending every |
| 325 | - ;; survives every revision, so holding the copy we have is | 325 | + ;; replay this drops. |
| 326 | - ;; what keeps a rejoin from doubling the buffer — and what | 326 | + seen? (rooms/seen-message? (get-in m [channel :messages]) |
| 327 | - ;; keeps a replayed *pre-edit* row from landing under a line | 327 | + 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 | (cond | 328 | (cond |
| 354 | ;; The copy we already hold is the pre-edit one, and this is | 329 | ;; The copy we already hold is the pre-edit one, and this is |
| 355 | ;; the server's collapsed row saying so. Same message, later | 330 | ;; the server's collapsed row saying so. Same message, later |