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.
b52a754 parent: 43cc3f0 modified
src/frq/app.jolt +40 -14 | @@ -317,17 +317,30 @@ | ||
| 317 | 317 | press to react and what appears once you have look like one family. |
| 318 | 318 | |
| 319 | 319 | 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 | 329 | [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)}]]))) | |
| 331 | 344 | |
| 332 | 345 | (defn- reactor-card |
| 333 | 346 | "Who is on a reaction, beside the pointer resting on it. |
| @@ -556,6 +569,10 @@ | ||
| 556 | 569 | [:label {:label (:from m)}] |
| 557 | 570 | (when-let [at (:at m)] |
| 558 | 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 | 576 | ;; And the two things you can do to the message, at the far end of |
| 560 | 577 | ;; its heading: a nested row laid out from the right takes what is |
| 561 | 578 | ;; left of the width and puts the chips against the edge of it, so |
| @@ -1050,6 +1067,14 @@ | ||
| 1050 | 1067 | [:hbox {:spacing 8} |
| 1051 | 1068 | [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}] |
| 1052 | 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 | 1078 | ;; The pasted picture, above the line it will go out with. Shown rather |
| 1054 | 1079 | ;; than written into the draft: what is being sent is a picture, and a URL |
| 1055 | 1080 | ;; dropped into the entry would be an unreadable line of text sitting in |
| @@ -1071,10 +1096,11 @@ | ||
| 1071 | 1096 | ;; window and centring costs nothing. |
| 1072 | 1097 | ;; Equal air above and below, so the row sits on the middle of the strip |
| 1073 | 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 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. | |
| 1078 | 1104 | [:hbox {:spacing 8 :align :center :margin-bottom 12} |
| 1079 | 1105 | ;; narrow enough that Send keeps its place on a phone-width row |
| 1080 | 1106 | ;; 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, so | 319 | 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 :react | 331 | + [:reaction {:key :react |
| 324 | - :emoji "🙂" | 332 | + :emoji "🙂" |
| 325 | - :size pill-size | 333 | + :size pill-size |
| 326 | - :on-click #(s/open-picker! channel m)}] | 334 | + :on-click #(s/open-picker! channel m)}] |
| 327 | - [:reaction {:key :reply | 335 | + [:reaction {:key :reply |
| 328 | - :emoji "↩️" | 336 | + :emoji "↩️" |
| 329 | - :size pill-size | 337 | + :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-card | 345 | (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 of | 576 | ;; 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 is | 577 | ;; 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, so | 578 | ;; 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 rather | 1078 | ;; 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 URL | 1079 | ;; 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 in | 1080 | ;; 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 strip | 1097 | ;; 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 one | 1099 | + ;; 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. This | 1101 | + ;; 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 row | 1105 | ;; 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 field | 1106 | ;; A picture is pasted where everything else is typed: Ctrl+V. The field |
modified
src/frq/irc.jolt +19 -0 | @@ -386,6 +386,25 @@ | ||
| 386 | 386 | (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " ")) |
| 387 | 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 | 408 | (defn tagmsg! |
| 390 | 409 | "A message that is only tags: how freeq carries a reaction, a typing hint or |
| 391 | 410 | 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 or | 409 | "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 — the | 410 | 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 @@ | ||
| 203 | 203 | (str "dm:" peer-did "," our-did)) |
| 204 | 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 | 249 | (defn mutation-tags |
| 207 | 250 | "The two tags that make a mutation acceptable: the event id and the signature |
| 208 | 251 | 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-tags | 249 | (defn mutation-tags |
| 207 | "The two tags that make a mutation acceptable: the event id and the signature | 250 | "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, and | 251 | 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 @@ | ||
| 75 | 75 | (defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text]))) |
| 76 | 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 | 83 | ;; The message the emoji picker is choosing for, as `{:channel :id}`, or nil |
| 79 | 84 | ;; when it is closed. The picker is a panel over the compose bar rather than a |
| 80 | 85 | ;; screen: what is being reacted to has to stay in sight. |
| @@ -182,7 +187,7 @@ | ||
| 182 | 187 | and `:reply-to` is the one it answers. `:reactions` is what people have put |
| 183 | 188 | on it already, which on a replayed backlog the server hands over in full." |
| 184 | 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 | 191 | (let [at (or at (clock/now-ms)) |
| 187 | 192 | who (avatars/actor did from)] |
| 188 | 193 | (doseq [url (media/image-urls text)] |
| @@ -203,6 +208,11 @@ | ||
| 203 | 208 | ;; `:id` is what a reply points at, and |
| 204 | 209 | ;; `:reply-to` is what this one points at. |
| 205 | 210 | :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 | 216 | ;; emoji -> the nicks who put it there |
| 207 | 217 | :reactions (or reactions {})}) |
| 208 | 218 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) |
| @@ -220,6 +230,11 @@ | ||
| 220 | 230 | ;; A picker belongs to the message it was opened on; carrying it into another |
| 221 | 231 | ;; buffer would offer to react to something that is no longer on screen. |
| 222 | 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 | 238 | (swap! channels #(-> (ensure-channel % name) |
| 224 | 239 | (assoc-in [name :unread] 0) |
| 225 | 240 | (assoc-in [name :accessed] (swap! access-tick inc)))) |
| @@ -281,6 +296,52 @@ | ||
| 281 | 296 | msgs)) |
| 282 | 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 | 345 | ;; --- who is in the room ------------------------------------------------------ |
| 285 | 346 | ;; A channel's `:users` is nick -> mode prefix ("@", "+", or ""). The list is |
| 286 | 347 | ;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK |
| @@ -489,18 +550,41 @@ | ||
| 489 | 550 | buffer (cond |
| 490 | 551 | (str/starts-with? (or target "") "#") target |
| 491 | 552 | (= 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"))}))) | |
| 504 | 588 | ;; A message that is only tags. A reaction is the one this client reads: |
| 505 | 589 | ;; `+react` puts an emoji on the message `+reply` names, and the server's |
| 506 | 590 | ;; own `+freeq.at/unreact` takes it off again. |
| @@ -604,6 +688,12 @@ | ||
| 604 | 688 | ;; belongs. The banner is for what stops the whole app — a failed |
| 605 | 689 | ;; connection or a refused sign-in. |
| 606 | 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 | 697 | "903" (reset! status (str "Signed in as " (:handle @session))) |
| 608 | 698 | ("904" "905" "906") (do (reset! session nil) |
| 609 | 699 | ;; The broker token may still be good — but a |
| @@ -1052,6 +1142,48 @@ | ||
| 1052 | 1142 | (discard-file! copy) |
| 1053 | 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 | 1187 | (defn send-draft! |
| 1056 | 1188 | "Send the draft, with whatever picture is attached to it. |
| 1057 | 1189 | |
| @@ -1068,10 +1200,28 @@ | ||
| 1068 | 1200 | (let [text (str/trim @draft) |
| 1069 | 1201 | target @current |
| 1070 | 1202 | reply-to @replying-to |
| 1203 | + edit @editing | |
| 1071 | 1204 | {:keys [url status path] :as att} @attachment] |
| 1072 | 1205 | (cond |
| 1073 | 1206 | (not target) nil |
| 1074 | 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 | 1225 | (and (str/blank? text) (not url)) nil |
| 1076 | 1226 | :else |
| 1077 | 1227 | (let [line (str/trim (str text (when url (str " " url))))] |
| @@ -1132,17 +1282,6 @@ | ||
| 1132 | 1282 | group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog)) |
| 1133 | 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 | 1285 | (defn my-reaction? |
| 1147 | 1286 | "Whether this nick is already on that emoji — which is what makes a second |
| 1148 | 1287 | 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 nil | 83 | ;; 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 a | 84 | ;; 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 put | 187 | 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, and | 208 | ;; `: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-to | 210 | :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 there | 216 | ;; 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 another | 230 | ;; 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 is | 346 | ;; 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 NICK | 347 | ;; the server's: NAMES on the way in, and every JOIN, PART, QUIT, KICK and NICK |
| @@ -489,18 +550,41 @@ | |||
| 489 | buffer (cond | 550 | buffer (cond |
| 490 | (str/starts-with? (or target "") "#") target | 551 | (str/starts-with? (or target "") "#") target |
| 491 | (= from @form-nick) target | 552 | (= from @form-nick) target |
| 492 | - :else from)] | 553 | + :else from) |
| 493 | - (push-message! buffer from text | 554 | + ;; What this message rewrites, when it is a rewrite. The |
| 494 | - {:at at | 555 | + ;; 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 to | 558 | + ;; collapsed: replay sends one row per message, carrying |
| 498 | - ;; +reply; a client that sent the draft name | 559 | + ;; 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-reactions | 563 | + ;; 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's | 589 | ;; `+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 failed | 688 | ;; 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 a | 699 | ;; 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 @current | 1201 | target @current |
| 1070 | reply-to @replying-to | 1202 | reply-to @replying-to |
| 1203 | + edit @editing | ||
| 1071 | {:keys [url status path] :as att} @attachment] | 1204 | {:keys [url status path] :as att} @attachment] |
| 1072 | (cond | 1205 | (cond |
| 1073 | (not target) nil | 1206 | (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)) nil | 1225 | (and (str/blank? text) (not url)) nil |
| 1076 | :else | 1226 | :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 second | 1286 | "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." |