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

Show a message as it was last written, not as it was first sent

An edit arrives as a PRIVMSG naming the message it replaces, and a
message the server has already collapsed arrives with a tag saying so.
Both fold into the line that is on screen, under the id it was born
with — the id its reactions, replies and pins hang off.

The pencil beside reply and react is how one is made. It sends what
every other mutation sends: the editor's own signature over what the
revision says, without which the server keeps the original and says
nothing anybody was listening for. FAIL is listened for now.
nandi committed 2026-08-31T19:58:30-07:00 Browse files
b52a754 parent: 43cc3f0
modified src/frq/app.jolt +40 -14
@@ -317,17 +317,30 @@
317317 press to react and what appears once you have look like one family.
318318
319319 A row laid out from the right lays its first child furthest right, so
320- reacting comes first in the source and this reads ↩️ then 🙂 on screen."
320+ reacting comes first in the source and this reads ✏️ then ↩️ then 🙂 on
321+ screen — where there is a pencil at all. Only our own lines carry one: the
322+ server refuses an edit of somebody else's, and a chip that always fails is a
323+ chip that lies about what can be done.
324+
325+ The pencil is a chip beside the other two rather than a box holding one: a
326+ wrapper is laid out as a child in its own right, which put the pencil on the
327+ row at a remove from the pair it belongs with. It keys itself, so the line
328+ that has no pencil is a row of two chips and not a row with a hole in it."
321329 [channel m]
322- [:hbox {:key :actions :align :end :spacing 4}
323- [:reaction {:key :react
324- :emoji "🙂"
325- :size pill-size
326- :on-click #(s/open-picker! channel m)}]
327- [:reaction {:key :reply
328- :emoji "↩️"
329- :size pill-size
330- :on-click #(s/reply-to! m)}]])
330+ (into [:hbox {:key :actions :align :end :spacing 4}
331+ [:reaction {:key :react
332+ :emoji "🙂"
333+ :size pill-size
334+ :on-click #(s/open-picker! channel m)}]
335+ [:reaction {:key :reply
336+ :emoji "↩️"
337+ :size pill-size
338+ :on-click #(s/reply-to! m)}]]
339+ (when (s/mine? m)
340+ [[:reaction {:key :edit
341+ :emoji "✏️"
342+ :size pill-size
343+ :on-click #(s/start-edit! channel m)}]])))
331344
332345 (defn- reactor-card
333346 "Who is on a reaction, beside the pointer resting on it.
@@ -556,6 +569,10 @@
556569 [:label {:label (:from m)}]
557570 (when-let [at (:at m)]
558571 [:dim-label {:label (clock/clock-time at)}])
572+ ;; Beside the clock, because it is the same kind of fact about the
573+ ;; line: what is on screen is not what was first said.
574+ (when (:edited? m)
575+ [:dim-label {:label "(edited)"}])
559576 ;; And the two things you can do to the message, at the far end of
560577 ;; its heading: a nested row laid out from the right takes what is
561578 ;; left of the width and puts the chips against the edge of it, so
@@ -1050,6 +1067,14 @@
10501067 [:hbox {:spacing 8}
10511068 [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}]
10521069 [:button {:label "✕" :on-click s/cancel-reply!}]])]
1070+ ;; And, in the same place, that the box holds a rewrite rather than
1071+ ;; something new: the text in it is a copy of a line already on screen,
1072+ ;; and without this Send would look like it was about to say it twice.
1073+ [:vbox {:key :editing}
1074+ (when @s/editing
1075+ [:hbox {:spacing 8}
1076+ [:dim-label {:label "✏️ Editing your message"}]
1077+ [:button {:label "✕" :on-click s/cancel-edit!}]])]
10531078 ;; The pasted picture, above the line it will go out with. Shown rather
10541079 ;; than written into the draft: what is being sent is a picture, and a URL
10551080 ;; dropped into the entry would be an unreadable line of text sitting in
@@ -1071,10 +1096,11 @@
10711096 ;; window and centring costs nothing.
10721097 ;; Equal air above and below, so the row sits on the middle of the strip
10731098 ;; between the separator and the bottom edge rather than flat against it.
1074- ;; Above it is three of the column's gaps: one after the separator and one
1075- ;; for each of the empty wrappers — the reply banner and the attachment,
1076- ;; which cost a gap apiece whether or not they have anything in them. This
1077- ;; margin plus the window's own is what answers them underneath.
1099+ ;; Above it is four of the column's gaps: one after the separator and one
1100+ ;; for each of the empty wrappers — the reply banner, the edit banner and
1101+ ;; the attachment, which cost a gap apiece whether or not they have
1102+ ;; anything in them. This margin plus the window's own is what answers
1103+ ;; them underneath.
10781104 [:hbox {:spacing 8 :align :center :margin-bottom 12}
10791105 ;; narrow enough that Send keeps its place on a phone-width row
10801106 ;; A picture is pasted where everything else is typed: Ctrl+V. The field
@@ -317,17 +317,30 @@
317 press to react and what appears once you have look like one family.317 press to react and what appears once you have look like one family.
318 318
319 A row laid out from the right lays its first child furthest right, so319 A row laid out from the right lays its first child furthest right, so
320- reacting comes first in the source and this reads ↩️ then 🙂 on screen."320+ reacting comes first in the source and this reads ✏️ then ↩️ then 🙂 on
321+ screen — where there is a pencil at all. Only our own lines carry one: the
322+ server refuses an edit of somebody else's, and a chip that always fails is a
323+ chip that lies about what can be done.
324+
325+ The pencil is a chip beside the other two rather than a box holding one: a
326+ wrapper is laid out as a child in its own right, which put the pencil on the
327+ row at a remove from the pair it belongs with. It keys itself, so the line
328+ that has no pencil is a row of two chips and not a row with a hole in it."
321 [channel m]329 [channel m]
322- [:hbox {:key :actions :align :end :spacing 4}330+ (into [:hbox {:key :actions :align :end :spacing 4}
323- [:reaction {:key :react331+ [:reaction {:key :react
324- :emoji "🙂"332+ :emoji "🙂"
325- :size pill-size333+ :size pill-size
326- :on-click #(s/open-picker! channel m)}]334+ :on-click #(s/open-picker! channel m)}]
327- [:reaction {:key :reply335+ [:reaction {:key :reply
328- :emoji "↩️"336+ :emoji "↩️"
329- :size pill-size337+ :size pill-size
330- :on-click #(s/reply-to! m)}]])338+ :on-click #(s/reply-to! m)}]]
339+ (when (s/mine? m)
340+ [[:reaction {:key :edit
341+ :emoji "✏️"
342+ :size pill-size
343+ :on-click #(s/start-edit! channel m)}]])))
331 344
332 (defn- reactor-card345 (defn- reactor-card
333 "Who is on a reaction, beside the pointer resting on it.346 "Who is on a reaction, beside the pointer resting on it.
@@ -556,6 +569,10 @@
556 [:label {:label (:from m)}]569 [:label {:label (:from m)}]
557 (when-let [at (:at m)]570 (when-let [at (:at m)]
558 [:dim-label {:label (clock/clock-time at)}])571 [:dim-label {:label (clock/clock-time at)}])
572+ ;; Beside the clock, because it is the same kind of fact about the
573+ ;; line: what is on screen is not what was first said.
574+ (when (:edited? m)
575+ [:dim-label {:label "(edited)"}])
559 ;; And the two things you can do to the message, at the far end of576 ;; And the two things you can do to the message, at the far end of
560 ;; its heading: a nested row laid out from the right takes what is577 ;; its heading: a nested row laid out from the right takes what is
561 ;; left of the width and puts the chips against the edge of it, so578 ;; left of the width and puts the chips against the edge of it, so
@@ -1050,6 +1067,14 @@
1050 [:hbox {:spacing 8}1067 [:hbox {:spacing 8}
1051 [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}]1068 [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}]
1052 [:button {:label "✕" :on-click s/cancel-reply!}]])]1069 [:button {:label "✕" :on-click s/cancel-reply!}]])]
1070+ ;; And, in the same place, that the box holds a rewrite rather than
1071+ ;; something new: the text in it is a copy of a line already on screen,
1072+ ;; and without this Send would look like it was about to say it twice.
1073+ [:vbox {:key :editing}
1074+ (when @s/editing
1075+ [:hbox {:spacing 8}
1076+ [:dim-label {:label "✏️ Editing your message"}]
1077+ [:button {:label "✕" :on-click s/cancel-edit!}]])]
1053 ;; The pasted picture, above the line it will go out with. Shown rather1078 ;; The pasted picture, above the line it will go out with. Shown rather
1054 ;; than written into the draft: what is being sent is a picture, and a URL1079 ;; than written into the draft: what is being sent is a picture, and a URL
1055 ;; dropped into the entry would be an unreadable line of text sitting in1080 ;; dropped into the entry would be an unreadable line of text sitting in
@@ -1071,10 +1096,11 @@
1071 ;; window and centring costs nothing.1096 ;; window and centring costs nothing.
1072 ;; Equal air above and below, so the row sits on the middle of the strip1097 ;; Equal air above and below, so the row sits on the middle of the strip
1073 ;; between the separator and the bottom edge rather than flat against it.1098 ;; between the separator and the bottom edge rather than flat against it.
1074- ;; Above it is three of the column's gaps: one after the separator and one1099+ ;; Above it is four of the column's gaps: one after the separator and one
1075- ;; for each of the empty wrappers — the reply banner and the attachment,1100+ ;; for each of the empty wrappers — the reply banner, the edit banner and
1076- ;; which cost a gap apiece whether or not they have anything in them. This1101+ ;; the attachment, which cost a gap apiece whether or not they have
1077- ;; margin plus the window's own is what answers them underneath.1102+ ;; anything in them. This margin plus the window's own is what answers
1103+ ;; them underneath.
1078 [:hbox {:spacing 8 :align :center :margin-bottom 12}1104 [:hbox {:spacing 8 :align :center :margin-bottom 12}
1079 ;; narrow enough that Send keeps its place on a phone-width row1105 ;; narrow enough that Send keeps its place on a phone-width row
1080 ;; A picture is pasted where everything else is typed: Ctrl+V. The field1106 ;; A picture is pasted where everything else is typed: Ctrl+V. The field
modified src/frq/irc.jolt +19 -0
@@ -386,6 +386,25 @@
386386 (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " "))
387387 "PRIVMSG " target " :" text))))
388388
389+(defn edit!
390+ "Rewrite something already said. The `+draft/edit` tag names the message
391+ being replaced, and what follows is its new text — the server checks that
392+ the message was ours, files the revision under the original's id, and sends
393+ the new line on to the channel for every client to fold in.
394+
395+ A PRIVMSG rather than a TAGMSG, because an edit carries a body — and signed,
396+ like every other thing that changes a record already written: from an account
397+ the server answers an unsigned one with
398+ `FAIL EDIT SIGNATURE_REQUIRED` and the message stays as it was. `peer-did` is
399+ who a DM is with, which is half of the name a DM signature is made under."
400+ ([conn target msgid text] (edit! conn target msgid text nil))
401+ ([conn target msgid text peer-did]
402+ (let [tags (assoc (msgsig/edit-tags target msgid text nil peer-did)
403+ "+draft/edit" msgid)
404+ pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))]
405+ (send-line! conn (str "@" (str/join ";" pairs)
406+ " PRIVMSG " target " :" text)))))
407+
389408 (defn tagmsg!
390409 "A message that is only tags: how freeq carries a reaction, a typing hint or
391410 a delete. `tags` is a map of name to value, sent in no particular order — the
@@ -386,6 +386,25 @@
386 (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " "))386 (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " "))
387 "PRIVMSG " target " :" text))))387 "PRIVMSG " target " :" text))))
388 388
389+(defn edit!
390+ "Rewrite something already said. The `+draft/edit` tag names the message
391+ being replaced, and what follows is its new text — the server checks that
392+ the message was ours, files the revision under the original's id, and sends
393+ the new line on to the channel for every client to fold in.
394+
395+ A PRIVMSG rather than a TAGMSG, because an edit carries a body — and signed,
396+ like every other thing that changes a record already written: from an account
397+ the server answers an unsigned one with
398+ `FAIL EDIT SIGNATURE_REQUIRED` and the message stays as it was. `peer-did` is
399+ who a DM is with, which is half of the name a DM signature is made under."
400+ ([conn target msgid text] (edit! conn target msgid text nil))
401+ ([conn target msgid text peer-did]
402+ (let [tags (assoc (msgsig/edit-tags target msgid text nil peer-did)
403+ "+draft/edit" msgid)
404+ pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))]
405+ (send-line! conn (str "@" (str/join ";" pairs)
406+ " PRIVMSG " target " :" text)))))
407+
389 (defn tagmsg!408 (defn tagmsg!
390 "A message that is only tags: how freeq carries a reaction, a typing hint or409 "A message that is only tags: how freeq carries a reaction, a typing hint or
391 a delete. `tags` is a map of name to value, sent in no particular order — the410 a delete. `tags` is a map of name to value, sent in no particular order — the
modified src/frq/msgsig.jolt +43 -0
@@ -203,6 +203,49 @@
203203 (str "dm:" peer-did "," our-did))
204204 :else nil))
205205
206+(def ^:private hex-digits "0123456789abcdef")
207+
208+(defn- body-hash
209+ "How a document names the text it covers: `sha256:` and the hash in lower-case
210+ hex. The signature is over the hash rather than the words, so a message of any
211+ length signs the same amount."
212+ [text]
213+ (str "sha256:"
214+ (apply str
215+ (for [b (sha256 (.getBytes (or text "") "UTF-8"))
216+ :let [v (bit-and (int b) 0xff)]
217+ c [(nth hex-digits (bit-shift-right v 4))
218+ (nth hex-digits (bit-and v 0xf))]]
219+ c))))
220+
221+(defn edit-tags
222+ "The tags that make a rewrite acceptable: the id this edit is minted under
223+ and a signature over what it says.
224+
225+ An edit is a *message* document rather than a mutation one — it carries a
226+ body — so the fields are the message's own: who, which id, where, the hash
227+ of the new text, and `edit` naming the message being replaced. The server
228+ rebuilds this from what arrives and refuses an edit whose signature does not
229+ verify, or (from an account) one that carries none at all.
230+
231+ Empty when this connection has no key: a guest signs nothing, and the server
232+ asks a guest for nothing."
233+ [target root-msgid text reply-to peer-did]
234+ (let [{:keys [did]} @signer]
235+ (or (when did
236+ (when-let [venue (signing-target target did peer-did)]
237+ (let [id (event-id)
238+ fields (cond-> {"body" (body-hash text)
239+ "edit" root-msgid
240+ "from" did
241+ "msgid" id
242+ "target" venue}
243+ (seq (or reply-to "")) (assoc "reply" reply-to))]
244+ (when-let [sig (sign-bytes (canonical fields))]
245+ {"+freeq.at/eventid" id
246+ "+freeq.at/sig" sig}))))
247+ {})))
248+
206249 (defn mutation-tags
207250 "The two tags that make a mutation acceptable: the event id and the signature
208251 over it. Empty when this connection has no key — a guest signs nothing, and
@@ -203,6 +203,49 @@
203 (str "dm:" peer-did "," our-did))203 (str "dm:" peer-did "," our-did))
204 :else nil))204 :else nil))
205 205
206+(def ^:private hex-digits "0123456789abcdef")
207+
208+(defn- body-hash
209+ "How a document names the text it covers: `sha256:` and the hash in lower-case
210+ hex. The signature is over the hash rather than the words, so a message of any
211+ length signs the same amount."
212+ [text]
213+ (str "sha256:"
214+ (apply str
215+ (for [b (sha256 (.getBytes (or text "") "UTF-8"))
216+ :let [v (bit-and (int b) 0xff)]
217+ c [(nth hex-digits (bit-shift-right v 4))
218+ (nth hex-digits (bit-and v 0xf))]]
219+ c))))
220+
221+(defn edit-tags
222+ "The tags that make a rewrite acceptable: the id this edit is minted under
223+ and a signature over what it says.
224+
225+ An edit is a *message* document rather than a mutation one — it carries a
226+ body — so the fields are the message's own: who, which id, where, the hash
227+ of the new text, and `edit` naming the message being replaced. The server
228+ rebuilds this from what arrives and refuses an edit whose signature does not
229+ verify, or (from an account) one that carries none at all.
230+
231+ Empty when this connection has no key: a guest signs nothing, and the server
232+ asks a guest for nothing."
233+ [target root-msgid text reply-to peer-did]
234+ (let [{:keys [did]} @signer]
235+ (or (when did
236+ (when-let [venue (signing-target target did peer-did)]
237+ (let [id (event-id)
238+ fields (cond-> {"body" (body-hash text)
239+ "edit" root-msgid
240+ "from" did
241+ "msgid" id
242+ "target" venue}
243+ (seq (or reply-to "")) (assoc "reply" reply-to))]
244+ (when-let [sig (sign-bytes (canonical fields))]
245+ {"+freeq.at/eventid" id
246+ "+freeq.at/sig" sig}))))
247+ {})))
248+
206 (defn mutation-tags249 (defn mutation-tags
207 "The two tags that make a mutation acceptable: the event id and the signature250 "The two tags that make a mutation acceptable: the event id and the signature
208 over it. Empty when this connection has no key — a guest signs nothing, and251 over it. Empty when this connection has no key — a guest signs nothing, and
modified src/frq/state.jolt +163 -24
@@ -75,6 +75,11 @@
7575 (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
7676 (defn cancel-reply! [] (reset! replying-to nil))
7777
78+;; The message the draft is a rewrite of, as `{:channel :id}`, or nil when the
79+;; box is being used for something new. Only the id is kept: what is being
80+;; rewritten is in the box, and the line on screen is the thing it will replace.
81+(defonce editing (atom nil))
82+
7883 ;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
7984 ;; when it is closed. The picker is a panel over the compose bar rather than a
8085 ;; screen: what is being reacted to has to stay in sight.
@@ -182,7 +187,7 @@
182187 and `:reply-to` is the one it answers. `:reactions` is what people have put
183188 on it already, which on a replayed backlog the server hands over in full."
184189 ([channel from text] (push-message! channel from text {}))
185- ([channel from text {:keys [at did id reply-to reactions]}]
190+ ([channel from text {:keys [at did id reply-to reactions edited?]}]
186191 (let [at (or at (clock/now-ms))
187192 who (avatars/actor did from)]
188193 (doseq [url (media/image-urls text)]
@@ -203,6 +208,11 @@
203208 ;; `:id` is what a reply points at, and
204209 ;; `:reply-to` is what this one points at.
205210 :id id :reply-to reply-to
211+ ;; The sender has since rewritten this line.
212+ ;; Replay says so with a tag rather than by
213+ ;; sending the revision, so a message can
214+ ;; arrive already edited.
215+ :edited? (boolean edited?)
206216 ;; emoji -> the nicks who put it there
207217 :reactions (or reactions {})})
208218 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
@@ -220,6 +230,11 @@
220230 ;; A picker belongs to the message it was opened on; carrying it into another
221231 ;; buffer would offer to react to something that is no longer on screen.
222232 (reset! reacting nil)
233+ ;; And an edit belongs to a line in the buffer being left: carried across, the
234+ ;; next Send would rewrite a message nobody in this room can see.
235+ (when (and @editing (not= name (:channel @editing)))
236+ (reset! editing nil)
237+ (reset! draft ""))
223238 (swap! channels #(-> (ensure-channel % name)
224239 (assoc-in [name :unread] 0)
225240 (assoc-in [name :accessed] (swap! access-tick inc))))
@@ -281,6 +296,52 @@
281296 msgs))
282297 m)))))
283298
299+(defn edit-message!
300+ "Rewrite a message in place, and say so. `msgid` names the line as it was
301+ first sent: a message keeps the id it was born with across every revision,
302+ which is what keeps its reactions, replies and pins attached to it.
303+
304+ Only the sender may rewrite their own line, so an edit whose nick is not the
305+ one on the message is dropped the server checks authorship too, and a
306+ client that believed the wire alone would let a hostile relay put words in
307+ somebody's mouth.
308+
309+ Answers what became of it: `:applied`, `:refused` for one that was not the
310+ sender's to make, or `:absent` when no line here has that id — an edit of
311+ something older than the backlog we asked for, which is the one case the
312+ caller shows as a line of its own rather than losing what it says."
313+ [channel msgid from text]
314+ (if-not (and channel msgid)
315+ :absent
316+ (let [found? (atom nil)]
317+ (swap! channels
318+ (fn [m]
319+ (if-let [msgs (get-in m [channel :messages])]
320+ (assoc-in m [channel :messages]
321+ (mapv (fn [msg]
322+ (if (= msgid (:id msg))
323+ (if (= (str/lower-case (or (:from msg) ""))
324+ (str/lower-case (or from "")))
325+ (do (reset! found? :applied)
326+ (assoc msg
327+ :text text
328+ :images (media/image-urls text)
329+ :edited? true))
330+ ;; Somebody else's line. The server
331+ ;; refuses this too, so it is either a
332+ ;; peer talking to us directly or one
333+ ;; lying and neither gets to put words
334+ ;; under a name that is not theirs.
335+ (do (reset! found? :refused) msg))
336+ msg))
337+ msgs))
338+ m)))
339+ ;; The revision may link a picture the original did not.
340+ (when (= :applied @found?)
341+ (doseq [url (media/image-urls text)]
342+ (media/fetch! url #(swap! media-tick inc))))
343+ (or @found? :absent))))
344+
284345 ;; --- who is in the room ------------------------------------------------------
285346 ;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is
286347 ;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK
@@ -489,18 +550,41 @@
489550 buffer (cond
490551 (str/starts-with? (or target "") "#") target
491552 (= from @form-nick) target
492- :else from)]
493- (push-message! buffer from text
494- {:at at
495- :did (:account msg)
496- :id (irc/tag-value tags "msgid")
497- ;; The server canonicalises +draft/reply to
498- ;; +reply; a client that sent the draft name
499- ;; may still reach us before it does.
500- :reply-to (or (irc/tag-value tags "+reply")
501- (irc/tag-value tags "+draft/reply"))
502- :reactions (parse-reactions
503- (irc/tag-value tags "+freeq.at/reactions"))}))
553+ :else from)
554+ ;; What this message rewrites, when it is a rewrite. The
555+ ;; server canonicalises the name to `+draft/edit`.
556+ edit-of (irc/tag-value tags "+draft/edit")
557+ ;; And what the server says about a line it has already
558+ ;; collapsed: replay sends one row per message, carrying
559+ ;; the current text and no `+draft/edit` to hint that it
560+ ;; is not the original. This tag is the only trace.
561+ replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
562+ (if edit-of
563+ ;; A revision is not a new line: it replaces the one it
564+ ;; names, under that line's own id — never the revision's
565+ ;; wire msgid, which nothing else refers to.
566+ (when (= :absent (edit-message! buffer edit-of from text))
567+ ;; The original is outside the backlog we hold, so show
568+ ;; the current text rather than dropping what was said.
569+ (push-message! buffer from text
570+ {:at at
571+ :did (:account msg)
572+ :id edit-of
573+ :edited? true
574+ :reply-to (or (irc/tag-value tags "+reply")
575+ (irc/tag-value tags "+draft/reply"))}))
576+ (push-message! buffer from text
577+ {:at at
578+ :did (:account msg)
579+ :id (irc/tag-value tags "msgid")
580+ :edited? replayed-edit?
581+ ;; The server canonicalises +draft/reply to
582+ ;; +reply; a client that sent the draft
583+ ;; name may still reach us before it does.
584+ :reply-to (or (irc/tag-value tags "+reply")
585+ (irc/tag-value tags "+draft/reply"))
586+ :reactions (parse-reactions
587+ (irc/tag-value tags "+freeq.at/reactions"))})))
504588 ;; A message that is only tags. A reaction is the one this client reads:
505589 ;; `+react` puts an emoji on the message `+reply` names, and the server's
506590 ;; own `+freeq.at/unreact` takes it off again.
@@ -604,6 +688,12 @@
604688 ;; belongs. The banner is for what stops the whole app a failed
605689 ;; connection or a refused sign-in.
606690 (when-not ch (reset! error (str "Cannot join: " why))))
691+ ;; What the server refused and why, in the reader's words. An edit or a
692+ ;; reaction it will not take is otherwise silent: the line on screen
693+ ;; simply never changes, which reads as the app having lost it.
694+ "FAIL" (let [[what _code] params]
695+ (reset! error (str (or what "Request") " refused — "
696+ (or (last params) "no reason given"))))
607697 "903" (reset! status (str "Signed in as " (:handle @session)))
608698 ("904" "905" "906") (do (reset! session nil)
609699 ;; The broker token may still be good but a
@@ -1052,6 +1142,48 @@
10521142 (discard-file! copy)
10531143 (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
10541144
1145+(defn- dm-peer-did
1146+ "The DID of whoever this DM buffer is with, from the last thing they said.
1147+ nil for a channel, and for a conversation where nobody with a DID has spoken
1148+ a signature over a DM needs both sides named, and there is nothing to name."
1149+ [channel]
1150+ (when-not (str/starts-with? (or channel "") "#")
1151+ (->> (get-in @channels [channel :messages])
1152+ (remove #(= @form-nick (:from %)))
1153+ (keep :did)
1154+ last)))
1155+
1156+(defn mine?
1157+ "Whether we are the one who said this. Nick against nick, which is what the
1158+ server itself falls back to for an account with no DID and an edit it would
1159+ refuse is one not worth offering."
1160+ [m]
1161+ (and (not (:system? m))
1162+ (seq (or (:from m) ""))
1163+ (= (str/lower-case (:from m))
1164+ (str/lower-case (or @form-nick "")))))
1165+
1166+(defn start-edit!
1167+ "Put a message back in the box to be rewritten.
1168+
1169+ The old text is the starting point rather than an empty line: an edit is
1170+ usually a word, and retyping the sentence around it is not what was asked
1171+ for. Whatever was half-typed is dropped a draft and an edit are two things
1172+ to say, and the box holds one."
1173+ [channel m]
1174+ (when (and (:id m) (mine? m))
1175+ (reset! replying-to nil)
1176+ (reset! editing {:channel channel :id (:id m)})
1177+ (reset! draft (or (:text m) ""))))
1178+
1179+(defn cancel-edit!
1180+ "Leave the message as it was said. The box empties with it: what is in it is
1181+ a copy of the line on screen, and leaving that behind would look like a draft
1182+ the reader wrote."
1183+ []
1184+ (reset! editing nil)
1185+ (reset! draft ""))
1186+
10551187 (defn send-draft!
10561188 "Send the draft, with whatever picture is attached to it.
10571189
@@ -1068,10 +1200,28 @@
10681200 (let [text (str/trim @draft)
10691201 target @current
10701202 reply-to @replying-to
1203+ edit @editing
10711204 {:keys [url status path] :as att} @attachment]
10721205 (cond
10731206 (not target) nil
10741207 (= :uploading status) (reset! error "The picture is still uploading.")
1208+ ;; A rewrite replaces what was said, and what was said is a line of text:
1209+ ;; there is no wire form for adding a picture to a message already sent,
1210+ ;; so the attachment is held back rather than silently dropped.
1211+ (and edit att) (reset! error "Finish the edit before sending a picture.")
1212+ (and edit (str/blank? text)) nil
1213+ edit
1214+ (do (when-let [c @conn]
1215+ (irc/edit! c (:channel edit) (:id edit) text
1216+ (dm-peer-did (:channel edit))))
1217+ ;; Same reason as a new message: the server's echo is the copy that
1218+ ;; every other client sees, and folding this one in as well would
1219+ ;; rewrite the line twice. Without echo-message nothing comes back,
1220+ ;; so the rewrite has to be applied here or it never shows.
1221+ (when-not (some-> @conn (irc/cap-acked? "echo-message"))
1222+ (edit-message! (:channel edit) (:id edit) @form-nick text))
1223+ (reset! editing nil)
1224+ (reset! draft ""))
10751225 (and (str/blank? text) (not url)) nil
10761226 :else
10771227 (let [line (str/trim (str text (when url (str " " url))))]
@@ -1132,17 +1282,6 @@
11321282 group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))
11331283 :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))
11341284
1135-(defn- dm-peer-did
1136- "The DID of whoever this DM buffer is with, from the last thing they said.
1137- nil for a channel, and for a conversation where nobody with a DID has spoken
1138- a signature over a DM needs both sides named, and there is nothing to name."
1139- [channel]
1140- (when-not (str/starts-with? (or channel "") "#")
1141- (->> (get-in @channels [channel :messages])
1142- (remove #(= @form-nick (:from %)))
1143- (keep :did)
1144- last)))
1145-
11461285 (defn my-reaction?
11471286 "Whether this nick is already on that emoji — which is what makes a second
11481287 click take it off rather than send the same reaction twice."
@@ -75,6 +75,11 @@
75 (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))75 (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
76 (defn cancel-reply! [] (reset! replying-to nil))76 (defn cancel-reply! [] (reset! replying-to nil))
77 77
78+;; The message the draft is a rewrite of, as `{:channel :id}`, or nil when the
79+;; box is being used for something new. Only the id is kept: what is being
80+;; rewritten is in the box, and the line on screen is the thing it will replace.
81+(defonce editing (atom nil))
82+
78 ;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil83 ;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil
79 ;; when it is closed. The picker is a panel over the compose bar rather than a84 ;; when it is closed. The picker is a panel over the compose bar rather than a
80 ;; screen: what is being reacted to has to stay in sight.85 ;; screen: what is being reacted to has to stay in sight.
@@ -182,7 +187,7 @@
182 and `:reply-to` is the one it answers. `:reactions` is what people have put187 and `:reply-to` is the one it answers. `:reactions` is what people have put
183 on it already, which on a replayed backlog the server hands over in full."188 on it already, which on a replayed backlog the server hands over in full."
184 ([channel from text] (push-message! channel from text {}))189 ([channel from text] (push-message! channel from text {}))
185- ([channel from text {:keys [at did id reply-to reactions]}]190+ ([channel from text {:keys [at did id reply-to reactions edited?]}]
186 (let [at (or at (clock/now-ms))191 (let [at (or at (clock/now-ms))
187 who (avatars/actor did from)]192 who (avatars/actor did from)]
188 (doseq [url (media/image-urls text)]193 (doseq [url (media/image-urls text)]
@@ -203,6 +208,11 @@
203 ;; `:id` is what a reply points at, and208 ;; `:id` is what a reply points at, and
204 ;; `:reply-to` is what this one points at.209 ;; `:reply-to` is what this one points at.
205 :id id :reply-to reply-to210 :id id :reply-to reply-to
211+ ;; The sender has since rewritten this line.
212+ ;; Replay says so with a tag rather than by
213+ ;; sending the revision, so a message can
214+ ;; arrive already edited.
215+ :edited? (boolean edited?)
206 ;; emoji -> the nicks who put it there216 ;; emoji -> the nicks who put it there
207 :reactions (or reactions {})})217 :reactions (or reactions {})})
208 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))218 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
@@ -220,6 +230,11 @@
220 ;; A picker belongs to the message it was opened on; carrying it into another230 ;; A picker belongs to the message it was opened on; carrying it into another
221 ;; buffer would offer to react to something that is no longer on screen.231 ;; buffer would offer to react to something that is no longer on screen.
222 (reset! reacting nil)232 (reset! reacting nil)
233+ ;; And an edit belongs to a line in the buffer being left: carried across, the
234+ ;; next Send would rewrite a message nobody in this room can see.
235+ (when (and @editing (not= name (:channel @editing)))
236+ (reset! editing nil)
237+ (reset! draft ""))
223 (swap! channels #(-> (ensure-channel % name)238 (swap! channels #(-> (ensure-channel % name)
224 (assoc-in [name :unread] 0)239 (assoc-in [name :unread] 0)
225 (assoc-in [name :accessed] (swap! access-tick inc))))240 (assoc-in [name :accessed] (swap! access-tick inc))))
@@ -281,6 +296,52 @@
281 msgs))296 msgs))
282 m)))))297 m)))))
283 298
299+(defn edit-message!
300+ "Rewrite a message in place, and say so. `msgid` names the line as it was
301+ first sent: a message keeps the id it was born with across every revision,
302+ which is what keeps its reactions, replies and pins attached to it.
303+
304+ Only the sender may rewrite their own line, so an edit whose nick is not the
305+ one on the message is dropped the server checks authorship too, and a
306+ client that believed the wire alone would let a hostile relay put words in
307+ somebody's mouth.
308+
309+ Answers what became of it: `:applied`, `:refused` for one that was not the
310+ sender's to make, or `:absent` when no line here has that id — an edit of
311+ something older than the backlog we asked for, which is the one case the
312+ caller shows as a line of its own rather than losing what it says."
313+ [channel msgid from text]
314+ (if-not (and channel msgid)
315+ :absent
316+ (let [found? (atom nil)]
317+ (swap! channels
318+ (fn [m]
319+ (if-let [msgs (get-in m [channel :messages])]
320+ (assoc-in m [channel :messages]
321+ (mapv (fn [msg]
322+ (if (= msgid (:id msg))
323+ (if (= (str/lower-case (or (:from msg) ""))
324+ (str/lower-case (or from "")))
325+ (do (reset! found? :applied)
326+ (assoc msg
327+ :text text
328+ :images (media/image-urls text)
329+ :edited? true))
330+ ;; Somebody else's line. The server
331+ ;; refuses this too, so it is either a
332+ ;; peer talking to us directly or one
333+ ;; lying and neither gets to put words
334+ ;; under a name that is not theirs.
335+ (do (reset! found? :refused) msg))
336+ msg))
337+ msgs))
338+ m)))
339+ ;; The revision may link a picture the original did not.
340+ (when (= :applied @found?)
341+ (doseq [url (media/image-urls text)]
342+ (media/fetch! url #(swap! media-tick inc))))
343+ (or @found? :absent))))
344+
284 ;; --- who is in the room ------------------------------------------------------345 ;; --- who is in the room ------------------------------------------------------
285 ;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is346 ;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is
286 ;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK347 ;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK
@@ -489,18 +550,41 @@
489 buffer (cond550 buffer (cond
490 (str/starts-with? (or target "") "#") target551 (str/starts-with? (or target "") "#") target
491 (= from @form-nick) target552 (= from @form-nick) target
492- :else from)]553+ :else from)
493- (push-message! buffer from text554+ ;; What this message rewrites, when it is a rewrite. The
494- {:at at555+ ;; server canonicalises the name to `+draft/edit`.
495- :did (:account msg)556+ edit-of (irc/tag-value tags "+draft/edit")
496- :id (irc/tag-value tags "msgid")557+ ;; And what the server says about a line it has already
497- ;; The server canonicalises +draft/reply to558+ ;; collapsed: replay sends one row per message, carrying
498- ;; +reply; a client that sent the draft name559+ ;; the current text and no `+draft/edit` to hint that it
499- ;; may still reach us before it does.560+ ;; is not the original. This tag is the only trace.
500- :reply-to (or (irc/tag-value tags "+reply")561+ replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
501- (irc/tag-value tags "+draft/reply"))562+ (if edit-of
502- :reactions (parse-reactions563+ ;; A revision is not a new line: it replaces the one it
503- (irc/tag-value tags "+freeq.at/reactions"))}))564+ ;; names, under that line's own id — never the revision's
565+ ;; wire msgid, which nothing else refers to.
566+ (when (= :absent (edit-message! buffer edit-of from text))
567+ ;; The original is outside the backlog we hold, so show
568+ ;; the current text rather than dropping what was said.
569+ (push-message! buffer from text
570+ {:at at
571+ :did (:account msg)
572+ :id edit-of
573+ :edited? true
574+ :reply-to (or (irc/tag-value tags "+reply")
575+ (irc/tag-value tags "+draft/reply"))}))
576+ (push-message! buffer from text
577+ {:at at
578+ :did (:account msg)
579+ :id (irc/tag-value tags "msgid")
580+ :edited? replayed-edit?
581+ ;; The server canonicalises +draft/reply to
582+ ;; +reply; a client that sent the draft
583+ ;; name may still reach us before it does.
584+ :reply-to (or (irc/tag-value tags "+reply")
585+ (irc/tag-value tags "+draft/reply"))
586+ :reactions (parse-reactions
587+ (irc/tag-value tags "+freeq.at/reactions"))})))
504 ;; A message that is only tags. A reaction is the one this client reads:588 ;; A message that is only tags. A reaction is the one this client reads:
505 ;; `+react` puts an emoji on the message `+reply` names, and the server's589 ;; `+react` puts an emoji on the message `+reply` names, and the server's
506 ;; own `+freeq.at/unreact` takes it off again.590 ;; own `+freeq.at/unreact` takes it off again.
@@ -604,6 +688,12 @@
604 ;; belongs. The banner is for what stops the whole app a failed688 ;; belongs. The banner is for what stops the whole app a failed
605 ;; connection or a refused sign-in.689 ;; connection or a refused sign-in.
606 (when-not ch (reset! error (str "Cannot join: " why))))690 (when-not ch (reset! error (str "Cannot join: " why))))
691+ ;; What the server refused and why, in the reader's words. An edit or a
692+ ;; reaction it will not take is otherwise silent: the line on screen
693+ ;; simply never changes, which reads as the app having lost it.
694+ "FAIL" (let [[what _code] params]
695+ (reset! error (str (or what "Request") " refused — "
696+ (or (last params) "no reason given"))))
607 "903" (reset! status (str "Signed in as " (:handle @session)))697 "903" (reset! status (str "Signed in as " (:handle @session)))
608 ("904" "905" "906") (do (reset! session nil)698 ("904" "905" "906") (do (reset! session nil)
609 ;; The broker token may still be good but a699 ;; The broker token may still be good but a
@@ -1052,6 +1142,48 @@
1052 (discard-file! copy)1142 (discard-file! copy)
1053 (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))1143 (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
1054 1144
1145+(defn- dm-peer-did
1146+ "The DID of whoever this DM buffer is with, from the last thing they said.
1147+ nil for a channel, and for a conversation where nobody with a DID has spoken
1148+ a signature over a DM needs both sides named, and there is nothing to name."
1149+ [channel]
1150+ (when-not (str/starts-with? (or channel "") "#")
1151+ (->> (get-in @channels [channel :messages])
1152+ (remove #(= @form-nick (:from %)))
1153+ (keep :did)
1154+ last)))
1155+
1156+(defn mine?
1157+ "Whether we are the one who said this. Nick against nick, which is what the
1158+ server itself falls back to for an account with no DID and an edit it would
1159+ refuse is one not worth offering."
1160+ [m]
1161+ (and (not (:system? m))
1162+ (seq (or (:from m) ""))
1163+ (= (str/lower-case (:from m))
1164+ (str/lower-case (or @form-nick "")))))
1165+
1166+(defn start-edit!
1167+ "Put a message back in the box to be rewritten.
1168+
1169+ The old text is the starting point rather than an empty line: an edit is
1170+ usually a word, and retyping the sentence around it is not what was asked
1171+ for. Whatever was half-typed is dropped a draft and an edit are two things
1172+ to say, and the box holds one."
1173+ [channel m]
1174+ (when (and (:id m) (mine? m))
1175+ (reset! replying-to nil)
1176+ (reset! editing {:channel channel :id (:id m)})
1177+ (reset! draft (or (:text m) ""))))
1178+
1179+(defn cancel-edit!
1180+ "Leave the message as it was said. The box empties with it: what is in it is
1181+ a copy of the line on screen, and leaving that behind would look like a draft
1182+ the reader wrote."
1183+ []
1184+ (reset! editing nil)
1185+ (reset! draft ""))
1186+
1055 (defn send-draft!1187 (defn send-draft!
1056 "Send the draft, with whatever picture is attached to it.1188 "Send the draft, with whatever picture is attached to it.
1057 1189
@@ -1068,10 +1200,28 @@
1068 (let [text (str/trim @draft)1200 (let [text (str/trim @draft)
1069 target @current1201 target @current
1070 reply-to @replying-to1202 reply-to @replying-to
1203+ edit @editing
1071 {:keys [url status path] :as att} @attachment]1204 {:keys [url status path] :as att} @attachment]
1072 (cond1205 (cond
1073 (not target) nil1206 (not target) nil
1074 (= :uploading status) (reset! error "The picture is still uploading.")1207 (= :uploading status) (reset! error "The picture is still uploading.")
1208+ ;; A rewrite replaces what was said, and what was said is a line of text:
1209+ ;; there is no wire form for adding a picture to a message already sent,
1210+ ;; so the attachment is held back rather than silently dropped.
1211+ (and edit att) (reset! error "Finish the edit before sending a picture.")
1212+ (and edit (str/blank? text)) nil
1213+ edit
1214+ (do (when-let [c @conn]
1215+ (irc/edit! c (:channel edit) (:id edit) text
1216+ (dm-peer-did (:channel edit))))
1217+ ;; Same reason as a new message: the server's echo is the copy that
1218+ ;; every other client sees, and folding this one in as well would
1219+ ;; rewrite the line twice. Without echo-message nothing comes back,
1220+ ;; so the rewrite has to be applied here or it never shows.
1221+ (when-not (some-> @conn (irc/cap-acked? "echo-message"))
1222+ (edit-message! (:channel edit) (:id edit) @form-nick text))
1223+ (reset! editing nil)
1224+ (reset! draft ""))
1075 (and (str/blank? text) (not url)) nil1225 (and (str/blank? text) (not url)) nil
1076 :else1226 :else
1077 (let [line (str/trim (str text (when url (str " " url))))]1227 (let [line (str/trim (str text (when url (str " " url))))]
@@ -1132,17 +1282,6 @@
1132 group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))1282 group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))
1133 :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))1283 :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))
1134 1284
1135-(defn- dm-peer-did
1136- "The DID of whoever this DM buffer is with, from the last thing they said.
1137- nil for a channel, and for a conversation where nobody with a DID has spoken
1138- a signature over a DM needs both sides named, and there is nothing to name."
1139- [channel]
1140- (when-not (str/starts-with? (or channel "") "#")
1141- (->> (get-in @channels [channel :messages])
1142- (remove #(= @form-nick (:from %)))
1143- (keep :did)
1144- last)))
1145-
1146 (defn my-reaction?1285 (defn my-reaction?
1147 "Whether this nick is already on that emoji — which is what makes a second1286 "Whether this nick is already on that emoji — which is what makes a second
1148 click take it off rather than send the same reaction twice."1287 click take it off rather than send the same reaction twice."