Rewrite a line from the phone
A message keeps the id it was born with across every revision — that is what keeps its reactions, replies and pins attached to it — so an edit is not a new line, it replaces the one it names in place. `frq.edits` is that fold, pure over the channels map and answering what became of it: applied, refused for one that was not the sender's to make, or absent for an edit of something older than the backlog we hold, which is the one case shown as a line of its own rather than lost. Authorship is checked here and not only at the server, unchanged from what the desktop did: a client that believed the wire alone would let a hostile relay put words in somebody else's mouth. The picture links stayed behind. `frq.media` is not portable, so the fold takes a function to decorate the rewritten message and the desktop passes one that re-reads the links out of the new text — rather than dragging media into common/ for one line of it. The pencil had never appeared on the phone at all, and not because of the chip: `mine?` is asked about a message and the phone had installed something that took a nick, so every line answered false. It is `frq.rooms/mine?` now, nick against nick and case-insensitively, which is what the server falls back to for an account with no DID — an edit it would refuse is one not worth offering. Verified on a Pixel 6a signed in: the pencil shows on our own lines and not on anyone else's, the box opens on the old text, and sending replaces the line where it stands and marks it edited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dcb50e0 parent: 0ee5786 added
common/frq/edits.cljc +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +(ns frq.edits | |
| 2 | + "Rewriting a line that is already said. | |
| 3 | + | |
| 4 | + A message keeps the id it was born with across every revision, which is what | |
| 5 | + keeps its reactions, replies and pins attached to it. So an edit is not a new | |
| 6 | + line: it replaces the one it names, in place, under that line's own id. | |
| 7 | + | |
| 8 | + Pure over the channels map, like `frq.members` and `frq.reactions`, and | |
| 9 | + shared for the same reason — who may rewrite what is the server's rule and | |
| 10 | + neither half of frq gets a say in it." | |
| 11 | + (:require [clojure.string :as str])) | |
| 12 | + | |
| 13 | +(defn apply-edit | |
| 14 | + "The revision folded into the buffer it belongs to. | |
| 15 | + | |
| 16 | + Answers `{:channels :result}`, where the result says what became of it: | |
| 17 | + `:applied`; `:refused` for one that was not the sender's to make; or | |
| 18 | + `:absent` when no line here has that id — an edit of something older than the | |
| 19 | + backlog we asked for, which is the one case the caller shows as a line of its | |
| 20 | + own rather than losing what it says. | |
| 21 | + | |
| 22 | + Only the sender may rewrite their own line, so an edit whose nick is not the | |
| 23 | + one on the message is dropped. The server checks authorship too, and a client | |
| 24 | + that believed the wire alone would let a hostile relay put words in somebody | |
| 25 | + else's mouth. | |
| 26 | + | |
| 27 | + `decorate` is applied to the rewritten message, for whatever the caller | |
| 28 | + derives from the text it now carries — the desktop re-reads the picture links | |
| 29 | + out of it there. `frq.media` is not portable and this does not need it to be." | |
| 30 | + ([channels channel msgid from text] | |
| 31 | + (apply-edit channels channel msgid from text identity)) | |
| 32 | + ([channels channel msgid from text decorate] | |
| 33 | + (if-not (and channel msgid) | |
| 34 | + {:channels channels :result :absent} | |
| 35 | + (let [msgs (get-in channels [channel :messages])] | |
| 36 | + (if-not msgs | |
| 37 | + {:channels channels :result :absent} | |
| 38 | + (let [result (volatile! :absent) | |
| 39 | + msgs' (mapv (fn [msg] | |
| 40 | + (if (= msgid (:id msg)) | |
| 41 | + (if (= (str/lower-case (or (:from msg) "")) | |
| 42 | + (str/lower-case (or from ""))) | |
| 43 | + (do (vreset! result :applied) | |
| 44 | + (decorate (assoc msg | |
| 45 | + :text text | |
| 46 | + :edited? true))) | |
| 47 | + (do (vreset! result :refused) msg)) | |
| 48 | + msg)) | |
| 49 | + msgs)] | |
| 50 | + {:channels (assoc-in channels [channel :messages] msgs') | |
| 51 | + :result @result})))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +(ns frq.edits | ||
| 2 | + "Rewriting a line that is already said. | ||
| 3 | + | ||
| 4 | + A message keeps the id it was born with across every revision, which is what | ||
| 5 | + keeps its reactions, replies and pins attached to it. So an edit is not a new | ||
| 6 | + line: it replaces the one it names, in place, under that line's own id. | ||
| 7 | + | ||
| 8 | + Pure over the channels map, like `frq.members` and `frq.reactions`, and | ||
| 9 | + shared for the same reason — who may rewrite what is the server's rule and | ||
| 10 | + neither half of frq gets a say in it." | ||
| 11 | + (:require [clojure.string :as str])) | ||
| 12 | + | ||
| 13 | +(defn apply-edit | ||
| 14 | + "The revision folded into the buffer it belongs to. | ||
| 15 | + | ||
| 16 | + Answers `{:channels :result}`, where the result says what became of it: | ||
| 17 | + `:applied`; `:refused` for one that was not the sender's to make; or | ||
| 18 | + `:absent` when no line here has that id — an edit of something older than the | ||
| 19 | + backlog we asked for, which is the one case the caller shows as a line of its | ||
| 20 | + own rather than losing what it says. | ||
| 21 | + | ||
| 22 | + Only the sender may rewrite their own line, so an edit whose nick is not the | ||
| 23 | + one on the message is dropped. The server checks authorship too, and a client | ||
| 24 | + that believed the wire alone would let a hostile relay put words in somebody | ||
| 25 | + else's mouth. | ||
| 26 | + | ||
| 27 | + `decorate` is applied to the rewritten message, for whatever the caller | ||
| 28 | + derives from the text it now carries — the desktop re-reads the picture links | ||
| 29 | + out of it there. `frq.media` is not portable and this does not need it to be." | ||
| 30 | + ([channels channel msgid from text] | ||
| 31 | + (apply-edit channels channel msgid from text identity)) | ||
| 32 | + ([channels channel msgid from text decorate] | ||
| 33 | + (if-not (and channel msgid) | ||
| 34 | + {:channels channels :result :absent} | ||
| 35 | + (let [msgs (get-in channels [channel :messages])] | ||
| 36 | + (if-not msgs | ||
| 37 | + {:channels channels :result :absent} | ||
| 38 | + (let [result (volatile! :absent) | ||
| 39 | + msgs' (mapv (fn [msg] | ||
| 40 | + (if (= msgid (:id msg)) | ||
| 41 | + (if (= (str/lower-case (or (:from msg) "")) | ||
| 42 | + (str/lower-case (or from ""))) | ||
| 43 | + (do (vreset! result :applied) | ||
| 44 | + (decorate (assoc msg | ||
| 45 | + :text text | ||
| 46 | + :edited? true))) | ||
| 47 | + (do (vreset! result :refused) msg)) | ||
| 48 | + msg)) | ||
| 49 | + msgs)] | ||
| 50 | + {:channels (assoc-in channels [channel :messages] msgs') | ||
| 51 | + :result @result})))))) | ||
modified
common/frq/irc/mutate.cljc +16 -0 | @@ -49,3 +49,19 @@ | ||
| 49 | 49 | (tag-line (merge {"+freeq.at/unreact" emoji "+reply" msgid} |
| 50 | 50 | (msgsig/mutation-tags "unreact" target msgid emoji peer-did)) |
| 51 | 51 | (str "TAGMSG " target)))) |
| 52 | + | |
| 53 | + | |
| 54 | +(defn edit-line | |
| 55 | + "Rewrite something already said. | |
| 56 | + | |
| 57 | + The `+draft/edit` tag names the message being replaced and what follows is | |
| 58 | + its new text — the server checks that the message was ours, files the | |
| 59 | + revision under the original's id, and sends the new line on to the channel | |
| 60 | + for every client to fold in. | |
| 61 | + | |
| 62 | + A PRIVMSG rather than a TAGMSG, because an edit carries a body." | |
| 63 | + ([target msgid text] (edit-line target msgid text nil)) | |
| 64 | + ([target msgid text peer-did] | |
| 65 | + (tag-line (assoc (msgsig/edit-tags target msgid text nil peer-did) | |
| 66 | + "+draft/edit" msgid) | |
| 67 | + (str "PRIVMSG " target " :" text)))) | |
| @@ -49,3 +49,19 @@ | |||
| 49 | (tag-line (merge {"+freeq.at/unreact" emoji "+reply" msgid} | 49 | (tag-line (merge {"+freeq.at/unreact" emoji "+reply" msgid} |
| 50 | (msgsig/mutation-tags "unreact" target msgid emoji peer-did)) | 50 | (msgsig/mutation-tags "unreact" target msgid emoji peer-did)) |
| 51 | (str "TAGMSG " target)))) | 51 | (str "TAGMSG " target)))) |
| 52 | + | ||
| 53 | + | ||
| 54 | +(defn edit-line | ||
| 55 | + "Rewrite something already said. | ||
| 56 | + | ||
| 57 | + The `+draft/edit` tag names the message being replaced and what follows is | ||
| 58 | + its new text — the server checks that the message was ours, files the | ||
| 59 | + revision under the original's id, and sends the new line on to the channel | ||
| 60 | + for every client to fold in. | ||
| 61 | + | ||
| 62 | + A PRIVMSG rather than a TAGMSG, because an edit carries a body." | ||
| 63 | + ([target msgid text] (edit-line target msgid text nil)) | ||
| 64 | + ([target msgid text peer-did] | ||
| 65 | + (tag-line (assoc (msgsig/edit-tags target msgid text nil peer-did) | ||
| 66 | + "+draft/edit" msgid) | ||
| 67 | + (str "PRIVMSG " target " :" text)))) | ||
modified
common/frq/rooms.cljc +13 -0 | @@ -53,3 +53,16 @@ | ||
| 53 | 53 | (str/includes? (str/lower-case (:name %)) q))) |
| 54 | 54 | (sort-by (juxt #(- (:accessed % 0)) :name)) |
| 55 | 55 | vec))) |
| 56 | + | |
| 57 | + | |
| 58 | +(defn mine? | |
| 59 | + "Whether we are the one who said this. | |
| 60 | + | |
| 61 | + Nick against nick, which is what the server itself falls back to for an | |
| 62 | + account with no DID — and an edit it would refuse is one not worth offering. | |
| 63 | + A system line is nobody's to rewrite." | |
| 64 | + [m me] | |
| 65 | + (and (not (:system? m)) | |
| 66 | + (seq (or (:from m) "")) | |
| 67 | + (= (str/lower-case (:from m)) | |
| 68 | + (str/lower-case (or me ""))))) | |
| @@ -53,3 +53,16 @@ | |||
| 53 | (str/includes? (str/lower-case (:name %)) q))) | 53 | (str/includes? (str/lower-case (:name %)) q))) |
| 54 | (sort-by (juxt #(- (:accessed % 0)) :name)) | 54 | (sort-by (juxt #(- (:accessed % 0)) :name)) |
| 55 | vec))) | 55 | vec))) |
| 56 | + | ||
| 57 | + | ||
| 58 | +(defn mine? | ||
| 59 | + "Whether we are the one who said this. | ||
| 60 | + | ||
| 61 | + Nick against nick, which is what the server itself falls back to for an | ||
| 62 | + account with no DID — and an edit it would refuse is one not worth offering. | ||
| 63 | + A system line is nobody's to rewrite." | ||
| 64 | + [m me] | ||
| 65 | + (and (not (:system? m)) | ||
| 66 | + (seq (or (:from m) "")) | ||
| 67 | + (= (str/lower-case (:from m)) | ||
| 68 | + (str/lower-case (or me ""))))) | ||
modified
flutter/src/frq/main.cljd +60 -7 | @@ -41,6 +41,7 @@ | ||
| 41 | 41 | [frq.rooms :as rooms] |
| 42 | 42 | [frq.members :as members] |
| 43 | 43 | [frq.reactions :as reactions] |
| 44 | + [frq.edits :as edits] | |
| 44 | 45 | [frq.irc.mutate :as mutate] |
| 45 | 46 | [frq.oauth.core :as oauth] |
| 46 | 47 | [frq.oauth.dart :as oauth-dart] |
| @@ -128,8 +129,25 @@ | ||
| 128 | 129 | (let [tags (:tags m) |
| 129 | 130 | target (first params) |
| 130 | 131 | text (last params) |
| 131 | - name (if (rooms/dm? target) who target)] | |
| 132 | - (swap! cells/channels update name | |
| 132 | + name (if (rooms/dm? target) who target) | |
| 133 | + edit-of (or (irc/tag-value tags "+draft/edit") | |
| 134 | + (irc/tag-value tags "+edit"))] | |
| 135 | + (if edit-of | |
| 136 | + ;; A revision is not a new line: it replaces the one it names, under | |
| 137 | + ;; that line's own id and never the revision's own wire msgid, which | |
| 138 | + ;; nothing else refers to. One older than the backlog we hold has | |
| 139 | + ;; nothing here to replace, and is shown as itself rather than lost. | |
| 140 | + (let [out (edits/apply-edit @cells/channels name edit-of who text)] | |
| 141 | + (reset! cells/channels (:channels out)) | |
| 142 | + (when (= :absent (:result out)) | |
| 143 | + (swap! cells/channels update name | |
| 144 | + #(-> (merge {:name name :messages [] :unread 0} %) | |
| 145 | + (update :messages conj {:from who | |
| 146 | + :text text | |
| 147 | + :did (:account m) | |
| 148 | + :id edit-of | |
| 149 | + :edited? true}))))) | |
| 150 | + (swap! cells/channels update name | |
| 133 | 151 | #(-> (merge {:name name :messages [] :unread 0} %) |
| 134 | 152 | (update :messages conj |
| 135 | 153 | ;; The msgid is what everything after a message |
| @@ -147,9 +165,15 @@ | ||
| 147 | 165 | (irc/tag-value tags "+draft/reply")) |
| 148 | 166 | ;; What is already on it, so a reconnect does not |
| 149 | 167 | ;; start every message empty. |
| 168 | + ;; What the server says about a line it has | |
| 169 | + ;; already collapsed: replay sends the current | |
| 170 | + ;; text and no `+draft/edit` to hint that it is | |
| 171 | + ;; not the original. This tag is the only trace. | |
| 172 | + :edited? (= "1" (irc/tag-value tags | |
| 173 | + "+freeq.at/edited")) | |
| 150 | 174 | :reactions (reactions/parse-tally |
| 151 | 175 | (irc/tag-value tags |
| 152 | - "+freeq.at/reactions"))})))) | |
| 176 | + "+freeq.at/reactions"))}))))) | |
| 153 | 177 | |
| 154 | 178 | ;; A message that is only tags. A reaction is the one this reads: |
| 155 | 179 | ;; `+react` puts an emoji on the message `+reply` names, and the |
| @@ -470,8 +494,23 @@ | ||
| 470 | 494 | |
| 471 | 495 | (defn- send-draft! [] |
| 472 | 496 | (let [text (str @cells/draft) |
| 473 | - room (str @cells/current)] | |
| 474 | - (when (and (seq text) (seq room) @conn) | |
| 497 | + room (str @cells/current) | |
| 498 | + edit @cells/editing] | |
| 499 | + (cond | |
| 500 | + ;; A rewrite replaces what was said. It carries no new msgid of its own | |
| 501 | + ;; — the server files it under the original's id — so nothing is added | |
| 502 | + ;; here and the echo folds it in where the line already is. | |
| 503 | + (and edit (seq text) (seq room) @conn) | |
| 504 | + (do (net/send-line! | |
| 505 | + @conn | |
| 506 | + (mutate/edit-line room (:id edit) text | |
| 507 | + (reactions/peer-did @cells/channels room | |
| 508 | + (str @cells/form-nick)))) | |
| 509 | + (reset! cells/editing nil) | |
| 510 | + (reset! cells/draft "")) | |
| 511 | + | |
| 512 | + (and (seq text) (seq room) @conn) | |
| 513 | + (do | |
| 475 | 514 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| 476 | 515 | ;; Echoed locally only when the server will not echo it back. With |
| 477 | 516 | ;; `echo-message` negotiated it does — that is what the cap is for, and |
| @@ -480,7 +519,7 @@ | ||
| 480 | 519 | (when-not (handshake/acked? @caps "echo-message") |
| 481 | 520 | (swap! cells/channels update room |
| 482 | 521 | #(update % :messages conj {:from @cells/form-nick :text text}))) |
| 483 | - (reset! cells/draft "")))) | |
| 522 | + (reset! cells/draft ""))))) | |
| 484 | 523 | |
| 485 | 524 | (defn- message-by-id [room id] |
| 486 | 525 | (when id |
| @@ -585,7 +624,21 @@ | ||
| 585 | 624 | :unhover-reaction! (fn [_ _] nil) |
| 586 | 625 | :wide? (fn [] false) |
| 587 | 626 | :desktop? (fn [] false) |
| 588 | - :mine? (fn [from] (= (str from) (str @cells/form-nick))) | |
| 627 | + :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) | |
| 628 | + | |
| 629 | + ;; Rewriting. The old text is the starting point rather than an empty | |
| 630 | + ;; line: an edit is usually a word, and retyping the sentence around it | |
| 631 | + ;; is not what was asked for. | |
| 632 | + :start-edit! (fn [channel m] | |
| 633 | + (when (and (:id m) (rooms/mine? m (str @cells/form-nick))) | |
| 634 | + (reset! cells/replying-to nil) | |
| 635 | + (reset! cells/editing {:channel channel :id (:id m)}) | |
| 636 | + (reset! cells/draft (or (:text m) "")))) | |
| 637 | + ;; The box empties with it: what is in it is a copy of the line on | |
| 638 | + ;; screen, and leaving that behind would look like a draft. | |
| 639 | + :cancel-edit! (fn [] | |
| 640 | + (reset! cells/editing nil) | |
| 641 | + (reset! cells/draft "")) | |
| 589 | 642 | :message-by-id (fn [room id] |
| 590 | 643 | (->> (get-in @cells/channels [room :messages]) |
| 591 | 644 | (filter #(= id (:id %))) |
| @@ -41,6 +41,7 @@ | |||
| 41 | [frq.rooms :as rooms] | 41 | [frq.rooms :as rooms] |
| 42 | [frq.members :as members] | 42 | [frq.members :as members] |
| 43 | [frq.reactions :as reactions] | 43 | [frq.reactions :as reactions] |
| 44 | + [frq.edits :as edits] | ||
| 44 | [frq.irc.mutate :as mutate] | 45 | [frq.irc.mutate :as mutate] |
| 45 | [frq.oauth.core :as oauth] | 46 | [frq.oauth.core :as oauth] |
| 46 | [frq.oauth.dart :as oauth-dart] | 47 | [frq.oauth.dart :as oauth-dart] |
| @@ -128,8 +129,25 @@ | |||
| 128 | (let [tags (:tags m) | 129 | (let [tags (:tags m) |
| 129 | target (first params) | 130 | target (first params) |
| 130 | text (last params) | 131 | text (last params) |
| 131 | - name (if (rooms/dm? target) who target)] | 132 | + name (if (rooms/dm? target) who target) |
| 132 | - (swap! cells/channels update name | 133 | + edit-of (or (irc/tag-value tags "+draft/edit") |
| 134 | + (irc/tag-value tags "+edit"))] | ||
| 135 | + (if edit-of | ||
| 136 | + ;; A revision is not a new line: it replaces the one it names, under | ||
| 137 | + ;; that line's own id and never the revision's own wire msgid, which | ||
| 138 | + ;; nothing else refers to. One older than the backlog we hold has | ||
| 139 | + ;; nothing here to replace, and is shown as itself rather than lost. | ||
| 140 | + (let [out (edits/apply-edit @cells/channels name edit-of who text)] | ||
| 141 | + (reset! cells/channels (:channels out)) | ||
| 142 | + (when (= :absent (:result out)) | ||
| 143 | + (swap! cells/channels update name | ||
| 144 | + #(-> (merge {:name name :messages [] :unread 0} %) | ||
| 145 | + (update :messages conj {:from who | ||
| 146 | + :text text | ||
| 147 | + :did (:account m) | ||
| 148 | + :id edit-of | ||
| 149 | + :edited? true}))))) | ||
| 150 | + (swap! cells/channels update name | ||
| 133 | #(-> (merge {:name name :messages [] :unread 0} %) | 151 | #(-> (merge {:name name :messages [] :unread 0} %) |
| 134 | (update :messages conj | 152 | (update :messages conj |
| 135 | ;; The msgid is what everything after a message | 153 | ;; The msgid is what everything after a message |
| @@ -147,9 +165,15 @@ | |||
| 147 | (irc/tag-value tags "+draft/reply")) | 165 | (irc/tag-value tags "+draft/reply")) |
| 148 | ;; What is already on it, so a reconnect does not | 166 | ;; What is already on it, so a reconnect does not |
| 149 | ;; start every message empty. | 167 | ;; start every message empty. |
| 168 | + ;; What the server says about a line it has | ||
| 169 | + ;; already collapsed: replay sends the current | ||
| 170 | + ;; text and no `+draft/edit` to hint that it is | ||
| 171 | + ;; not the original. This tag is the only trace. | ||
| 172 | + :edited? (= "1" (irc/tag-value tags | ||
| 173 | + "+freeq.at/edited")) | ||
| 150 | :reactions (reactions/parse-tally | 174 | :reactions (reactions/parse-tally |
| 151 | (irc/tag-value tags | 175 | (irc/tag-value tags |
| 152 | - "+freeq.at/reactions"))})))) | 176 | + "+freeq.at/reactions"))}))))) |
| 153 | 177 | ||
| 154 | ;; A message that is only tags. A reaction is the one this reads: | 178 | ;; A message that is only tags. A reaction is the one this reads: |
| 155 | ;; `+react` puts an emoji on the message `+reply` names, and the | 179 | ;; `+react` puts an emoji on the message `+reply` names, and the |
| @@ -470,8 +494,23 @@ | |||
| 470 | 494 | ||
| 471 | (defn- send-draft! [] | 495 | (defn- send-draft! [] |
| 472 | (let [text (str @cells/draft) | 496 | (let [text (str @cells/draft) |
| 473 | - room (str @cells/current)] | 497 | + room (str @cells/current) |
| 474 | - (when (and (seq text) (seq room) @conn) | 498 | + edit @cells/editing] |
| 499 | + (cond | ||
| 500 | + ;; A rewrite replaces what was said. It carries no new msgid of its own | ||
| 501 | + ;; — the server files it under the original's id — so nothing is added | ||
| 502 | + ;; here and the echo folds it in where the line already is. | ||
| 503 | + (and edit (seq text) (seq room) @conn) | ||
| 504 | + (do (net/send-line! | ||
| 505 | + @conn | ||
| 506 | + (mutate/edit-line room (:id edit) text | ||
| 507 | + (reactions/peer-did @cells/channels room | ||
| 508 | + (str @cells/form-nick)))) | ||
| 509 | + (reset! cells/editing nil) | ||
| 510 | + (reset! cells/draft "")) | ||
| 511 | + | ||
| 512 | + (and (seq text) (seq room) @conn) | ||
| 513 | + (do | ||
| 475 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) | 514 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| 476 | ;; Echoed locally only when the server will not echo it back. With | 515 | ;; Echoed locally only when the server will not echo it back. With |
| 477 | ;; `echo-message` negotiated it does — that is what the cap is for, and | 516 | ;; `echo-message` negotiated it does — that is what the cap is for, and |
| @@ -480,7 +519,7 @@ | |||
| 480 | (when-not (handshake/acked? @caps "echo-message") | 519 | (when-not (handshake/acked? @caps "echo-message") |
| 481 | (swap! cells/channels update room | 520 | (swap! cells/channels update room |
| 482 | #(update % :messages conj {:from @cells/form-nick :text text}))) | 521 | #(update % :messages conj {:from @cells/form-nick :text text}))) |
| 483 | - (reset! cells/draft "")))) | 522 | + (reset! cells/draft ""))))) |
| 484 | 523 | ||
| 485 | (defn- message-by-id [room id] | 524 | (defn- message-by-id [room id] |
| 486 | (when id | 525 | (when id |
| @@ -585,7 +624,21 @@ | |||
| 585 | :unhover-reaction! (fn [_ _] nil) | 624 | :unhover-reaction! (fn [_ _] nil) |
| 586 | :wide? (fn [] false) | 625 | :wide? (fn [] false) |
| 587 | :desktop? (fn [] false) | 626 | :desktop? (fn [] false) |
| 588 | - :mine? (fn [from] (= (str from) (str @cells/form-nick))) | 627 | + :mine? (fn [m] (rooms/mine? m (str @cells/form-nick))) |
| 628 | + | ||
| 629 | + ;; Rewriting. The old text is the starting point rather than an empty | ||
| 630 | + ;; line: an edit is usually a word, and retyping the sentence around it | ||
| 631 | + ;; is not what was asked for. | ||
| 632 | + :start-edit! (fn [channel m] | ||
| 633 | + (when (and (:id m) (rooms/mine? m (str @cells/form-nick))) | ||
| 634 | + (reset! cells/replying-to nil) | ||
| 635 | + (reset! cells/editing {:channel channel :id (:id m)}) | ||
| 636 | + (reset! cells/draft (or (:text m) "")))) | ||
| 637 | + ;; The box empties with it: what is in it is a copy of the line on | ||
| 638 | + ;; screen, and leaving that behind would look like a draft. | ||
| 639 | + :cancel-edit! (fn [] | ||
| 640 | + (reset! cells/editing nil) | ||
| 641 | + (reset! cells/draft "")) | ||
| 589 | :message-by-id (fn [room id] | 642 | :message-by-id (fn [room id] |
| 590 | (->> (get-in @cells/channels [room :messages]) | 643 | (->> (get-in @cells/channels [room :messages]) |
| 591 | (filter #(= id (:id %))) | 644 | (filter #(= id (:id %))) |
modified
src/frq/irc.clj +3 -15 | @@ -241,23 +241,11 @@ | ||
| 241 | 241 | "PRIVMSG " target " :" text)))) |
| 242 | 242 | |
| 243 | 243 | (defn edit! |
| 244 | - "Rewrite something already said. The `+draft/edit` tag names the message | |
| 245 | - being replaced, and what follows is its new text — the server checks that | |
| 246 | - the message was ours, files the revision under the original's id, and sends | |
| 247 | - the new line on to the channel for every client to fold in. | |
| 248 | - | |
| 249 | - A PRIVMSG rather than a TAGMSG, because an edit carries a body — and signed, | |
| 250 | - like every other thing that changes a record already written: from an account | |
| 251 | - the server answers an unsigned one with | |
| 252 | - `FAIL EDIT SIGNATURE_REQUIRED` and the message stays as it was. `peer-did` is | |
| 253 | - who a DM is with, which is half of the name a DM signature is made under." | |
| 244 | + "Rewrite something already said. The line is `frq.irc.mutate`'s; this writes | |
| 245 | + it." | |
| 254 | 246 | ([conn target msgid text] (edit! conn target msgid text nil)) |
| 255 | 247 | ([conn target msgid text peer-did] |
| 256 | - (let [tags (assoc (msgsig/edit-tags target msgid text nil peer-did) | |
| 257 | - "+draft/edit" msgid) | |
| 258 | - pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))] | |
| 259 | - (send-line! conn (str "@" (str/join ";" pairs) | |
| 260 | - " PRIVMSG " target " :" text))))) | |
| 248 | + (send-line! conn (mutate/edit-line target msgid text peer-did)))) | |
| 261 | 249 | |
| 262 | 250 | (defn tagmsg! |
| 263 | 251 | "A message that is only tags: how freeq carries a reaction, a typing hint or |
| @@ -241,23 +241,11 @@ | |||
| 241 | "PRIVMSG " target " :" text)))) | 241 | "PRIVMSG " target " :" text)))) |
| 242 | 242 | ||
| 243 | (defn edit! | 243 | (defn edit! |
| 244 | - "Rewrite something already said. The `+draft/edit` tag names the message | 244 | + "Rewrite something already said. The line is `frq.irc.mutate`'s; this writes |
| 245 | - being replaced, and what follows is its new text — the server checks that | 245 | + it." |
| 246 | - the message was ours, files the revision under the original's id, and sends | ||
| 247 | - the new line on to the channel for every client to fold in. | ||
| 248 | - | ||
| 249 | - A PRIVMSG rather than a TAGMSG, because an edit carries a body — and signed, | ||
| 250 | - like every other thing that changes a record already written: from an account | ||
| 251 | - the server answers an unsigned one with | ||
| 252 | - `FAIL EDIT SIGNATURE_REQUIRED` and the message stays as it was. `peer-did` is | ||
| 253 | - who a DM is with, which is half of the name a DM signature is made under." | ||
| 254 | ([conn target msgid text] (edit! conn target msgid text nil)) | 246 | ([conn target msgid text] (edit! conn target msgid text nil)) |
| 255 | ([conn target msgid text peer-did] | 247 | ([conn target msgid text peer-did] |
| 256 | - (let [tags (assoc (msgsig/edit-tags target msgid text nil peer-did) | 248 | + (send-line! conn (mutate/edit-line target msgid text peer-did)))) |
| 257 | - "+draft/edit" msgid) | ||
| 258 | - pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))] | ||
| 259 | - (send-line! conn (str "@" (str/join ";" pairs) | ||
| 260 | - " PRIVMSG " target " :" text))))) | ||
| 261 | 249 | ||
| 262 | (defn tagmsg! | 250 | (defn tagmsg! |
| 263 | "A message that is only tags: how freeq carries a reaction, a typing hint or | 251 | "A message that is only tags: how freeq carries a reaction, a typing hint or |
modified
src/frq/state.clj +11 -54 | @@ -8,6 +8,7 @@ | ||
| 8 | 8 | [frq.rooms :as rooms] |
| 9 | 9 | [frq.members :as members] |
| 10 | 10 | [frq.reactions :as reactions] |
| 11 | + [frq.edits :as edits] | |
| 11 | 12 | [glimmer.ratom :as r :refer [atom]] |
| 12 | 13 | [frq.actions :as actions] |
| 13 | 14 | [frq.cells :as cells] |
| @@ -564,57 +565,16 @@ | ||
| 564 | 565 | (swap! channels reactions/update-reaction channel msgid emoji nick on?)) |
| 565 | 566 | |
| 566 | 567 | (defn edit-message! |
| 567 | - "Rewrite a message in place, and say so. `msgid` names the line as it was | |
| 568 | - first sent: a message keeps the id it was born with across every revision, | |
| 569 | - which is what keeps its reactions, replies and pins attached to it. | |
| 570 | - | |
| 571 | - Only the sender may rewrite their own line, so an edit whose nick is not the | |
| 572 | - one on the message is dropped — the server checks authorship too, and a | |
| 573 | - client that believed the wire alone would let a hostile relay put words in | |
| 574 | - somebody's mouth. | |
| 575 | - | |
| 576 | - Answers what became of it: `:applied`, `:refused` for one that was not the | |
| 577 | - sender's to make, or `:absent` when no line here has that id — an edit of | |
| 578 | - something older than the backlog we asked for, which is the one case the | |
| 579 | - caller shows as a line of its own rather than losing what it says." | |
| 568 | + "Rewrite a message in place, and say so. | |
| 569 | + | |
| 570 | + `frq.edits` is the fold and what it answers; the atom and the picture links | |
| 571 | + are this half's. A message keeps the id it was born with across every | |
| 572 | + revision, which is what keeps its reactions, replies and pins attached to it." | |
| 580 | 573 | [channel msgid from text] |
| 581 | - (if-not (and channel msgid) | |
| 582 | - :absent | |
| 583 | - (let [found? (atom nil)] | |
| 584 | - (swap! channels | |
| 585 | - (fn [m] | |
| 586 | - (if-let [msgs (get-in m [channel :messages])] | |
| 587 | - (assoc-in m [channel :messages] | |
| 588 | - (mapv (fn [msg] | |
| 589 | - (if (= msgid (:id msg)) | |
| 590 | - (if (= (str/lower-case (or (:from msg) "")) | |
| 591 | - (str/lower-case (or from ""))) | |
| 592 | - (do (reset! found? :applied) | |
| 593 | - (assoc msg | |
| 594 | - :text text | |
| 595 | - :images (media/image-urls text) | |
| 596 | - :edited? true)) | |
| 597 | - ;; Somebody else's line. The server | |
| 598 | - ;; refuses this too, so it is either a | |
| 599 | - ;; peer talking to us directly or one | |
| 600 | - ;; lying — and neither gets to put words | |
| 601 | - ;; under a name that is not theirs. | |
| 602 | - (do (reset! found? :refused) msg)) | |
| 603 | - msg)) | |
| 604 | - msgs)) | |
| 605 | - m))) | |
| 606 | - ;; The revision may link a picture the original did not. | |
| 607 | - (when (= :applied @found?) | |
| 608 | - (doseq [url (media/image-urls text)] | |
| 609 | - (media/fetch! url #(swap! media-tick inc)))) | |
| 610 | - (or @found? :absent)))) | |
| 611 | - | |
| 612 | -;; --- who is in the room ------------------------------------------------------ | |
| 613 | -;; All of it is `frq.members` now: a fold over the channels map, which is the | |
| 614 | -;; same fold under either compiler. What stays here is the atom it is folded | |
| 615 | -;; into and `ensure-channel`, because a room means more to this half than to | |
| 616 | -;; the phone — unread counts, read marks, a joining flag — and the shared | |
| 617 | -;; functions deliberately only ever touch `:users` and `:names-acc`. | |
| 574 | + (let [out (edits/apply-edit @channels channel msgid from text | |
| 575 | + #(assoc % :images (media/image-urls text)))] | |
| 576 | + (reset! channels (:channels out)) | |
| 577 | + (:result out))) | |
| 618 | 578 | |
| 619 | 579 | (defn- names-line [channel names] |
| 620 | 580 | (swap! channels #(members/with-names (ensure-channel % channel) channel names))) |
| @@ -1426,10 +1386,7 @@ | ||
| 1426 | 1386 | server itself falls back to for an account with no DID — and an edit it would |
| 1427 | 1387 | refuse is one not worth offering." |
| 1428 | 1388 | [m] |
| 1429 | - (and (not (:system? m)) | |
| 1430 | - (seq (or (:from m) "")) | |
| 1431 | - (= (str/lower-case (:from m)) | |
| 1432 | - (str/lower-case (or @form-nick ""))))) | |
| 1389 | + (rooms/mine? m @form-nick)) | |
| 1433 | 1390 | |
| 1434 | 1391 | (defn start-edit! |
| 1435 | 1392 | "Put a message back in the box to be rewritten. |
| @@ -8,6 +8,7 @@ | |||
| 8 | [frq.rooms :as rooms] | 8 | [frq.rooms :as rooms] |
| 9 | [frq.members :as members] | 9 | [frq.members :as members] |
| 10 | [frq.reactions :as reactions] | 10 | [frq.reactions :as reactions] |
| 11 | + [frq.edits :as edits] | ||
| 11 | [glimmer.ratom :as r :refer [atom]] | 12 | [glimmer.ratom :as r :refer [atom]] |
| 12 | [frq.actions :as actions] | 13 | [frq.actions :as actions] |
| 13 | [frq.cells :as cells] | 14 | [frq.cells :as cells] |
| @@ -564,57 +565,16 @@ | |||
| 564 | (swap! channels reactions/update-reaction channel msgid emoji nick on?)) | 565 | (swap! channels reactions/update-reaction channel msgid emoji nick on?)) |
| 565 | 566 | ||
| 566 | (defn edit-message! | 567 | (defn edit-message! |
| 567 | - "Rewrite a message in place, and say so. `msgid` names the line as it was | 568 | + "Rewrite a message in place, and say so. |
| 568 | - first sent: a message keeps the id it was born with across every revision, | 569 | + |
| 569 | - which is what keeps its reactions, replies and pins attached to it. | 570 | + `frq.edits` is the fold and what it answers; the atom and the picture links |
| 570 | - | 571 | + are this half's. A message keeps the id it was born with across every |
| 571 | - Only the sender may rewrite their own line, so an edit whose nick is not the | 572 | + revision, which is what keeps its reactions, replies and pins attached to it." |
| 572 | - one on the message is dropped — the server checks authorship too, and a | ||
| 573 | - client that believed the wire alone would let a hostile relay put words in | ||
| 574 | - somebody's mouth. | ||
| 575 | - | ||
| 576 | - Answers what became of it: `:applied`, `:refused` for one that was not the | ||
| 577 | - sender's to make, or `:absent` when no line here has that id — an edit of | ||
| 578 | - something older than the backlog we asked for, which is the one case the | ||
| 579 | - caller shows as a line of its own rather than losing what it says." | ||
| 580 | [channel msgid from text] | 573 | [channel msgid from text] |
| 581 | - (if-not (and channel msgid) | 574 | + (let [out (edits/apply-edit @channels channel msgid from text |
| 582 | - :absent | 575 | + #(assoc % :images (media/image-urls text)))] |
| 583 | - (let [found? (atom nil)] | 576 | + (reset! channels (:channels out)) |
| 584 | - (swap! channels | 577 | + (:result out))) |
| 585 | - (fn [m] | ||
| 586 | - (if-let [msgs (get-in m [channel :messages])] | ||
| 587 | - (assoc-in m [channel :messages] | ||
| 588 | - (mapv (fn [msg] | ||
| 589 | - (if (= msgid (:id msg)) | ||
| 590 | - (if (= (str/lower-case (or (:from msg) "")) | ||
| 591 | - (str/lower-case (or from ""))) | ||
| 592 | - (do (reset! found? :applied) | ||
| 593 | - (assoc msg | ||
| 594 | - :text text | ||
| 595 | - :images (media/image-urls text) | ||
| 596 | - :edited? true)) | ||
| 597 | - ;; Somebody else's line. The server | ||
| 598 | - ;; refuses this too, so it is either a | ||
| 599 | - ;; peer talking to us directly or one | ||
| 600 | - ;; lying — and neither gets to put words | ||
| 601 | - ;; under a name that is not theirs. | ||
| 602 | - (do (reset! found? :refused) msg)) | ||
| 603 | - msg)) | ||
| 604 | - msgs)) | ||
| 605 | - m))) | ||
| 606 | - ;; The revision may link a picture the original did not. | ||
| 607 | - (when (= :applied @found?) | ||
| 608 | - (doseq [url (media/image-urls text)] | ||
| 609 | - (media/fetch! url #(swap! media-tick inc)))) | ||
| 610 | - (or @found? :absent)))) | ||
| 611 | - | ||
| 612 | -;; --- who is in the room ------------------------------------------------------ | ||
| 613 | -;; All of it is `frq.members` now: a fold over the channels map, which is the | ||
| 614 | -;; same fold under either compiler. What stays here is the atom it is folded | ||
| 615 | -;; into and `ensure-channel`, because a room means more to this half than to | ||
| 616 | -;; the phone — unread counts, read marks, a joining flag — and the shared | ||
| 617 | -;; functions deliberately only ever touch `:users` and `:names-acc`. | ||
| 618 | 578 | ||
| 619 | (defn- names-line [channel names] | 579 | (defn- names-line [channel names] |
| 620 | (swap! channels #(members/with-names (ensure-channel % channel) channel names))) | 580 | (swap! channels #(members/with-names (ensure-channel % channel) channel names))) |
| @@ -1426,10 +1386,7 @@ | |||
| 1426 | server itself falls back to for an account with no DID — and an edit it would | 1386 | server itself falls back to for an account with no DID — and an edit it would |
| 1427 | refuse is one not worth offering." | 1387 | refuse is one not worth offering." |
| 1428 | [m] | 1388 | [m] |
| 1429 | - (and (not (:system? m)) | 1389 | + (rooms/mine? m @form-nick)) |
| 1430 | - (seq (or (:from m) "")) | ||
| 1431 | - (= (str/lower-case (:from m)) | ||
| 1432 | - (str/lower-case (or @form-nick ""))))) | ||
| 1433 | 1390 | ||
| 1434 | (defn start-edit! | 1391 | (defn start-edit! |
| 1435 | "Put a message back in the box to be rewritten. | 1392 | "Put a message back in the box to be rewritten. |