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

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>
nandi committed 2026-09-20T12:38:29-07:00 Browse files
7b4d21d parent: 4ef6dc8
modified nim/src/frq/edits.nim +25 -0
@@ -39,3 +39,28 @@ proc applyEdit*(rooms: var OrderedTable[string, Room],
3939 r.messages[i].editIds.add revision
4040 result = erApplied
4141 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 revision39 r.messages[i].editIds.add revision
40 result = erApplied40 result = erApplied
41 rooms[room] = r41 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]
1515 import std/sets
1616 import frq/[cells, model, rooms, reactions, trace, ircparse, clock,
1717 atproto, handshake, textruns, members, msgsig, profile, store,
18- profilefetch]
18+ profilefetch, edits]
1919 import frq/conn as tr
2020 import frq/oauth as oa
2121
@@ -590,6 +590,31 @@ proc dispatch*(event: JsonNode) =
590590 app.editing = EditTarget()
591591 app.draft = ""
592592
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+
593618 of "image.pick":
594619 # Picking a file and uploading it are both the host's: a file dialog is
595620 # the platform's, and so is a multipart POST. The core says who is asking
@@ -897,6 +922,22 @@ proc drain*() =
897922 if hasTally: m.reactions = parseTally(tally)
898923 note(room, m)
899924
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+
900941 of "JOIN":
901942 if p.params.len >= 1:
902943 let room = p.params[0]
@@ -15,7 +15,7 @@ import std/[json, options, sequtils, strutils, tables]
15 import std/sets15 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 tr19 import frq/conn as tr
20 import frq/oauth as oa20 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 is619 # 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 asking620 # 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 =
533533 editing.children.add hbox(%*{"spacing": 8},
534534 emoji("📝", ""),
535535 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"))
537543 banners.children.add editing
538544
539545 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 editing543 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":
357357 check senderRows[0].props{"wrap"}.getBool()
358358 check not senderRows[0].children.anyIt(
359359 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 @@
99 ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound`
1010 ## reads what went out. No socket at either end.
1111
12-import std/[json, sequtils, strutils, tables, unittest]
12+import std/[json, options, sequtils, strutils, tables, unittest]
1313 import frq/[cells, model, profile, reducer, rooms]
1414 import frq/conn as tr
1515
@@ -332,3 +332,51 @@ suite "sending a picture":
332332 dispatch(%*{"id": "attachment.failed:Upload failed (413)"})
333333 check app.hasError
334334 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 tr14 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.hasError333 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