Unsending a line, from the banner that offers to rewrite it
Edit mode is the moment a reader is already looking at one message and deciding what to do with it, so the other thing they might want belongs there. `destructive`, because freeq leaves a deleted line out of CHATHISTORY and out of a JOIN replay: there is nothing to undo it with. The wire is `+draft/delete=<msgid>` on a TAGMSG -- a mutation with no body, only the id of what should stop existing -- signed like a reaction, because freeq refuses an unsigned mutation from an account. `msgsig.mutationTags` has known the `delete` kind since the port and had never been asked for one; the document it builds is the vector frozen under that name in freeq's `chat-signing-vectors.json`. There was no TAGMSG case on the way in at all, which would have left a deleted line on screen until a reconnect -- our own and, more to the point, an op's. `applyDelete` does not check the nick where `applyEdit` does, and the asymmetry is deliberate: a forged edit puts words in a mouth, a forged delete takes them away, and the server has already refused any delete whose actor was neither author nor op. Reactions ride a TAGMSG too and are still not handled on the way in. They get away with it because the tally comes round again on the next CHATHISTORY. A delete has no second chance, the whole point being that the line stops being sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7b4d21d parent: 4ef6dc8 modified
nim/src/frq/edits.nim +25 -0 | @@ -39,3 +39,28 @@ proc applyEdit*(rooms: var OrderedTable[string, Room], | ||
| 39 | 39 | r.messages[i].editIds.add revision |
| 40 | 40 | result = erApplied |
| 41 | 41 | rooms[room] = r |
| 42 | + | |
| 43 | +proc applyDelete*(rooms: var OrderedTable[string, Room], | |
| 44 | + room, msgid: string): bool = | |
| 45 | + ## Take a line out of the buffer it was said in. | |
| 46 | + ## | |
| 47 | + ## freeq's delete is soft on its side — a `deleted_at` on the row — but | |
| 48 | + ## what it means to a reader is that the line is gone: the server leaves it | |
| 49 | + ## out of CHATHISTORY and out of a JOIN replay, so a buffer that kept it | |
| 50 | + ## would be the only place it still existed, and only until a reconnect. | |
| 51 | + ## | |
| 52 | + ## Unlike `applyEdit` this does not check the nick, and the difference is | |
| 53 | + ## in what the two relays could do. A forged edit puts words in somebody's | |
| 54 | + ## mouth; a forged delete takes words away, and the server has already | |
| 55 | + ## refused any delete whose actor was neither the author nor an op — | |
| 56 | + ## `AUTHOR_MISMATCH`. Checking authorship here would only disagree with it | |
| 57 | + ## in the one case it is right and we cannot see: an op clearing up. The | |
| 58 | + ## line would sit on screen, deleted everywhere else. | |
| 59 | + if room.len == 0 or msgid.len == 0: return false | |
| 60 | + if not rooms.hasKey(room): return false | |
| 61 | + var r = rooms[room] | |
| 62 | + let i = r.indexById(msgid) | |
| 63 | + if i < 0: return false | |
| 64 | + r.messages.delete(i) | |
| 65 | + rooms[room] = r | |
| 66 | + true | |
| @@ -39,3 +39,28 @@ proc applyEdit*(rooms: var OrderedTable[string, Room], | |||
| 39 | r.messages[i].editIds.add revision | 39 | r.messages[i].editIds.add revision |
| 40 | result = erApplied | 40 | result = erApplied |
| 41 | rooms[room] = r | 41 | rooms[room] = r |
| 42 | + | ||
| 43 | +proc applyDelete*(rooms: var OrderedTable[string, Room], | ||
| 44 | + room, msgid: string): bool = | ||
| 45 | + ## Take a line out of the buffer it was said in. | ||
| 46 | + ## | ||
| 47 | + ## freeq's delete is soft on its side — a `deleted_at` on the row — but | ||
| 48 | + ## what it means to a reader is that the line is gone: the server leaves it | ||
| 49 | + ## out of CHATHISTORY and out of a JOIN replay, so a buffer that kept it | ||
| 50 | + ## would be the only place it still existed, and only until a reconnect. | ||
| 51 | + ## | ||
| 52 | + ## Unlike `applyEdit` this does not check the nick, and the difference is | ||
| 53 | + ## in what the two relays could do. A forged edit puts words in somebody's | ||
| 54 | + ## mouth; a forged delete takes words away, and the server has already | ||
| 55 | + ## refused any delete whose actor was neither the author nor an op — | ||
| 56 | + ## `AUTHOR_MISMATCH`. Checking authorship here would only disagree with it | ||
| 57 | + ## in the one case it is right and we cannot see: an op clearing up. The | ||
| 58 | + ## line would sit on screen, deleted everywhere else. | ||
| 59 | + if room.len == 0 or msgid.len == 0: return false | ||
| 60 | + if not rooms.hasKey(room): return false | ||
| 61 | + var r = rooms[room] | ||
| 62 | + let i = r.indexById(msgid) | ||
| 63 | + if i < 0: return false | ||
| 64 | + r.messages.delete(i) | ||
| 65 | + rooms[room] = r | ||
| 66 | + true | ||
modified
nim/src/frq/reducer.nim +42 -1 | @@ -15,7 +15,7 @@ import std/[json, options, sequtils, strutils, tables] | ||
| 15 | 15 | import std/sets |
| 16 | 16 | import frq/[cells, model, rooms, reactions, trace, ircparse, clock, |
| 17 | 17 | atproto, handshake, textruns, members, msgsig, profile, store, |
| 18 | - profilefetch] | |
| 18 | + profilefetch, edits] | |
| 19 | 19 | import frq/conn as tr |
| 20 | 20 | import frq/oauth as oa |
| 21 | 21 | |
| @@ -590,6 +590,31 @@ proc dispatch*(event: JsonNode) = | ||
| 590 | 590 | app.editing = EditTarget() |
| 591 | 591 | app.draft = "" |
| 592 | 592 | |
| 593 | + of "edit.delete": | |
| 594 | + # Unsending, which freeq carries as a TAGMSG rather than a message: | |
| 595 | + # there is no body, only the id of the line that should stop existing. | |
| 596 | + # | |
| 597 | + # Signed like a reaction and for the same reason -- it is a mutation of | |
| 598 | + # somebody's record, and freeq refuses an unsigned one from an account. | |
| 599 | + # `subject` is what is being deleted and there is no emoji, which is | |
| 600 | + # exactly the document `chat-signing-vectors.json` freezes under the | |
| 601 | + # name `delete`. | |
| 602 | + let mid = if arg.len > 0: arg else: app.editing.id | |
| 603 | + if mid.len > 0: | |
| 604 | + var tags = "+draft/delete=" & mid | |
| 605 | + for k, v in mutationTags("delete", app.current, mid, "", | |
| 606 | + peerDid(app.currentRoom, app.formNick), | |
| 607 | + nowMs()): | |
| 608 | + tags.add ";" & k & "=" & v | |
| 609 | + send("@" & tags & " TAGMSG " & app.current) | |
| 610 | + # Taken off the screen now rather than when the echo lands. The server | |
| 611 | + # relays the TAGMSG back and `TAGMSG` below would remove it again, to | |
| 612 | + # no effect -- but a reader who has just pressed Delete should not | |
| 613 | + # watch the line sit there while a round trip happens. | |
| 614 | + discard app.rooms.applyDelete(app.current, mid) | |
| 615 | + app.editing = EditTarget() | |
| 616 | + app.draft = "" | |
| 617 | + | |
| 593 | 618 | of "image.pick": |
| 594 | 619 | # Picking a file and uploading it are both the host's: a file dialog is |
| 595 | 620 | # the platform's, and so is a multipart POST. The core says who is asking |
| @@ -897,6 +922,22 @@ proc drain*() = | ||
| 897 | 922 | if hasTally: m.reactions = parseTally(tally) |
| 898 | 923 | note(room, m) |
| 899 | 924 | |
| 925 | + of "TAGMSG": | |
| 926 | + # A message with tags and nothing said. freeq carries deletes on one, | |
| 927 | + # and relays it to the channel -- so this arrives both for our own | |
| 928 | + # delete and for everybody else's, including an op's. | |
| 929 | + # | |
| 930 | + # Reactions ride a TAGMSG too and are not handled here: they arrive | |
| 931 | + # again as a tally on the next CHATHISTORY, which is the only reason | |
| 932 | + # their absence has gone unnoticed. A delete has no such second | |
| 933 | + # chance, because the whole point is that the line stops being sent. | |
| 934 | + if p.params.len >= 1: | |
| 935 | + let (gone, isDelete) = tagValue(p.tags, "+draft/delete") | |
| 936 | + if isDelete and gone.len > 0: | |
| 937 | + let target = p.params[0] | |
| 938 | + let room = if target.startsWith("#"): target else: nickOf(p.prefix) | |
| 939 | + discard app.rooms.applyDelete(room, gone) | |
| 940 | + | |
| 900 | 941 | of "JOIN": |
| 901 | 942 | if p.params.len >= 1: |
| 902 | 943 | let room = p.params[0] |
| @@ -15,7 +15,7 @@ import std/[json, options, sequtils, strutils, tables] | |||
| 15 | import std/sets | 15 | import std/sets |
| 16 | import frq/[cells, model, rooms, reactions, trace, ircparse, clock, | 16 | import frq/[cells, model, rooms, reactions, trace, ircparse, clock, |
| 17 | atproto, handshake, textruns, members, msgsig, profile, store, | 17 | atproto, handshake, textruns, members, msgsig, profile, store, |
| 18 | - profilefetch] | 18 | + profilefetch, edits] |
| 19 | import frq/conn as tr | 19 | import frq/conn as tr |
| 20 | import frq/oauth as oa | 20 | import frq/oauth as oa |
| 21 | 21 | ||
| @@ -590,6 +590,31 @@ proc dispatch*(event: JsonNode) = | |||
| 590 | app.editing = EditTarget() | 590 | app.editing = EditTarget() |
| 591 | app.draft = "" | 591 | app.draft = "" |
| 592 | 592 | ||
| 593 | + of "edit.delete": | ||
| 594 | + # Unsending, which freeq carries as a TAGMSG rather than a message: | ||
| 595 | + # there is no body, only the id of the line that should stop existing. | ||
| 596 | + # | ||
| 597 | + # Signed like a reaction and for the same reason -- it is a mutation of | ||
| 598 | + # somebody's record, and freeq refuses an unsigned one from an account. | ||
| 599 | + # `subject` is what is being deleted and there is no emoji, which is | ||
| 600 | + # exactly the document `chat-signing-vectors.json` freezes under the | ||
| 601 | + # name `delete`. | ||
| 602 | + let mid = if arg.len > 0: arg else: app.editing.id | ||
| 603 | + if mid.len > 0: | ||
| 604 | + var tags = "+draft/delete=" & mid | ||
| 605 | + for k, v in mutationTags("delete", app.current, mid, "", | ||
| 606 | + peerDid(app.currentRoom, app.formNick), | ||
| 607 | + nowMs()): | ||
| 608 | + tags.add ";" & k & "=" & v | ||
| 609 | + send("@" & tags & " TAGMSG " & app.current) | ||
| 610 | + # Taken off the screen now rather than when the echo lands. The server | ||
| 611 | + # relays the TAGMSG back and `TAGMSG` below would remove it again, to | ||
| 612 | + # no effect -- but a reader who has just pressed Delete should not | ||
| 613 | + # watch the line sit there while a round trip happens. | ||
| 614 | + discard app.rooms.applyDelete(app.current, mid) | ||
| 615 | + app.editing = EditTarget() | ||
| 616 | + app.draft = "" | ||
| 617 | + | ||
| 593 | of "image.pick": | 618 | of "image.pick": |
| 594 | # Picking a file and uploading it are both the host's: a file dialog is | 619 | # Picking a file and uploading it are both the host's: a file dialog is |
| 595 | # the platform's, and so is a multipart POST. The core says who is asking | 620 | # the platform's, and so is a multipart POST. The core says who is asking |
| @@ -897,6 +922,22 @@ proc drain*() = | |||
| 897 | if hasTally: m.reactions = parseTally(tally) | 922 | if hasTally: m.reactions = parseTally(tally) |
| 898 | note(room, m) | 923 | note(room, m) |
| 899 | 924 | ||
| 925 | + of "TAGMSG": | ||
| 926 | + # A message with tags and nothing said. freeq carries deletes on one, | ||
| 927 | + # and relays it to the channel -- so this arrives both for our own | ||
| 928 | + # delete and for everybody else's, including an op's. | ||
| 929 | + # | ||
| 930 | + # Reactions ride a TAGMSG too and are not handled here: they arrive | ||
| 931 | + # again as a tally on the next CHATHISTORY, which is the only reason | ||
| 932 | + # their absence has gone unnoticed. A delete has no such second | ||
| 933 | + # chance, because the whole point is that the line stops being sent. | ||
| 934 | + if p.params.len >= 1: | ||
| 935 | + let (gone, isDelete) = tagValue(p.tags, "+draft/delete") | ||
| 936 | + if isDelete and gone.len > 0: | ||
| 937 | + let target = p.params[0] | ||
| 938 | + let room = if target.startsWith("#"): target else: nickOf(p.prefix) | ||
| 939 | + discard app.rooms.applyDelete(room, gone) | ||
| 940 | + | ||
| 900 | of "JOIN": | 941 | of "JOIN": |
| 901 | if p.params.len >= 1: | 942 | if p.params.len >= 1: |
| 902 | let room = p.params[0] | 943 | let room = p.params[0] |
modified
nim/src/frq/screens/chat.nim +7 -1 | @@ -533,7 +533,13 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 533 | 533 | editing.children.add hbox(%*{"spacing": 8}, |
| 534 | 534 | emoji("📝", ""), |
| 535 | 535 | dimLabel("Editing your message"), |
| 536 | - button("✕", "edit.cancel")) | |
| 536 | + button("✕", "edit.cancel"), | |
| 537 | + # Rewriting and unsending are the same decision taken two ways, and | |
| 538 | + # this is the moment a reader is already looking at the line and | |
| 539 | + # deciding what to do with it. `destructive` because it is: freeq | |
| 540 | + # leaves a deleted line out of history, so there is nothing to undo | |
| 541 | + # it with. | |
| 542 | + button("Delete", "edit.delete", "destructive")) | |
| 537 | 543 | banners.children.add editing |
| 538 | 544 | |
| 539 | 545 | var attach = vbox(%*{"key": "attachment"}) |
| @@ -533,7 +533,13 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 533 | editing.children.add hbox(%*{"spacing": 8}, | 533 | editing.children.add hbox(%*{"spacing": 8}, |
| 534 | emoji("📝", ""), | 534 | emoji("📝", ""), |
| 535 | dimLabel("Editing your message"), | 535 | dimLabel("Editing your message"), |
| 536 | - button("✕", "edit.cancel")) | 536 | + button("✕", "edit.cancel"), |
| 537 | + # Rewriting and unsending are the same decision taken two ways, and | ||
| 538 | + # this is the moment a reader is already looking at the line and | ||
| 539 | + # deciding what to do with it. `destructive` because it is: freeq | ||
| 540 | + # leaves a deleted line out of history, so there is nothing to undo | ||
| 541 | + # it with. | ||
| 542 | + button("Delete", "edit.delete", "destructive")) | ||
| 537 | banners.children.add editing | 543 | banners.children.add editing |
| 538 | 544 | ||
| 539 | var attach = vbox(%*{"key": "attachment"}) | 545 | var attach = vbox(%*{"key": "attachment"}) |
modified
nim/tests/tscreens.nim +28 -0 | @@ -357,3 +357,31 @@ suite "the sender's row": | ||
| 357 | 357 | check senderRows[0].props{"wrap"}.getBool() |
| 358 | 358 | check not senderRows[0].children.anyIt( |
| 359 | 359 | it.tag == "button" and it.props{"expand"}.getBool()) |
| 360 | + | |
| 361 | +suite "the editing banner": | |
| 362 | + setup: | |
| 363 | + var s = initState() | |
| 364 | + s.formNick = "me" | |
| 365 | + s.current = "#test" | |
| 366 | + s.rooms.ensureRoom("#test") | |
| 367 | + var r = s.rooms["#test"] | |
| 368 | + r.messages = @[Message(id: "1", frm: "me", text: "regrettable", at: 1)] | |
| 369 | + s.rooms["#test"] = r | |
| 370 | + | |
| 371 | + test "offers no delete when nothing is being edited": | |
| 372 | + let t = cht.chatScreen(s, true) | |
| 373 | + check "edit.delete" notin t.find("button").mapIt( | |
| 374 | + it.props{"onClick"}.getStr()) | |
| 375 | + | |
| 376 | + test "but does while a line is open for editing": | |
| 377 | + # The moment a reader is already looking at one line and deciding what | |
| 378 | + # to do with it is the moment to offer the other thing they might want. | |
| 379 | + s.editing = EditTarget(has: true, room: "#test", id: "1") | |
| 380 | + let t = cht.chatScreen(s, true) | |
| 381 | + let b = t.find("button").filterIt( | |
| 382 | + it.props{"onClick"}.getStr() == "edit.delete") | |
| 383 | + check b.len == 1 | |
| 384 | + check b[0].props{"label"}.getStr() == "Delete" | |
| 385 | + # Deleting is not undoable — freeq leaves the line out of history — so | |
| 386 | + # it should not look like the cancel beside it. | |
| 387 | + check b[0].props{"kind"}.getStr() == "destructive" | |
| @@ -357,3 +357,31 @@ suite "the sender's row": | |||
| 357 | check senderRows[0].props{"wrap"}.getBool() | 357 | check senderRows[0].props{"wrap"}.getBool() |
| 358 | check not senderRows[0].children.anyIt( | 358 | check not senderRows[0].children.anyIt( |
| 359 | it.tag == "button" and it.props{"expand"}.getBool()) | 359 | it.tag == "button" and it.props{"expand"}.getBool()) |
| 360 | + | ||
| 361 | +suite "the editing banner": | ||
| 362 | + setup: | ||
| 363 | + var s = initState() | ||
| 364 | + s.formNick = "me" | ||
| 365 | + s.current = "#test" | ||
| 366 | + s.rooms.ensureRoom("#test") | ||
| 367 | + var r = s.rooms["#test"] | ||
| 368 | + r.messages = @[Message(id: "1", frm: "me", text: "regrettable", at: 1)] | ||
| 369 | + s.rooms["#test"] = r | ||
| 370 | + | ||
| 371 | + test "offers no delete when nothing is being edited": | ||
| 372 | + let t = cht.chatScreen(s, true) | ||
| 373 | + check "edit.delete" notin t.find("button").mapIt( | ||
| 374 | + it.props{"onClick"}.getStr()) | ||
| 375 | + | ||
| 376 | + test "but does while a line is open for editing": | ||
| 377 | + # The moment a reader is already looking at one line and deciding what | ||
| 378 | + # to do with it is the moment to offer the other thing they might want. | ||
| 379 | + s.editing = EditTarget(has: true, room: "#test", id: "1") | ||
| 380 | + let t = cht.chatScreen(s, true) | ||
| 381 | + let b = t.find("button").filterIt( | ||
| 382 | + it.props{"onClick"}.getStr() == "edit.delete") | ||
| 383 | + check b.len == 1 | ||
| 384 | + check b[0].props{"label"}.getStr() == "Delete" | ||
| 385 | + # Deleting is not undoable — freeq leaves the line out of history — so | ||
| 386 | + # it should not look like the cancel beside it. | ||
| 387 | + check b[0].props{"kind"}.getStr() == "destructive" | ||
modified
nim/tests/tsession.nim +49 -1 | @@ -9,7 +9,7 @@ | ||
| 9 | 9 | ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound` |
| 10 | 10 | ## reads what went out. No socket at either end. |
| 11 | 11 | |
| 12 | -import std/[json, sequtils, strutils, tables, unittest] | |
| 12 | +import std/[json, options, sequtils, strutils, tables, unittest] | |
| 13 | 13 | import frq/[cells, model, profile, reducer, rooms] |
| 14 | 14 | import frq/conn as tr |
| 15 | 15 | |
| @@ -332,3 +332,51 @@ suite "sending a picture": | ||
| 332 | 332 | dispatch(%*{"id": "attachment.failed:Upload failed (413)"}) |
| 333 | 333 | check app.hasError |
| 334 | 334 | check app.error == "Upload failed (413)" |
| 335 | + | |
| 336 | +suite "unsending a line": | |
| 337 | + setup: | |
| 338 | + reset() | |
| 339 | + joined("#freeq") | |
| 340 | + dispatch(%*{"id": "room.open:#freeq"}) | |
| 341 | + say(":alice!a@h PRIVMSG #freeq :@msgid=m1 something regrettable") | |
| 342 | + say("@msgid=m1 :alice!a@h PRIVMSG #freeq :something regrettable") | |
| 343 | + say("@msgid=m2 :bob!b@h PRIVMSG #freeq :and one of bob's") | |
| 344 | + discard sent() | |
| 345 | + | |
| 346 | + test "the delete goes out as a TAGMSG naming the id": | |
| 347 | + # Not a PRIVMSG: there is no body, only the id of the line that should | |
| 348 | + # stop existing. `+draft/delete` is freeq's tag for it and the wrong one | |
| 349 | + # would be relayed to nobody and refused quietly. | |
| 350 | + dispatch(%*{"id": "edit.start:m1"}) | |
| 351 | + dispatch(%*{"id": "edit.delete"}) | |
| 352 | + let out1 = sent() | |
| 353 | + check out1.len == 1 | |
| 354 | + check "+draft/delete=m1" in out1[0] | |
| 355 | + check " TAGMSG #freeq" in out1[0] | |
| 356 | + | |
| 357 | + test "and the line goes, without waiting for the echo": | |
| 358 | + dispatch(%*{"id": "edit.start:m1"}) | |
| 359 | + dispatch(%*{"id": "edit.delete"}) | |
| 360 | + check app.rooms["#freeq"].messageById("m1").isNone | |
| 361 | + check app.rooms["#freeq"].messageById("m2").isSome | |
| 362 | + | |
| 363 | + test "and edit mode is over": | |
| 364 | + dispatch(%*{"id": "edit.start:m1"}) | |
| 365 | + check app.editing.has | |
| 366 | + check app.draft.len > 0 | |
| 367 | + dispatch(%*{"id": "edit.delete"}) | |
| 368 | + check not app.editing.has | |
| 369 | + check app.draft == "" | |
| 370 | + | |
| 371 | + test "somebody else's delete takes their line too": | |
| 372 | + # The relayed TAGMSG, which is how every other client hears of this — | |
| 373 | + # and how an op clearing up reaches us. There was no TAGMSG case at all, | |
| 374 | + # so the line stayed on screen until a reconnect dropped it. | |
| 375 | + say("@msgid=x :bob!b@h TAGMSG #freeq") # no delete tag: nothing happens | |
| 376 | + check app.rooms["#freeq"].messageById("m2").isSome | |
| 377 | + say("@+draft/delete=m2;msgid=x :bob!b@h TAGMSG #freeq") | |
| 378 | + check app.rooms["#freeq"].messageById("m2").isNone | |
| 379 | + | |
| 380 | + test "a delete for a line we never had is not an error": | |
| 381 | + say("@+draft/delete=nope :bob!b@h TAGMSG #freeq") | |
| 382 | + check not app.hasError | |
| @@ -9,7 +9,7 @@ | |||
| 9 | ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound` | 9 | ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound` |
| 10 | ## reads what went out. No socket at either end. | 10 | ## reads what went out. No socket at either end. |
| 11 | 11 | ||
| 12 | -import std/[json, sequtils, strutils, tables, unittest] | 12 | +import std/[json, options, sequtils, strutils, tables, unittest] |
| 13 | import frq/[cells, model, profile, reducer, rooms] | 13 | import frq/[cells, model, profile, reducer, rooms] |
| 14 | import frq/conn as tr | 14 | import frq/conn as tr |
| 15 | 15 | ||
| @@ -332,3 +332,51 @@ suite "sending a picture": | |||
| 332 | dispatch(%*{"id": "attachment.failed:Upload failed (413)"}) | 332 | dispatch(%*{"id": "attachment.failed:Upload failed (413)"}) |
| 333 | check app.hasError | 333 | check app.hasError |
| 334 | check app.error == "Upload failed (413)" | 334 | check app.error == "Upload failed (413)" |
| 335 | + | ||
| 336 | +suite "unsending a line": | ||
| 337 | + setup: | ||
| 338 | + reset() | ||
| 339 | + joined("#freeq") | ||
| 340 | + dispatch(%*{"id": "room.open:#freeq"}) | ||
| 341 | + say(":alice!a@h PRIVMSG #freeq :@msgid=m1 something regrettable") | ||
| 342 | + say("@msgid=m1 :alice!a@h PRIVMSG #freeq :something regrettable") | ||
| 343 | + say("@msgid=m2 :bob!b@h PRIVMSG #freeq :and one of bob's") | ||
| 344 | + discard sent() | ||
| 345 | + | ||
| 346 | + test "the delete goes out as a TAGMSG naming the id": | ||
| 347 | + # Not a PRIVMSG: there is no body, only the id of the line that should | ||
| 348 | + # stop existing. `+draft/delete` is freeq's tag for it and the wrong one | ||
| 349 | + # would be relayed to nobody and refused quietly. | ||
| 350 | + dispatch(%*{"id": "edit.start:m1"}) | ||
| 351 | + dispatch(%*{"id": "edit.delete"}) | ||
| 352 | + let out1 = sent() | ||
| 353 | + check out1.len == 1 | ||
| 354 | + check "+draft/delete=m1" in out1[0] | ||
| 355 | + check " TAGMSG #freeq" in out1[0] | ||
| 356 | + | ||
| 357 | + test "and the line goes, without waiting for the echo": | ||
| 358 | + dispatch(%*{"id": "edit.start:m1"}) | ||
| 359 | + dispatch(%*{"id": "edit.delete"}) | ||
| 360 | + check app.rooms["#freeq"].messageById("m1").isNone | ||
| 361 | + check app.rooms["#freeq"].messageById("m2").isSome | ||
| 362 | + | ||
| 363 | + test "and edit mode is over": | ||
| 364 | + dispatch(%*{"id": "edit.start:m1"}) | ||
| 365 | + check app.editing.has | ||
| 366 | + check app.draft.len > 0 | ||
| 367 | + dispatch(%*{"id": "edit.delete"}) | ||
| 368 | + check not app.editing.has | ||
| 369 | + check app.draft == "" | ||
| 370 | + | ||
| 371 | + test "somebody else's delete takes their line too": | ||
| 372 | + # The relayed TAGMSG, which is how every other client hears of this — | ||
| 373 | + # and how an op clearing up reaches us. There was no TAGMSG case at all, | ||
| 374 | + # so the line stayed on screen until a reconnect dropped it. | ||
| 375 | + say("@msgid=x :bob!b@h TAGMSG #freeq") # no delete tag: nothing happens | ||
| 376 | + check app.rooms["#freeq"].messageById("m2").isSome | ||
| 377 | + say("@+draft/delete=m2;msgid=x :bob!b@h TAGMSG #freeq") | ||
| 378 | + check app.rooms["#freeq"].messageById("m2").isNone | ||
| 379 | + | ||
| 380 | + test "a delete for a line we never had is not an error": | ||
| 381 | + say("@+draft/delete=nope :bob!b@h TAGMSG #freeq") | ||
| 382 | + check not app.hasError | ||