Answer to every name a line has been given, and ask for the one we lack
A message keeps the id it was born with through every revision, and the
server gives each revision a msgid of its own. So an answer to a line that
has already been rewritten names the revision — an id no client that
collapses edits has ever held. `frq.edits` said as much in as many words:
"never the revision's wire msgid, which nothing else refers to". Something
does refer to it. A reply does.
So a line now answers to more than one name. `apply-edit` keeps the
revision's msgid in `:edit-ids` as it folds the rewrite in, `answers-to?` is
the one rule about what names a line, and `message-by-id` and the reaction
tally both ask it rather than matching `:id` by hand.
That covers every edit this client watched happen. It cannot cover a backlog:
replay sends one collapsed row per message carrying the surviving msgid and
`+freeq.at/edited=1`, and the revision's own id never crosses the wire, so
there is nothing on the line to learn it from. freeq knows, though —
`GET /api/v1/messages/{msgid}` answers with `replaces_msgid` — and it has
been handing this client a bearer for that half of itself since sign-in, in
a NOTICE both frontends dropped on the floor. `frq.replies` spends it: one
question per unresolved reply, asked once per id per session whatever comes
back, and the answer is written onto the message as another name it answers
to. The same place a live edit puts it, so there is still one rule.
`fetch-text!` is the seam that makes that portable — headers and all, which
is why it could not be `frq.media`'s: a picture is fetched by URL alone and
this carries the bearer. The desktop answers it over the same hand-rolled
HTTPS `frq.atproto` already speaks, the phone over `dart:io`.
And the chip aims its click at `row-id` rather than at the name in the reply.
A line found through `answers-to?` was found under one of its other names,
while the row on screen is keyed by `row-id`: scrolling to the name in the
reply landed on nothing, so a chip could quote the right message and go
nowhere at all when it was pressed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>6908605 parent: e8f0b5f modified
common/frq/actions.cljc +1 -0 | @@ -108,6 +108,7 @@ | ||
| 108 | 108 | (defn react-from-picker! [& args] (call :react-from-picker! args)) |
| 109 | 109 | (defn recent-everywhere [& args] (call :recent-everywhere args)) |
| 110 | 110 | (defn reply-to! [& args] (call :reply-to! args)) |
| 111 | +(defn resolve-reply! [& args] (call :resolve-reply! args)) | |
| 111 | 112 | (defn start-edit! [& args] (call :start-edit! args)) |
| 112 | 113 | (defn toggle-reaction! [& args] (call :toggle-reaction! args)) |
| 113 | 114 | (defn unhover-reaction! [& args] (call :unhover-reaction! args)) |
| @@ -108,6 +108,7 @@ | |||
| 108 | (defn react-from-picker! [& args] (call :react-from-picker! args)) | 108 | (defn react-from-picker! [& args] (call :react-from-picker! args)) |
| 109 | (defn recent-everywhere [& args] (call :recent-everywhere args)) | 109 | (defn recent-everywhere [& args] (call :recent-everywhere args)) |
| 110 | (defn reply-to! [& args] (call :reply-to! args)) | 110 | (defn reply-to! [& args] (call :reply-to! args)) |
| 111 | +(defn resolve-reply! [& args] (call :resolve-reply! args)) | ||
| 111 | (defn start-edit! [& args] (call :start-edit! args)) | 112 | (defn start-edit! [& args] (call :start-edit! args)) |
| 112 | (defn toggle-reaction! [& args] (call :toggle-reaction! args)) | 113 | (defn toggle-reaction! [& args] (call :toggle-reaction! args)) |
| 113 | (defn unhover-reaction! [& args] (call :unhover-reaction! args)) | 114 | (defn unhover-reaction! [& args] (call :unhover-reaction! args)) |
modified
common/frq/cells.cljc +7 -1 | @@ -51,6 +51,12 @@ | ||
| 51 | 51 | (defonce search (atom "")) |
| 52 | 52 | |
| 53 | 53 | (defonce broker-token (atom nil)) |
| 54 | + | |
| 55 | +;; What the server hands out as a NOTICE the moment SASL succeeds, for the | |
| 56 | +;; half of freeq that is REST rather than IRC. Only `frq.replies` uses it so | |
| 57 | +;; far — asking what a msgid was, for a reply pointing at a line that arrived | |
| 58 | +;; already collapsed. Cleared on disconnect with everything else a session is. | |
| 59 | +(defonce api-bearer (atom nil)) | |
| 54 | 60 | (defonce login-url (atom nil)) ; shown while the browser is open |
| 55 | 61 | |
| 56 | 62 | ;; ------------------------------------------------------------------- chat |
| @@ -265,7 +271,7 @@ | ||
| 265 | 271 | [] |
| 266 | 272 | [screen status error connecting? form-host form-port form-tls? form-nick |
| 267 | 273 | auth-mode form-handle form-app-password session channels current |
| 268 | - join-input search broker-token login-url draft editing replying-to | |
| 274 | + join-input search broker-token api-bearer login-url draft editing replying-to | |
| 269 | 275 | attachment jump-tick show-users? hide-chat-list? overview? at-present? |
| 270 | 276 | emoji-group emoji-search highlight jump-to lightbox overview-return |
| 271 | 277 | saved-to reacting window-height window-width reaction-hover hide-join-part? |
| @@ -51,6 +51,12 @@ | |||
| 51 | (defonce search (atom "")) | 51 | (defonce search (atom "")) |
| 52 | 52 | ||
| 53 | (defonce broker-token (atom nil)) | 53 | (defonce broker-token (atom nil)) |
| 54 | + | ||
| 55 | +;; What the server hands out as a NOTICE the moment SASL succeeds, for the | ||
| 56 | +;; half of freeq that is REST rather than IRC. Only `frq.replies` uses it so | ||
| 57 | +;; far — asking what a msgid was, for a reply pointing at a line that arrived | ||
| 58 | +;; already collapsed. Cleared on disconnect with everything else a session is. | ||
| 59 | +(defonce api-bearer (atom nil)) | ||
| 54 | (defonce login-url (atom nil)) ; shown while the browser is open | 60 | (defonce login-url (atom nil)) ; shown while the browser is open |
| 55 | 61 | ||
| 56 | ;; ------------------------------------------------------------------- chat | 62 | ;; ------------------------------------------------------------------- chat |
| @@ -265,7 +271,7 @@ | |||
| 265 | [] | 271 | [] |
| 266 | [screen status error connecting? form-host form-port form-tls? form-nick | 272 | [screen status error connecting? form-host form-port form-tls? form-nick |
| 267 | auth-mode form-handle form-app-password session channels current | 273 | auth-mode form-handle form-app-password session channels current |
| 268 | - join-input search broker-token login-url draft editing replying-to | 274 | + join-input search broker-token api-bearer login-url draft editing replying-to |
| 269 | attachment jump-tick show-users? hide-chat-list? overview? at-present? | 275 | attachment jump-tick show-users? hide-chat-list? overview? at-present? |
| 270 | emoji-group emoji-search highlight jump-to lightbox overview-return | 276 | emoji-group emoji-search highlight jump-to lightbox overview-return |
| 271 | saved-to reacting window-height window-width reaction-hover hide-join-part? | 277 | saved-to reacting window-height window-width reaction-hover hide-join-part? |
modified
common/frq/edits.cljc +25 -9 | @@ -5,6 +5,13 @@ | ||
| 5 | 5 | keeps its reactions, replies and pins attached to it. So an edit is not a new |
| 6 | 6 | line: it replaces the one it names, in place, under that line's own id. |
| 7 | 7 | |
| 8 | + The revision's own msgid is kept beside it rather than dropped. Nothing else | |
| 9 | + refers to it, this used to say, which was very nearly true and wrong where | |
| 10 | + it counted: someone answering a line that has already been rewritten replies | |
| 11 | + to the wording in front of them, so the `+reply` names the revision. A | |
| 12 | + client that threw that id away held the message under a name no reply used, | |
| 13 | + and every such answer came out as a chip pointing at nothing. | |
| 14 | + | |
| 8 | 15 | Pure over the channels map, like `frq.members` and `frq.reactions`, and |
| 9 | 16 | shared for the same reason — who may rewrite what is the server's rule and |
| 10 | 17 | neither half of frq gets a say in it." |
| @@ -24,15 +31,20 @@ | ||
| 24 | 31 | that believed the wire alone would let a hostile relay put words in somebody |
| 25 | 32 | else's mouth. |
| 26 | 33 | |
| 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." | |
| 34 | + `opts` are `:decorate`, applied to the rewritten message for whatever the | |
| 35 | + caller derives from the text it now carries — the desktop re-reads the | |
| 36 | + picture links out of it there; `frq.media` is not portable and this does not | |
| 37 | + need it to be — and `:revision`, the msgid the server gave the edit itself, | |
| 38 | + which joins `:edit-ids` on the message so a reply naming it still finds the | |
| 39 | + line it belongs to. See `frq.rooms/answers-to?`." | |
| 30 | 40 | ([channels channel msgid from text] |
| 31 | - (apply-edit channels channel msgid from text identity)) | |
| 32 | - ([channels channel msgid from text decorate] | |
| 41 | + (apply-edit channels channel msgid from text nil)) | |
| 42 | + ([channels channel msgid from text opts] | |
| 33 | 43 | (if-not (and channel msgid) |
| 34 | 44 | {:channels channels :result :absent} |
| 35 | - (let [msgs (get-in channels [channel :messages])] | |
| 45 | + (let [{:keys [decorate revision]} (if (map? opts) opts {:decorate opts}) | |
| 46 | + decorate (or decorate identity) | |
| 47 | + msgs (get-in channels [channel :messages])] | |
| 36 | 48 | (if-not msgs |
| 37 | 49 | {:channels channels :result :absent} |
| 38 | 50 | (let [result (volatile! :absent) |
| @@ -41,9 +53,13 @@ | ||
| 41 | 53 | (if (= (str/lower-case (or (:from msg) "")) |
| 42 | 54 | (str/lower-case (or from ""))) |
| 43 | 55 | (do (vreset! result :applied) |
| 44 | - (decorate (assoc msg | |
| 45 | - :text text | |
| 46 | - :edited? true))) | |
| 56 | + (decorate (cond-> (assoc msg | |
| 57 | + :text text | |
| 58 | + :edited? true) | |
| 59 | + revision | |
| 60 | + (update :edit-ids | |
| 61 | + (fnil conj #{}) | |
| 62 | + revision)))) | |
| 47 | 63 | (do (vreset! result :refused) msg)) |
| 48 | 64 | msg)) |
| 49 | 65 | msgs)] |
| @@ -5,6 +5,13 @@ | |||
| 5 | keeps its reactions, replies and pins attached to it. So an edit is not a new | 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. | 6 | line: it replaces the one it names, in place, under that line's own id. |
| 7 | 7 | ||
| 8 | + The revision's own msgid is kept beside it rather than dropped. Nothing else | ||
| 9 | + refers to it, this used to say, which was very nearly true and wrong where | ||
| 10 | + it counted: someone answering a line that has already been rewritten replies | ||
| 11 | + to the wording in front of them, so the `+reply` names the revision. A | ||
| 12 | + client that threw that id away held the message under a name no reply used, | ||
| 13 | + and every such answer came out as a chip pointing at nothing. | ||
| 14 | + | ||
| 8 | Pure over the channels map, like `frq.members` and `frq.reactions`, and | 15 | 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 | 16 | 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." | 17 | neither half of frq gets a say in it." |
| @@ -24,15 +31,20 @@ | |||
| 24 | that believed the wire alone would let a hostile relay put words in somebody | 31 | that believed the wire alone would let a hostile relay put words in somebody |
| 25 | else's mouth. | 32 | else's mouth. |
| 26 | 33 | ||
| 27 | - `decorate` is applied to the rewritten message, for whatever the caller | 34 | + `opts` are `:decorate`, applied to the rewritten message for whatever the |
| 28 | - derives from the text it now carries — the desktop re-reads the picture links | 35 | + caller derives from the text it now carries — the desktop re-reads the |
| 29 | - out of it there. `frq.media` is not portable and this does not need it to be." | 36 | + picture links out of it there; `frq.media` is not portable and this does not |
| 37 | + need it to be — and `:revision`, the msgid the server gave the edit itself, | ||
| 38 | + which joins `:edit-ids` on the message so a reply naming it still finds the | ||
| 39 | + line it belongs to. See `frq.rooms/answers-to?`." | ||
| 30 | ([channels channel msgid from text] | 40 | ([channels channel msgid from text] |
| 31 | - (apply-edit channels channel msgid from text identity)) | 41 | + (apply-edit channels channel msgid from text nil)) |
| 32 | - ([channels channel msgid from text decorate] | 42 | + ([channels channel msgid from text opts] |
| 33 | (if-not (and channel msgid) | 43 | (if-not (and channel msgid) |
| 34 | {:channels channels :result :absent} | 44 | {:channels channels :result :absent} |
| 35 | - (let [msgs (get-in channels [channel :messages])] | 45 | + (let [{:keys [decorate revision]} (if (map? opts) opts {:decorate opts}) |
| 46 | + decorate (or decorate identity) | ||
| 47 | + msgs (get-in channels [channel :messages])] | ||
| 36 | (if-not msgs | 48 | (if-not msgs |
| 37 | {:channels channels :result :absent} | 49 | {:channels channels :result :absent} |
| 38 | (let [result (volatile! :absent) | 50 | (let [result (volatile! :absent) |
| @@ -41,9 +53,13 @@ | |||
| 41 | (if (= (str/lower-case (or (:from msg) "")) | 53 | (if (= (str/lower-case (or (:from msg) "")) |
| 42 | (str/lower-case (or from ""))) | 54 | (str/lower-case (or from ""))) |
| 43 | (do (vreset! result :applied) | 55 | (do (vreset! result :applied) |
| 44 | - (decorate (assoc msg | 56 | + (decorate (cond-> (assoc msg |
| 45 | - :text text | 57 | + :text text |
| 46 | - :edited? true))) | 58 | + :edited? true) |
| 59 | + revision | ||
| 60 | + (update :edit-ids | ||
| 61 | + (fnil conj #{}) | ||
| 62 | + revision)))) | ||
| 47 | (do (vreset! result :refused) msg)) | 63 | (do (vreset! result :refused) msg)) |
| 48 | msg)) | 64 | msg)) |
| 49 | msgs)] | 65 | msgs)] |
modified
common/frq/io.cljc +20 -1 | @@ -120,7 +120,7 @@ | ||
| 120 | 120 | [bytes] |
| 121 | 121 | (call :utf8-string [bytes])) |
| 122 | 122 | |
| 123 | -;; ------------------------------------------------------------------- time | |
| 123 | +;; ---------------------------------------------------------------- network | |
| 124 | 124 | |
| 125 | 125 | (defn open-url! |
| 126 | 126 | "Hand `url` to whatever shows web pages here, and say whether that worked. |
| @@ -132,6 +132,25 @@ | ||
| 132 | 132 | [url] |
| 133 | 133 | (boolean (call :open-url! [url]))) |
| 134 | 134 | |
| 135 | +(defn fetch-text! | |
| 136 | + "Ask `url` for its body, as text, and hand it to `on-done` — nil where the | |
| 137 | + request could not be made or the server refused. | |
| 138 | + | |
| 139 | + Off whatever thread the caller is on: the desktop drops it on a future and | |
| 140 | + Dart awaits it, so neither blocks a frame. The callback is the only answer; | |
| 141 | + there is no synchronous form, because one of the two sides cannot give one. | |
| 142 | + | |
| 143 | + `headers` is a map of name to value, which is the whole reason this is here | |
| 144 | + rather than in `frq.media`: a picture is fetched by URL alone, and asking | |
| 145 | + freeq about a message means carrying the bearer it handed out at sign-in. | |
| 146 | + | |
| 147 | + Text and not parsed JSON: the two sides have very different JSON, and | |
| 148 | + `frq.atproto.core/json-*` reads a string on both." | |
| 149 | + [url headers on-done] | |
| 150 | + (call :fetch-text! [url headers on-done])) | |
| 151 | + | |
| 152 | +;; ------------------------------------------------------------------- time | |
| 153 | + | |
| 135 | 154 | (defn wall-nanos [] (call :wall-nanos [])) |
| 136 | 155 | (defn mono-nanos [] (call :mono-nanos [])) |
| 137 | 156 | |
| @@ -120,7 +120,7 @@ | |||
| 120 | [bytes] | 120 | [bytes] |
| 121 | (call :utf8-string [bytes])) | 121 | (call :utf8-string [bytes])) |
| 122 | 122 | ||
| 123 | -;; ------------------------------------------------------------------- time | 123 | +;; ---------------------------------------------------------------- network |
| 124 | 124 | ||
| 125 | (defn open-url! | 125 | (defn open-url! |
| 126 | "Hand `url` to whatever shows web pages here, and say whether that worked. | 126 | "Hand `url` to whatever shows web pages here, and say whether that worked. |
| @@ -132,6 +132,25 @@ | |||
| 132 | [url] | 132 | [url] |
| 133 | (boolean (call :open-url! [url]))) | 133 | (boolean (call :open-url! [url]))) |
| 134 | 134 | ||
| 135 | +(defn fetch-text! | ||
| 136 | + "Ask `url` for its body, as text, and hand it to `on-done` — nil where the | ||
| 137 | + request could not be made or the server refused. | ||
| 138 | + | ||
| 139 | + Off whatever thread the caller is on: the desktop drops it on a future and | ||
| 140 | + Dart awaits it, so neither blocks a frame. The callback is the only answer; | ||
| 141 | + there is no synchronous form, because one of the two sides cannot give one. | ||
| 142 | + | ||
| 143 | + `headers` is a map of name to value, which is the whole reason this is here | ||
| 144 | + rather than in `frq.media`: a picture is fetched by URL alone, and asking | ||
| 145 | + freeq about a message means carrying the bearer it handed out at sign-in. | ||
| 146 | + | ||
| 147 | + Text and not parsed JSON: the two sides have very different JSON, and | ||
| 148 | + `frq.atproto.core/json-*` reads a string on both." | ||
| 149 | + [url headers on-done] | ||
| 150 | + (call :fetch-text! [url headers on-done])) | ||
| 151 | + | ||
| 152 | +;; ------------------------------------------------------------------- time | ||
| 153 | + | ||
| 135 | (defn wall-nanos [] (call :wall-nanos [])) | 154 | (defn wall-nanos [] (call :wall-nanos [])) |
| 136 | (defn mono-nanos [] (call :mono-nanos [])) | 155 | (defn mono-nanos [] (call :mono-nanos [])) |
| 137 | 156 | ||
modified
common/frq/reactions.cljc +8 -3 | @@ -10,7 +10,8 @@ | ||
| 10 | 10 | reader pressing a pill here." |
| 11 | 11 | (:require [clojure.string :as str] |
| 12 | 12 | [frq.cells :as cells] |
| 13 | - [frq.emoji :as emoji])) | |
| 13 | + [frq.emoji :as emoji] | |
| 14 | + [frq.rooms :as rooms])) | |
| 14 | 15 | |
| 15 | 16 | (defn parse-tally |
| 16 | 17 | "The server's tally of what is already on a message, as |
| @@ -49,13 +50,17 @@ | ||
| 49 | 50 | |
| 50 | 51 | The message it names may not be there — a reaction on something older than |
| 51 | 52 | the backlog we asked for — and then there is nothing to show it on, so |
| 52 | - nothing happens." | |
| 53 | + nothing happens. | |
| 54 | + | |
| 55 | + Named the way a reply names one: somebody reacting to a line that has since | |
| 56 | + been rewritten puts the emoji on the revision's msgid, which is a name the | |
| 57 | + message answers to. See `frq.rooms/answers-to?`." | |
| 53 | 58 | [channels channel msgid emoji nick on?] |
| 54 | 59 | (if (and channel msgid (seq (or emoji ""))) |
| 55 | 60 | (if-let [msgs (get-in channels [channel :messages])] |
| 56 | 61 | (assoc-in channels [channel :messages] |
| 57 | 62 | (mapv (fn [msg] |
| 58 | - (if (= msgid (:id msg)) | |
| 63 | + (if (rooms/answers-to? msg msgid) | |
| 59 | 64 | (update msg :reactions with-reaction emoji nick on?) |
| 60 | 65 | msg)) |
| 61 | 66 | msgs)) |
| @@ -10,7 +10,8 @@ | |||
| 10 | reader pressing a pill here." | 10 | reader pressing a pill here." |
| 11 | (:require [clojure.string :as str] | 11 | (:require [clojure.string :as str] |
| 12 | [frq.cells :as cells] | 12 | [frq.cells :as cells] |
| 13 | - [frq.emoji :as emoji])) | 13 | + [frq.emoji :as emoji] |
| 14 | + [frq.rooms :as rooms])) | ||
| 14 | 15 | ||
| 15 | (defn parse-tally | 16 | (defn parse-tally |
| 16 | "The server's tally of what is already on a message, as | 17 | "The server's tally of what is already on a message, as |
| @@ -49,13 +50,17 @@ | |||
| 49 | 50 | ||
| 50 | The message it names may not be there — a reaction on something older than | 51 | The message it names may not be there — a reaction on something older than |
| 51 | the backlog we asked for — and then there is nothing to show it on, so | 52 | the backlog we asked for — and then there is nothing to show it on, so |
| 52 | - nothing happens." | 53 | + nothing happens. |
| 54 | + | ||
| 55 | + Named the way a reply names one: somebody reacting to a line that has since | ||
| 56 | + been rewritten puts the emoji on the revision's msgid, which is a name the | ||
| 57 | + message answers to. See `frq.rooms/answers-to?`." | ||
| 53 | [channels channel msgid emoji nick on?] | 58 | [channels channel msgid emoji nick on?] |
| 54 | (if (and channel msgid (seq (or emoji ""))) | 59 | (if (and channel msgid (seq (or emoji ""))) |
| 55 | (if-let [msgs (get-in channels [channel :messages])] | 60 | (if-let [msgs (get-in channels [channel :messages])] |
| 56 | (assoc-in channels [channel :messages] | 61 | (assoc-in channels [channel :messages] |
| 57 | (mapv (fn [msg] | 62 | (mapv (fn [msg] |
| 58 | - (if (= msgid (:id msg)) | 63 | + (if (rooms/answers-to? msg msgid) |
| 59 | (update msg :reactions with-reaction emoji nick on?) | 64 | (update msg :reactions with-reaction emoji nick on?) |
| 60 | msg)) | 65 | msg)) |
| 61 | msgs)) | 66 | msgs)) |
added
common/frq/replies.cljc +70 -0 | new file mode 100644 | ||
| @@ -0,0 +1,70 @@ | ||
| 1 | +(ns frq.replies | |
| 2 | + "Finding the line a reply points at when the buffer does not hold that name. | |
| 3 | + | |
| 4 | + A message keeps the id it was born with through every revision, and the | |
| 5 | + server gives each revision a msgid of its own — so an answer to a line that | |
| 6 | + has already been rewritten names the revision (see `frq.edits`). While this | |
| 7 | + client is connected it watches that happen and records both names. A backlog | |
| 8 | + is the case it cannot watch: replay sends one collapsed row per message, | |
| 9 | + carrying the surviving msgid, the final text and `+freeq.at/edited=1`, and | |
| 10 | + the revision's own msgid never crosses the wire at all. Nothing on the line | |
| 11 | + says which other names it used to answer to. | |
| 12 | + | |
| 13 | + freeq knows, though. `GET /api/v1/messages/{msgid}` answers with | |
| 14 | + `replaces_msgid` — the id the revision collapsed into — so one question per | |
| 15 | + unresolved reply is enough to tie the two together. The answer is written | |
| 16 | + onto the message as another name it answers to, which is the same place a | |
| 17 | + live edit puts it, so `frq.rooms/answers-to?` is still the only rule about | |
| 18 | + what names a line. | |
| 19 | + | |
| 20 | + Asked once per id and never again: `asked` remembers every id this session | |
| 21 | + has put the question to, answer or no answer. A chip that cannot be resolved | |
| 22 | + is a chip that stays as it is — the screen is redrawn constantly and a miss | |
| 23 | + that re-asked would be a request per frame." | |
| 24 | + (:require [frq.atproto.core :as atproto] | |
| 25 | + [frq.cells :as cells] | |
| 26 | + [frq.io :as io] | |
| 27 | + [frq.rooms :as rooms])) | |
| 28 | + | |
| 29 | +(defonce ^:private asked (atom #{})) | |
| 30 | + | |
| 31 | +(defn forget-asks! | |
| 32 | + "Drop what has been asked, for a client signing in again — a new session has | |
| 33 | + a new bearer, and an id that failed under the old one deserves another go." | |
| 34 | + [] | |
| 35 | + (reset! asked #{})) | |
| 36 | + | |
| 37 | +(defn- learn! | |
| 38 | + "Record `id` as another name for the message `holds` names, if we hold it." | |
| 39 | + [channel id holds] | |
| 40 | + (when (and channel id holds) | |
| 41 | + (swap! cells/channels | |
| 42 | + (fn [chans] | |
| 43 | + (if-let [msgs (get-in chans [channel :messages])] | |
| 44 | + (assoc-in chans [channel :messages] | |
| 45 | + (mapv (fn [m] | |
| 46 | + (if (rooms/answers-to? m holds) | |
| 47 | + (update m :edit-ids (fnil conj #{}) id) | |
| 48 | + m)) | |
| 49 | + msgs)) | |
| 50 | + chans))))) | |
| 51 | + | |
| 52 | +(defn resolve! | |
| 53 | + "Ask freeq what `id` was, and tie it to the line it collapsed into. | |
| 54 | + | |
| 55 | + Does nothing without a bearer — the endpoint refuses an anonymous read of a | |
| 56 | + channel you are in — and nothing for an id already asked about. `on-change` | |
| 57 | + is called only when something was actually learned, so a caller can repaint | |
| 58 | + on it without repainting on every miss." | |
| 59 | + [channel id on-change] | |
| 60 | + (when (and channel id (not (contains? @asked id))) | |
| 61 | + (when-let [bearer (some-> @cells/api-bearer not-empty)] | |
| 62 | + (swap! asked conj id) | |
| 63 | + (io/fetch-text! | |
| 64 | + (str "https://" @cells/form-host "/api/v1/messages/" id) | |
| 65 | + {"Authorization" (str "Bearer " bearer) | |
| 66 | + "Accept" "application/json"} | |
| 67 | + (fn [body] | |
| 68 | + (when-let [holds (some-> body (atproto/json-str "replaces_msgid"))] | |
| 69 | + (learn! channel id holds) | |
| 70 | + (when on-change (on-change)))))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,70 @@ | |||
| 1 | +(ns frq.replies | ||
| 2 | + "Finding the line a reply points at when the buffer does not hold that name. | ||
| 3 | + | ||
| 4 | + A message keeps the id it was born with through every revision, and the | ||
| 5 | + server gives each revision a msgid of its own — so an answer to a line that | ||
| 6 | + has already been rewritten names the revision (see `frq.edits`). While this | ||
| 7 | + client is connected it watches that happen and records both names. A backlog | ||
| 8 | + is the case it cannot watch: replay sends one collapsed row per message, | ||
| 9 | + carrying the surviving msgid, the final text and `+freeq.at/edited=1`, and | ||
| 10 | + the revision's own msgid never crosses the wire at all. Nothing on the line | ||
| 11 | + says which other names it used to answer to. | ||
| 12 | + | ||
| 13 | + freeq knows, though. `GET /api/v1/messages/{msgid}` answers with | ||
| 14 | + `replaces_msgid` — the id the revision collapsed into — so one question per | ||
| 15 | + unresolved reply is enough to tie the two together. The answer is written | ||
| 16 | + onto the message as another name it answers to, which is the same place a | ||
| 17 | + live edit puts it, so `frq.rooms/answers-to?` is still the only rule about | ||
| 18 | + what names a line. | ||
| 19 | + | ||
| 20 | + Asked once per id and never again: `asked` remembers every id this session | ||
| 21 | + has put the question to, answer or no answer. A chip that cannot be resolved | ||
| 22 | + is a chip that stays as it is — the screen is redrawn constantly and a miss | ||
| 23 | + that re-asked would be a request per frame." | ||
| 24 | + (:require [frq.atproto.core :as atproto] | ||
| 25 | + [frq.cells :as cells] | ||
| 26 | + [frq.io :as io] | ||
| 27 | + [frq.rooms :as rooms])) | ||
| 28 | + | ||
| 29 | +(defonce ^:private asked (atom #{})) | ||
| 30 | + | ||
| 31 | +(defn forget-asks! | ||
| 32 | + "Drop what has been asked, for a client signing in again — a new session has | ||
| 33 | + a new bearer, and an id that failed under the old one deserves another go." | ||
| 34 | + [] | ||
| 35 | + (reset! asked #{})) | ||
| 36 | + | ||
| 37 | +(defn- learn! | ||
| 38 | + "Record `id` as another name for the message `holds` names, if we hold it." | ||
| 39 | + [channel id holds] | ||
| 40 | + (when (and channel id holds) | ||
| 41 | + (swap! cells/channels | ||
| 42 | + (fn [chans] | ||
| 43 | + (if-let [msgs (get-in chans [channel :messages])] | ||
| 44 | + (assoc-in chans [channel :messages] | ||
| 45 | + (mapv (fn [m] | ||
| 46 | + (if (rooms/answers-to? m holds) | ||
| 47 | + (update m :edit-ids (fnil conj #{}) id) | ||
| 48 | + m)) | ||
| 49 | + msgs)) | ||
| 50 | + chans))))) | ||
| 51 | + | ||
| 52 | +(defn resolve! | ||
| 53 | + "Ask freeq what `id` was, and tie it to the line it collapsed into. | ||
| 54 | + | ||
| 55 | + Does nothing without a bearer — the endpoint refuses an anonymous read of a | ||
| 56 | + channel you are in — and nothing for an id already asked about. `on-change` | ||
| 57 | + is called only when something was actually learned, so a caller can repaint | ||
| 58 | + on it without repainting on every miss." | ||
| 59 | + [channel id on-change] | ||
| 60 | + (when (and channel id (not (contains? @asked id))) | ||
| 61 | + (when-let [bearer (some-> @cells/api-bearer not-empty)] | ||
| 62 | + (swap! asked conj id) | ||
| 63 | + (io/fetch-text! | ||
| 64 | + (str "https://" @cells/form-host "/api/v1/messages/" id) | ||
| 65 | + {"Authorization" (str "Bearer " bearer) | ||
| 66 | + "Accept" "application/json"} | ||
| 67 | + (fn [body] | ||
| 68 | + (when-let [holds (some-> body (atproto/json-str "replaces_msgid"))] | ||
| 69 | + (learn! channel id holds) | ||
| 70 | + (when on-change (on-change)))))))) | ||
modified
common/frq/rooms.cljc +25 -0 | @@ -37,6 +37,31 @@ | ||
| 37 | 37 | [m] |
| 38 | 38 | (or (:id m) (:local-id m))) |
| 39 | 39 | |
| 40 | +(defn answers-to? | |
| 41 | + "Whether `id` names this line — by any of the names it has had. | |
| 42 | + | |
| 43 | + `row-id` is what this client calls a line; this is what everybody else may | |
| 44 | + call it. A message keeps the id it was born with through every revision (see | |
| 45 | + `frq.edits`), but the server gives each revision a msgid of its own, and | |
| 46 | + anyone replying to a line that has already been rewritten answers the | |
| 47 | + wording in front of them — so the `+reply` names the revision rather than | |
| 48 | + the original. Both are this message, so both find it. | |
| 49 | + | |
| 50 | + The local name counts too: a line this client has just sent has no msgid | |
| 51 | + until the server echoes it back, and its own reply chip points at the | |
| 52 | + `:local-id` until then." | |
| 53 | + [m id] | |
| 54 | + (boolean (and id (or (= id (:id m)) | |
| 55 | + (= id (:local-id m)) | |
| 56 | + (contains? (:edit-ids m) id))))) | |
| 57 | + | |
| 58 | +(defn message-by-id | |
| 59 | + "The message `id` names, if this buffer still holds it. See `answers-to?` | |
| 60 | + for what counts as naming one." | |
| 61 | + [channels channel id] | |
| 62 | + (when id | |
| 63 | + (first (filter #(answers-to? % id) (get-in channels [channel :messages]))))) | |
| 64 | + | |
| 40 | 65 | (defn last-preview [buffer] |
| 41 | 66 | (if-let [m (last (:messages buffer))] |
| 42 | 67 | (str (:from m) ": " (:text m)) |
| @@ -37,6 +37,31 @@ | |||
| 37 | [m] | 37 | [m] |
| 38 | (or (:id m) (:local-id m))) | 38 | (or (:id m) (:local-id m))) |
| 39 | 39 | ||
| 40 | +(defn answers-to? | ||
| 41 | + "Whether `id` names this line — by any of the names it has had. | ||
| 42 | + | ||
| 43 | + `row-id` is what this client calls a line; this is what everybody else may | ||
| 44 | + call it. A message keeps the id it was born with through every revision (see | ||
| 45 | + `frq.edits`), but the server gives each revision a msgid of its own, and | ||
| 46 | + anyone replying to a line that has already been rewritten answers the | ||
| 47 | + wording in front of them — so the `+reply` names the revision rather than | ||
| 48 | + the original. Both are this message, so both find it. | ||
| 49 | + | ||
| 50 | + The local name counts too: a line this client has just sent has no msgid | ||
| 51 | + until the server echoes it back, and its own reply chip points at the | ||
| 52 | + `:local-id` until then." | ||
| 53 | + [m id] | ||
| 54 | + (boolean (and id (or (= id (:id m)) | ||
| 55 | + (= id (:local-id m)) | ||
| 56 | + (contains? (:edit-ids m) id))))) | ||
| 57 | + | ||
| 58 | +(defn message-by-id | ||
| 59 | + "The message `id` names, if this buffer still holds it. See `answers-to?` | ||
| 60 | + for what counts as naming one." | ||
| 61 | + [channels channel id] | ||
| 62 | + (when id | ||
| 63 | + (first (filter #(answers-to? % id) (get-in channels [channel :messages]))))) | ||
| 64 | + | ||
| 40 | (defn last-preview [buffer] | 65 | (defn last-preview [buffer] |
| 41 | (if-let [m (last (:messages buffer))] | 66 | (if-let [m (last (:messages buffer))] |
| 42 | (str (:from m) ": " (:text m)) | 67 | (str (:from m) ": " (:text m)) |
modified
common/frq/screens/chat.cljc +22 -1 | @@ -545,6 +545,12 @@ | ||
| 545 | 545 | (defn- goto-message! |
| 546 | 546 | "Show message `id`, in `channel`, and say which one it was. |
| 547 | 547 | |
| 548 | + `id` is a `frq.rooms/row-id` — what the row on screen calls itself, and what | |
| 549 | + `message-row` matches a scroll target against. A caller holding some other | |
| 550 | + name for the same line (a reply names the revision of a message that has | |
| 551 | + been rewritten; see `frq.rooms/answers-to?`) resolves it to the message | |
| 552 | + first and asks for that. | |
| 553 | + | |
| 548 | 554 | Three things in a fixed order: be in the room, aim the scroll at the line, |
| 549 | 555 | and mark it once it is there. `jump-to` comes off again as soon as the frame |
| 550 | 556 | that scrolled has been painted — a scroll target that stays set pins the |
| @@ -584,15 +590,30 @@ | ||
| 584 | 590 | without knowing what it answers, and the message it answers is usually off |
| 585 | 591 | the top of the screen. Clicking takes you there." |
| 586 | 592 | [channel id] |
| 593 | + ;; A miss is a question rather than an answer. The line being answered may | |
| 594 | + ;; be one this buffer holds under another name — replay collapses an edited | |
| 595 | + ;; message onto the id it kept, and the reply names the revision — so the | |
| 596 | + ;; first render that cannot find it asks freeq what that msgid was, once. | |
| 597 | + ;; `frq.replies` does the asking, remembers what it has asked, and repaints | |
| 598 | + ;; only when it learned something, so the branch below is chosen again with | |
| 599 | + ;; the answer in hand. | |
| 600 | + (actions/resolve-reply! channel id) | |
| 587 | 601 | [:vbox {:key :reply} |
| 588 | 602 | (if-let [target (actions/message-by-id channel id)] |
| 589 | 603 | ;; A link, not a button: the chip is a pointer back to a line, not an |
| 590 | 604 | ;; action, and a filled pill above every answer was the loudest thing in |
| 591 | 605 | ;; the column. |
| 592 | 606 | [:link {:label (str "↩ " (:from target) ": " (summarise target 48)) |
| 607 | + ;; Aimed at what the row calls itself, which is not always what | |
| 608 | + ;; the reply called it: a line found through `answers-to?` was | |
| 609 | + ;; found under one of its other names — a revision's msgid — and | |
| 610 | + ;; the row on screen is keyed by `row-id`. Scrolling to the name | |
| 611 | + ;; in the reply landed on nothing at all, so the chip quoted the | |
| 612 | + ;; right message and went nowhere when it was pressed. | |
| 613 | + ;; | |
| 593 | 614 | ;; The line being answered is in the room already open, so the |
| 594 | 615 | ;; next frame is the one that scrolls. |
| 595 | - :on-click #(goto-message! channel id 120 2000)}] | |
| 616 | + :on-click #(goto-message! channel (rooms/row-id target) 120 2000)}] | |
| 596 | 617 | ;; The message it answers is older than this buffer goes. |
| 597 | 618 | [:dim-label {:label "↩ replying to an earlier message"}])]) |
| 598 | 619 | |
| @@ -545,6 +545,12 @@ | |||
| 545 | (defn- goto-message! | 545 | (defn- goto-message! |
| 546 | "Show message `id`, in `channel`, and say which one it was. | 546 | "Show message `id`, in `channel`, and say which one it was. |
| 547 | 547 | ||
| 548 | + `id` is a `frq.rooms/row-id` — what the row on screen calls itself, and what | ||
| 549 | + `message-row` matches a scroll target against. A caller holding some other | ||
| 550 | + name for the same line (a reply names the revision of a message that has | ||
| 551 | + been rewritten; see `frq.rooms/answers-to?`) resolves it to the message | ||
| 552 | + first and asks for that. | ||
| 553 | + | ||
| 548 | Three things in a fixed order: be in the room, aim the scroll at the line, | 554 | Three things in a fixed order: be in the room, aim the scroll at the line, |
| 549 | and mark it once it is there. `jump-to` comes off again as soon as the frame | 555 | and mark it once it is there. `jump-to` comes off again as soon as the frame |
| 550 | that scrolled has been painted — a scroll target that stays set pins the | 556 | that scrolled has been painted — a scroll target that stays set pins the |
| @@ -584,15 +590,30 @@ | |||
| 584 | without knowing what it answers, and the message it answers is usually off | 590 | without knowing what it answers, and the message it answers is usually off |
| 585 | the top of the screen. Clicking takes you there." | 591 | the top of the screen. Clicking takes you there." |
| 586 | [channel id] | 592 | [channel id] |
| 593 | + ;; A miss is a question rather than an answer. The line being answered may | ||
| 594 | + ;; be one this buffer holds under another name — replay collapses an edited | ||
| 595 | + ;; message onto the id it kept, and the reply names the revision — so the | ||
| 596 | + ;; first render that cannot find it asks freeq what that msgid was, once. | ||
| 597 | + ;; `frq.replies` does the asking, remembers what it has asked, and repaints | ||
| 598 | + ;; only when it learned something, so the branch below is chosen again with | ||
| 599 | + ;; the answer in hand. | ||
| 600 | + (actions/resolve-reply! channel id) | ||
| 587 | [:vbox {:key :reply} | 601 | [:vbox {:key :reply} |
| 588 | (if-let [target (actions/message-by-id channel id)] | 602 | (if-let [target (actions/message-by-id channel id)] |
| 589 | ;; A link, not a button: the chip is a pointer back to a line, not an | 603 | ;; A link, not a button: the chip is a pointer back to a line, not an |
| 590 | ;; action, and a filled pill above every answer was the loudest thing in | 604 | ;; action, and a filled pill above every answer was the loudest thing in |
| 591 | ;; the column. | 605 | ;; the column. |
| 592 | [:link {:label (str "↩ " (:from target) ": " (summarise target 48)) | 606 | [:link {:label (str "↩ " (:from target) ": " (summarise target 48)) |
| 607 | + ;; Aimed at what the row calls itself, which is not always what | ||
| 608 | + ;; the reply called it: a line found through `answers-to?` was | ||
| 609 | + ;; found under one of its other names — a revision's msgid — and | ||
| 610 | + ;; the row on screen is keyed by `row-id`. Scrolling to the name | ||
| 611 | + ;; in the reply landed on nothing at all, so the chip quoted the | ||
| 612 | + ;; right message and went nowhere when it was pressed. | ||
| 613 | + ;; | ||
| 593 | ;; The line being answered is in the room already open, so the | 614 | ;; The line being answered is in the room already open, so the |
| 594 | ;; next frame is the one that scrolls. | 615 | ;; next frame is the one that scrolls. |
| 595 | - :on-click #(goto-message! channel id 120 2000)}] | 616 | + :on-click #(goto-message! channel (rooms/row-id target) 120 2000)}] |
| 596 | ;; The message it answers is older than this buffer goes. | 617 | ;; The message it answers is older than this buffer goes. |
| 597 | [:dim-label {:label "↩ replying to an earlier message"}])]) | 618 | [:dim-label {:label "↩ replying to an earlier message"}])]) |
| 598 | 619 | ||
modified
flutter/src/frq/io/dart.cljd +32 -0 | @@ -148,11 +148,43 @@ | ||
| 148 | 148 | (do (aset ary i (first s)) |
| 149 | 149 | (recur (next s) (inc i))))))) |
| 150 | 150 | |
| 151 | +(defonce ^:private http (io/HttpClient.)) | |
| 152 | + | |
| 153 | +(defn ^:async ^:private get-text! | |
| 154 | + "One GET, its body as a string — nil for anything but a 200, and nil for a | |
| 155 | + request that never got there." | |
| 156 | + [url headers] | |
| 157 | + (try | |
| 158 | + (let [^io/HttpClientRequest req (await (.getUrl ^io/HttpClient http | |
| 159 | + (Uri.parse (str url))))] | |
| 160 | + (.set (.-headers req) "user-agent" "frq") | |
| 161 | + (doseq [[k v] headers] (.set (.-headers req) (str k) (str v))) | |
| 162 | + (let [^io/HttpClientResponse resp (await (.close req))] | |
| 163 | + (if (= 200 (.-statusCode resp)) | |
| 164 | + (await (.join (.transform resp (.-decoder conv/utf8)))) | |
| 165 | + ;; Drained, or `HttpClient` holds the connection open and hands it | |
| 166 | + ;; back out — the same rule `frq.media.dart` learned. | |
| 167 | + (do (await (.drain resp)) nil)))) | |
| 168 | + (catch Exception _ nil))) | |
| 169 | + | |
| 170 | +(defn ^:async ^:private fetch-text! | |
| 171 | + "`frq.io/fetch-text!` over dart:io. | |
| 172 | + | |
| 173 | + Async rather than a `.then` on what `get-text!` returns: cljd sees that | |
| 174 | + return as dynamic and says so, and a call it cannot resolve is one that | |
| 175 | + fails at run time rather than at the compiler. The Future this returns is | |
| 176 | + nobody's to wait on — `on-done` is where the answer goes, which is the whole | |
| 177 | + shape of the seam." | |
| 178 | + [url headers on-done] | |
| 179 | + (on-done (await (get-text! url headers))) | |
| 180 | + nil) | |
| 181 | + | |
| 151 | 182 | (defn install! |
| 152 | 183 | "`dir` is the app's storage directory, already awaited from path_provider." |
| 153 | 184 | [dir] |
| 154 | 185 | (fio/install! |
| 155 | 186 | {:getenv (fn [n] (get (.-environment io/Platform) n)) |
| 187 | + :fetch-text! fetch-text! | |
| 156 | 188 | ;; Android picks the activity: whatever answers an https VIEW intent, |
| 157 | 189 | ;; which is the browser the reader already uses and is already signed in |
| 158 | 190 | ;; to. `externalApplication` and not an in-app web view on purpose — a |
| @@ -148,11 +148,43 @@ | |||
| 148 | (do (aset ary i (first s)) | 148 | (do (aset ary i (first s)) |
| 149 | (recur (next s) (inc i))))))) | 149 | (recur (next s) (inc i))))))) |
| 150 | 150 | ||
| 151 | +(defonce ^:private http (io/HttpClient.)) | ||
| 152 | + | ||
| 153 | +(defn ^:async ^:private get-text! | ||
| 154 | + "One GET, its body as a string — nil for anything but a 200, and nil for a | ||
| 155 | + request that never got there." | ||
| 156 | + [url headers] | ||
| 157 | + (try | ||
| 158 | + (let [^io/HttpClientRequest req (await (.getUrl ^io/HttpClient http | ||
| 159 | + (Uri.parse (str url))))] | ||
| 160 | + (.set (.-headers req) "user-agent" "frq") | ||
| 161 | + (doseq [[k v] headers] (.set (.-headers req) (str k) (str v))) | ||
| 162 | + (let [^io/HttpClientResponse resp (await (.close req))] | ||
| 163 | + (if (= 200 (.-statusCode resp)) | ||
| 164 | + (await (.join (.transform resp (.-decoder conv/utf8)))) | ||
| 165 | + ;; Drained, or `HttpClient` holds the connection open and hands it | ||
| 166 | + ;; back out — the same rule `frq.media.dart` learned. | ||
| 167 | + (do (await (.drain resp)) nil)))) | ||
| 168 | + (catch Exception _ nil))) | ||
| 169 | + | ||
| 170 | +(defn ^:async ^:private fetch-text! | ||
| 171 | + "`frq.io/fetch-text!` over dart:io. | ||
| 172 | + | ||
| 173 | + Async rather than a `.then` on what `get-text!` returns: cljd sees that | ||
| 174 | + return as dynamic and says so, and a call it cannot resolve is one that | ||
| 175 | + fails at run time rather than at the compiler. The Future this returns is | ||
| 176 | + nobody's to wait on — `on-done` is where the answer goes, which is the whole | ||
| 177 | + shape of the seam." | ||
| 178 | + [url headers on-done] | ||
| 179 | + (on-done (await (get-text! url headers))) | ||
| 180 | + nil) | ||
| 181 | + | ||
| 151 | (defn install! | 182 | (defn install! |
| 152 | "`dir` is the app's storage directory, already awaited from path_provider." | 183 | "`dir` is the app's storage directory, already awaited from path_provider." |
| 153 | [dir] | 184 | [dir] |
| 154 | (fio/install! | 185 | (fio/install! |
| 155 | {:getenv (fn [n] (get (.-environment io/Platform) n)) | 186 | {:getenv (fn [n] (get (.-environment io/Platform) n)) |
| 187 | + :fetch-text! fetch-text! | ||
| 156 | ;; Android picks the activity: whatever answers an https VIEW intent, | 188 | ;; Android picks the activity: whatever answers an https VIEW intent, |
| 157 | ;; which is the browser the reader already uses and is already signed in | 189 | ;; which is the browser the reader already uses and is already signed in |
| 158 | ;; to. `externalApplication` and not an in-app web view on purpose — a | 190 | ;; to. `externalApplication` and not an in-app web view on purpose — a |
modified
flutter/src/frq/main.cljd +60 -18 | @@ -17,6 +17,7 @@ | ||
| 17 | 17 | compiler: the clock and the saved session, read through exactly the |
| 18 | 18 | namespaces the desktop reads them through." |
| 19 | 19 | (:require ["dart:async" :as async] |
| 20 | + [clojure.string :as str] | |
| 20 | 21 | ["dart:io" :as dio] |
| 21 | 22 | ["package:flutter/material.dart" :as m] |
| 22 | 23 | ["package:path_provider/path_provider.dart" :as pp] |
| @@ -45,6 +46,7 @@ | ||
| 45 | 46 | [frq.members :as members] |
| 46 | 47 | [frq.reactions :as reactions] |
| 47 | 48 | [frq.edits :as edits] |
| 49 | + [frq.replies :as replies] | |
| 48 | 50 | [frq.profile :as profile] |
| 49 | 51 | [frq.avatars.dart :as avatars] |
| 50 | 52 | ["package:image_picker/image_picker.dart" :as picker] |
| @@ -122,12 +124,17 @@ | ||
| 122 | 124 | are this half's. The links are read again off the new text because a |
| 123 | 125 | revision can add one or take one away, and a row drawing the picture its |
| 124 | 126 | line no longer mentions is the shape that gave it away. The desktop's |
| 125 | - `frq.state/edit-message!` is the same function over its own atom." | |
| 126 | - [room msgid from text] | |
| 127 | - (let [out (edits/apply-edit @cells/channels room msgid from text | |
| 128 | - #(assoc % :images (images-in text)))] | |
| 129 | - (reset! cells/channels (:channels out)) | |
| 130 | - (:result out))) | |
| 127 | + `frq.state/edit-message!` is the same function over its own atom. | |
| 128 | + | |
| 129 | + `revision` is the msgid the server gave the edit itself, kept on the message | |
| 130 | + because an answer to an already-rewritten line names that one." | |
| 131 | + ([room msgid from text] (edit-message! room msgid from text nil)) | |
| 132 | + ([room msgid from text revision] | |
| 133 | + (let [out (edits/apply-edit @cells/channels room msgid from text | |
| 134 | + {:decorate #(assoc % :images (images-in text)) | |
| 135 | + :revision revision})] | |
| 136 | + (reset! cells/channels (:channels out)) | |
| 137 | + (:result out)))) | |
| 131 | 138 | |
| 132 | 139 | ;; ------------------------------------------------------------------- rooms |
| 133 | 140 | ;; |
| @@ -418,10 +425,14 @@ | ||
| 418 | 425 | (avatars/fetch! (profile/actor (:account m) who) bump!) |
| 419 | 426 | (if edit-of |
| 420 | 427 | ;; A revision is not a new line: it replaces the one it names, under |
| 421 | - ;; that line's own id and never the revision's own wire msgid, which | |
| 422 | - ;; nothing else refers to. One older than the backlog we hold has | |
| 423 | - ;; nothing here to replace, and is shown as itself rather than lost. | |
| 424 | - (when (= :absent (edit-message! name edit-of who text)) | |
| 428 | + ;; that line's own id rather than its own wire msgid — which is not | |
| 429 | + ;; nothing, though: an answer to a line already rewritten names the | |
| 430 | + ;; revision, because the revision is the wording being answered, so | |
| 431 | + ;; it rides along as a name the message also answers to. One older | |
| 432 | + ;; than the backlog we hold has nothing here to replace, and is | |
| 433 | + ;; shown as itself rather than lost. | |
| 434 | + (when (= :absent (edit-message! name edit-of who text | |
| 435 | + (irc/tag-value tags "msgid"))) | |
| 425 | 436 | (swap! cells/channels |
| 426 | 437 | #(-> (rooms/ensure-channel % name) |
| 427 | 438 | (update-in [name :messages] conj |
| @@ -430,6 +441,9 @@ | ||
| 430 | 441 | :images (images-in text) |
| 431 | 442 | :did (:account m) |
| 432 | 443 | :id edit-of |
| 444 | + ;; Same two names, for the line shown in | |
| 445 | + ;; place of the original. | |
| 446 | + :edit-ids (set (remove nil? [(irc/tag-value tags "msgid")])) | |
| 433 | 447 | :at at |
| 434 | 448 | :actor (profile/actor (:account m) who) |
| 435 | 449 | :edited? true}) |
| @@ -783,6 +797,20 @@ | ||
| 783 | 797 | ;; because "refused" is what a |
| 784 | 798 | ;; reader needs and the code is what |
| 785 | 799 | ;; a bug report does. |
| 800 | + ;; The server's half of freeq that | |
| 801 | + ;; is REST rather than IRC: one | |
| 802 | + ;; NOTICE, straight after SASL | |
| 803 | + ;; succeeds, and the only way to get | |
| 804 | + ;; a bearer. Kept rather than shown | |
| 805 | + ;; — `frq.replies` spends it asking | |
| 806 | + ;; what a msgid was. | |
| 807 | + (and (= "NOTICE" cmd) | |
| 808 | + (str/starts-with? (str text) | |
| 809 | + "API-BEARER ")) | |
| 810 | + (reset! cells/api-bearer | |
| 811 | + (str/trim (subs (str text) | |
| 812 | + (count "API-BEARER ")))) | |
| 813 | + | |
| 786 | 814 | (= "FAIL" cmd) |
| 787 | 815 | (let [ps (vec (:params m)) |
| 788 | 816 | why (str (or (first ps) "Request") |
| @@ -849,6 +877,9 @@ | ||
| 849 | 877 | (reset! closing? true) |
| 850 | 878 | (when-let [c @conn] (net/close! c)) |
| 851 | 879 | (reset! conn nil) |
| 880 | + ;; The bearer is this session's, and so is every answer asked for with it. | |
| 881 | + (reset! cells/api-bearer nil) | |
| 882 | + (replies/forget-asks!) | |
| 852 | 883 | (forget-memberships!) |
| 853 | 884 | (reset! cells/connecting? false) |
| 854 | 885 | (reset! cells/status "Not connected") |
| @@ -1044,10 +1075,11 @@ | ||
| 1044 | 1075 | (reset! cells/replying-to nil) |
| 1045 | 1076 | (reset! cells/draft "")))))) |
| 1046 | 1077 | |
| 1047 | -(defn- message-by-id [room id] | |
| 1048 | - (when id | |
| 1049 | - (first (filter #(= id (rooms/row-id %)) | |
| 1050 | - (get-in @cells/channels [room :messages]))))) | |
| 1078 | +(defn- message-by-id | |
| 1079 | + "The message `id` names — by any name it has had. `frq.rooms/answers-to?` | |
| 1080 | + is the rule, shared so the desktop resolves a reply the same way." | |
| 1081 | + [room id] | |
| 1082 | + (rooms/message-by-id @cells/channels room id)) | |
| 1051 | 1083 | |
| 1052 | 1084 | (defn- toggle-reaction! |
| 1053 | 1085 | "Put my emoji on a message, or take it off if it is already mine. |
| @@ -1184,6 +1216,10 @@ | ||
| 1184 | 1216 | ;; Answering. Both are a cell and nothing else — what the composer does |
| 1185 | 1217 | ;; with `replying-to` is the shared screen's business. |
| 1186 | 1218 | :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text]))) |
| 1219 | + ;; A reply chip that found nothing asks what that msgid was. `bump!` is | |
| 1220 | + ;; the same repaint a picture or a face arriving uses — the same shape | |
| 1221 | + ;; of answer, landing after the row that wanted it was drawn. | |
| 1222 | + :resolve-reply! (fn [channel id] (replies/resolve! channel id bump!)) | |
| 1187 | 1223 | :cancel-reply! (fn [] (reset! cells/replying-to nil)) |
| 1188 | 1224 | |
| 1189 | 1225 | ;; Reacting. The pill itself, then the picker behind it. |
| @@ -1266,10 +1302,11 @@ | ||
| 1266 | 1302 | :cancel-edit! (fn [] |
| 1267 | 1303 | (reset! cells/editing nil) |
| 1268 | 1304 | (reset! cells/draft "")) |
| 1269 | - :message-by-id (fn [room id] | |
| 1270 | - (->> (get-in @cells/channels [room :messages]) | |
| 1271 | - (filter #(= id (:id %))) | |
| 1272 | - first)) | |
| 1305 | + ;; By either name, like the desktop's: a line this client sent has no | |
| 1306 | + ;; server msgid yet and is pointed at by the `:local-id` it made for | |
| 1307 | + ;; itself, so matching on `:id` alone lost the chip and the way back to | |
| 1308 | + ;; it for every message of our own. | |
| 1309 | + :message-by-id (fn [room id] (message-by-id room id)) | |
| 1273 | 1310 | ;; Where a face is. A URL and not a path, which is what the desktop |
| 1274 | 1311 | ;; answers — see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`, |
| 1275 | 1312 | ;; which is the one place that has to know which it got. Both names |
| @@ -1308,6 +1345,11 @@ | ||
| 1308 | 1345 | :quit! (fn [] (dio/exit 0)) |
| 1309 | 1346 | :forget-session! (fn [] |
| 1310 | 1347 | (reset! cells/broker-token nil) |
| 1348 | + ;; The bearer belongs to the session being dropped, | |
| 1349 | + ;; and what was asked under it deserves asking again | |
| 1350 | + ;; under the next one. | |
| 1351 | + (reset! cells/api-bearer nil) | |
| 1352 | + (replies/forget-asks!) | |
| 1311 | 1353 | (reset! cells/session nil)) |
| 1312 | 1354 | ;; Back to the newest line. Both halves, in the order `frq.state` gives |
| 1313 | 1355 | ;; them: the flag is what takes the button off the screen, and the tick |
| @@ -17,6 +17,7 @@ | |||
| 17 | compiler: the clock and the saved session, read through exactly the | 17 | compiler: the clock and the saved session, read through exactly the |
| 18 | namespaces the desktop reads them through." | 18 | namespaces the desktop reads them through." |
| 19 | (:require ["dart:async" :as async] | 19 | (:require ["dart:async" :as async] |
| 20 | + [clojure.string :as str] | ||
| 20 | ["dart:io" :as dio] | 21 | ["dart:io" :as dio] |
| 21 | ["package:flutter/material.dart" :as m] | 22 | ["package:flutter/material.dart" :as m] |
| 22 | ["package:path_provider/path_provider.dart" :as pp] | 23 | ["package:path_provider/path_provider.dart" :as pp] |
| @@ -45,6 +46,7 @@ | |||
| 45 | [frq.members :as members] | 46 | [frq.members :as members] |
| 46 | [frq.reactions :as reactions] | 47 | [frq.reactions :as reactions] |
| 47 | [frq.edits :as edits] | 48 | [frq.edits :as edits] |
| 49 | + [frq.replies :as replies] | ||
| 48 | [frq.profile :as profile] | 50 | [frq.profile :as profile] |
| 49 | [frq.avatars.dart :as avatars] | 51 | [frq.avatars.dart :as avatars] |
| 50 | ["package:image_picker/image_picker.dart" :as picker] | 52 | ["package:image_picker/image_picker.dart" :as picker] |
| @@ -122,12 +124,17 @@ | |||
| 122 | are this half's. The links are read again off the new text because a | 124 | are this half's. The links are read again off the new text because a |
| 123 | revision can add one or take one away, and a row drawing the picture its | 125 | revision can add one or take one away, and a row drawing the picture its |
| 124 | line no longer mentions is the shape that gave it away. The desktop's | 126 | line no longer mentions is the shape that gave it away. The desktop's |
| 125 | - `frq.state/edit-message!` is the same function over its own atom." | 127 | + `frq.state/edit-message!` is the same function over its own atom. |
| 126 | - [room msgid from text] | 128 | + |
| 127 | - (let [out (edits/apply-edit @cells/channels room msgid from text | 129 | + `revision` is the msgid the server gave the edit itself, kept on the message |
| 128 | - #(assoc % :images (images-in text)))] | 130 | + because an answer to an already-rewritten line names that one." |
| 129 | - (reset! cells/channels (:channels out)) | 131 | + ([room msgid from text] (edit-message! room msgid from text nil)) |
| 130 | - (:result out))) | 132 | + ([room msgid from text revision] |
| 133 | + (let [out (edits/apply-edit @cells/channels room msgid from text | ||
| 134 | + {:decorate #(assoc % :images (images-in text)) | ||
| 135 | + :revision revision})] | ||
| 136 | + (reset! cells/channels (:channels out)) | ||
| 137 | + (:result out)))) | ||
| 131 | 138 | ||
| 132 | ;; ------------------------------------------------------------------- rooms | 139 | ;; ------------------------------------------------------------------- rooms |
| 133 | ;; | 140 | ;; |
| @@ -418,10 +425,14 @@ | |||
| 418 | (avatars/fetch! (profile/actor (:account m) who) bump!) | 425 | (avatars/fetch! (profile/actor (:account m) who) bump!) |
| 419 | (if edit-of | 426 | (if edit-of |
| 420 | ;; A revision is not a new line: it replaces the one it names, under | 427 | ;; A revision is not a new line: it replaces the one it names, under |
| 421 | - ;; that line's own id and never the revision's own wire msgid, which | 428 | + ;; that line's own id rather than its own wire msgid — which is not |
| 422 | - ;; nothing else refers to. One older than the backlog we hold has | 429 | + ;; nothing, though: an answer to a line already rewritten names the |
| 423 | - ;; nothing here to replace, and is shown as itself rather than lost. | 430 | + ;; revision, because the revision is the wording being answered, so |
| 424 | - (when (= :absent (edit-message! name edit-of who text)) | 431 | + ;; it rides along as a name the message also answers to. One older |
| 432 | + ;; than the backlog we hold has nothing here to replace, and is | ||
| 433 | + ;; shown as itself rather than lost. | ||
| 434 | + (when (= :absent (edit-message! name edit-of who text | ||
| 435 | + (irc/tag-value tags "msgid"))) | ||
| 425 | (swap! cells/channels | 436 | (swap! cells/channels |
| 426 | #(-> (rooms/ensure-channel % name) | 437 | #(-> (rooms/ensure-channel % name) |
| 427 | (update-in [name :messages] conj | 438 | (update-in [name :messages] conj |
| @@ -430,6 +441,9 @@ | |||
| 430 | :images (images-in text) | 441 | :images (images-in text) |
| 431 | :did (:account m) | 442 | :did (:account m) |
| 432 | :id edit-of | 443 | :id edit-of |
| 444 | + ;; Same two names, for the line shown in | ||
| 445 | + ;; place of the original. | ||
| 446 | + :edit-ids (set (remove nil? [(irc/tag-value tags "msgid")])) | ||
| 433 | :at at | 447 | :at at |
| 434 | :actor (profile/actor (:account m) who) | 448 | :actor (profile/actor (:account m) who) |
| 435 | :edited? true}) | 449 | :edited? true}) |
| @@ -783,6 +797,20 @@ | |||
| 783 | ;; because "refused" is what a | 797 | ;; because "refused" is what a |
| 784 | ;; reader needs and the code is what | 798 | ;; reader needs and the code is what |
| 785 | ;; a bug report does. | 799 | ;; a bug report does. |
| 800 | + ;; The server's half of freeq that | ||
| 801 | + ;; is REST rather than IRC: one | ||
| 802 | + ;; NOTICE, straight after SASL | ||
| 803 | + ;; succeeds, and the only way to get | ||
| 804 | + ;; a bearer. Kept rather than shown | ||
| 805 | + ;; — `frq.replies` spends it asking | ||
| 806 | + ;; what a msgid was. | ||
| 807 | + (and (= "NOTICE" cmd) | ||
| 808 | + (str/starts-with? (str text) | ||
| 809 | + "API-BEARER ")) | ||
| 810 | + (reset! cells/api-bearer | ||
| 811 | + (str/trim (subs (str text) | ||
| 812 | + (count "API-BEARER ")))) | ||
| 813 | + | ||
| 786 | (= "FAIL" cmd) | 814 | (= "FAIL" cmd) |
| 787 | (let [ps (vec (:params m)) | 815 | (let [ps (vec (:params m)) |
| 788 | why (str (or (first ps) "Request") | 816 | why (str (or (first ps) "Request") |
| @@ -849,6 +877,9 @@ | |||
| 849 | (reset! closing? true) | 877 | (reset! closing? true) |
| 850 | (when-let [c @conn] (net/close! c)) | 878 | (when-let [c @conn] (net/close! c)) |
| 851 | (reset! conn nil) | 879 | (reset! conn nil) |
| 880 | + ;; The bearer is this session's, and so is every answer asked for with it. | ||
| 881 | + (reset! cells/api-bearer nil) | ||
| 882 | + (replies/forget-asks!) | ||
| 852 | (forget-memberships!) | 883 | (forget-memberships!) |
| 853 | (reset! cells/connecting? false) | 884 | (reset! cells/connecting? false) |
| 854 | (reset! cells/status "Not connected") | 885 | (reset! cells/status "Not connected") |
| @@ -1044,10 +1075,11 @@ | |||
| 1044 | (reset! cells/replying-to nil) | 1075 | (reset! cells/replying-to nil) |
| 1045 | (reset! cells/draft "")))))) | 1076 | (reset! cells/draft "")))))) |
| 1046 | 1077 | ||
| 1047 | -(defn- message-by-id [room id] | 1078 | +(defn- message-by-id |
| 1048 | - (when id | 1079 | + "The message `id` names — by any name it has had. `frq.rooms/answers-to?` |
| 1049 | - (first (filter #(= id (rooms/row-id %)) | 1080 | + is the rule, shared so the desktop resolves a reply the same way." |
| 1050 | - (get-in @cells/channels [room :messages]))))) | 1081 | + [room id] |
| 1082 | + (rooms/message-by-id @cells/channels room id)) | ||
| 1051 | 1083 | ||
| 1052 | (defn- toggle-reaction! | 1084 | (defn- toggle-reaction! |
| 1053 | "Put my emoji on a message, or take it off if it is already mine. | 1085 | "Put my emoji on a message, or take it off if it is already mine. |
| @@ -1184,6 +1216,10 @@ | |||
| 1184 | ;; Answering. Both are a cell and nothing else — what the composer does | 1216 | ;; Answering. Both are a cell and nothing else — what the composer does |
| 1185 | ;; with `replying-to` is the shared screen's business. | 1217 | ;; with `replying-to` is the shared screen's business. |
| 1186 | :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text]))) | 1218 | :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text]))) |
| 1219 | + ;; A reply chip that found nothing asks what that msgid was. `bump!` is | ||
| 1220 | + ;; the same repaint a picture or a face arriving uses — the same shape | ||
| 1221 | + ;; of answer, landing after the row that wanted it was drawn. | ||
| 1222 | + :resolve-reply! (fn [channel id] (replies/resolve! channel id bump!)) | ||
| 1187 | :cancel-reply! (fn [] (reset! cells/replying-to nil)) | 1223 | :cancel-reply! (fn [] (reset! cells/replying-to nil)) |
| 1188 | 1224 | ||
| 1189 | ;; Reacting. The pill itself, then the picker behind it. | 1225 | ;; Reacting. The pill itself, then the picker behind it. |
| @@ -1266,10 +1302,11 @@ | |||
| 1266 | :cancel-edit! (fn [] | 1302 | :cancel-edit! (fn [] |
| 1267 | (reset! cells/editing nil) | 1303 | (reset! cells/editing nil) |
| 1268 | (reset! cells/draft "")) | 1304 | (reset! cells/draft "")) |
| 1269 | - :message-by-id (fn [room id] | 1305 | + ;; By either name, like the desktop's: a line this client sent has no |
| 1270 | - (->> (get-in @cells/channels [room :messages]) | 1306 | + ;; server msgid yet and is pointed at by the `:local-id` it made for |
| 1271 | - (filter #(= id (:id %))) | 1307 | + ;; itself, so matching on `:id` alone lost the chip and the way back to |
| 1272 | - first)) | 1308 | + ;; it for every message of our own. |
| 1309 | + :message-by-id (fn [room id] (message-by-id room id)) | ||
| 1273 | ;; Where a face is. A URL and not a path, which is what the desktop | 1310 | ;; Where a face is. A URL and not a path, which is what the desktop |
| 1274 | ;; answers — see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`, | 1311 | ;; answers — see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`, |
| 1275 | ;; which is the one place that has to know which it got. Both names | 1312 | ;; which is the one place that has to know which it got. Both names |
| @@ -1308,6 +1345,11 @@ | |||
| 1308 | :quit! (fn [] (dio/exit 0)) | 1345 | :quit! (fn [] (dio/exit 0)) |
| 1309 | :forget-session! (fn [] | 1346 | :forget-session! (fn [] |
| 1310 | (reset! cells/broker-token nil) | 1347 | (reset! cells/broker-token nil) |
| 1348 | + ;; The bearer belongs to the session being dropped, | ||
| 1349 | + ;; and what was asked under it deserves asking again | ||
| 1350 | + ;; under the next one. | ||
| 1351 | + (reset! cells/api-bearer nil) | ||
| 1352 | + (replies/forget-asks!) | ||
| 1311 | (reset! cells/session nil)) | 1353 | (reset! cells/session nil)) |
| 1312 | ;; Back to the newest line. Both halves, in the order `frq.state` gives | 1354 | ;; Back to the newest line. Both halves, in the order `frq.state` gives |
| 1313 | ;; them: the flag is what takes the button off the screen, and the tick | 1355 | ;; them: the flag is what takes the button off the screen, and the tick |
modified
src/frq/atproto.clj +26 -19 | @@ -37,25 +37,32 @@ | ||
| 37 | 37 | |
| 38 | 38 | (defn request |
| 39 | 39 | "One HTTPS request, connection-per-request. Returns the response body. |
| 40 | - `body` nil makes it a GET." | |
| 41 | - [host path body] | |
| 42 | - (tls/ensure-native!) | |
| 43 | - (let [t (tls/tls-connect host 443) | |
| 44 | - payload (or body "") | |
| 45 | - head (str (if body "POST " "GET ") path " HTTP/1.1\r\n" | |
| 46 | - "Host: " host "\r\n" | |
| 47 | - "User-Agent: frq\r\n" | |
| 48 | - "Accept: application/json\r\n" | |
| 49 | - (when body | |
| 50 | - (str "Content-Type: application/json\r\n" | |
| 51 | - "Content-Length: " (count (.getBytes payload)) "\r\n")) | |
| 52 | - "Connection: close\r\n\r\n")] | |
| 53 | - (try | |
| 54 | - (tls/tls-write t (.getBytes (str head payload))) | |
| 55 | - (let [resp (read-all! t) | |
| 56 | - [_ b] (str/split resp #"\r\n\r\n" 2)] | |
| 57 | - (or b "")) | |
| 58 | - (finally (try (tls/tls-close t) (catch Exception _ nil)))))) | |
| 40 | + `body` nil makes it a GET. | |
| 41 | + | |
| 42 | + `headers` are written after the ones every request here carries, for a | |
| 43 | + caller that has to identify itself — `frq.io.jolt`'s `fetch-text!` sends the | |
| 44 | + freeq bearer this way. A header named twice is the caller's problem; nothing | |
| 45 | + here overrides what it is given." | |
| 46 | + ([host path body] (request host path body nil)) | |
| 47 | + ([host path body headers] | |
| 48 | + (tls/ensure-native!) | |
| 49 | + (let [t (tls/tls-connect host 443) | |
| 50 | + payload (or body "") | |
| 51 | + head (str (if body "POST " "GET ") path " HTTP/1.1\r\n" | |
| 52 | + "Host: " host "\r\n" | |
| 53 | + "User-Agent: frq\r\n" | |
| 54 | + "Accept: application/json\r\n" | |
| 55 | + (apply str (for [[k v] headers] (str k ": " v "\r\n"))) | |
| 56 | + (when body | |
| 57 | + (str "Content-Type: application/json\r\n" | |
| 58 | + "Content-Length: " (count (.getBytes payload)) "\r\n")) | |
| 59 | + "Connection: close\r\n\r\n")] | |
| 60 | + (try | |
| 61 | + (tls/tls-write t (.getBytes (str head payload))) | |
| 62 | + (let [resp (read-all! t) | |
| 63 | + [_ b] (str/split resp #"\r\n\r\n" 2)] | |
| 64 | + (or b "")) | |
| 65 | + (finally (try (tls/tls-close t) (catch Exception _ nil))))))) | |
| 59 | 66 | |
| 60 | 67 | ;; ------------------------------------------------------------------ JSON |
| 61 | 68 | |
| @@ -37,25 +37,32 @@ | |||
| 37 | 37 | ||
| 38 | (defn request | 38 | (defn request |
| 39 | "One HTTPS request, connection-per-request. Returns the response body. | 39 | "One HTTPS request, connection-per-request. Returns the response body. |
| 40 | - `body` nil makes it a GET." | 40 | + `body` nil makes it a GET. |
| 41 | - [host path body] | 41 | + |
| 42 | - (tls/ensure-native!) | 42 | + `headers` are written after the ones every request here carries, for a |
| 43 | - (let [t (tls/tls-connect host 443) | 43 | + caller that has to identify itself — `frq.io.jolt`'s `fetch-text!` sends the |
| 44 | - payload (or body "") | 44 | + freeq bearer this way. A header named twice is the caller's problem; nothing |
| 45 | - head (str (if body "POST " "GET ") path " HTTP/1.1\r\n" | 45 | + here overrides what it is given." |
| 46 | - "Host: " host "\r\n" | 46 | + ([host path body] (request host path body nil)) |
| 47 | - "User-Agent: frq\r\n" | 47 | + ([host path body headers] |
| 48 | - "Accept: application/json\r\n" | 48 | + (tls/ensure-native!) |
| 49 | - (when body | 49 | + (let [t (tls/tls-connect host 443) |
| 50 | - (str "Content-Type: application/json\r\n" | 50 | + payload (or body "") |
| 51 | - "Content-Length: " (count (.getBytes payload)) "\r\n")) | 51 | + head (str (if body "POST " "GET ") path " HTTP/1.1\r\n" |
| 52 | - "Connection: close\r\n\r\n")] | 52 | + "Host: " host "\r\n" |
| 53 | - (try | 53 | + "User-Agent: frq\r\n" |
| 54 | - (tls/tls-write t (.getBytes (str head payload))) | 54 | + "Accept: application/json\r\n" |
| 55 | - (let [resp (read-all! t) | 55 | + (apply str (for [[k v] headers] (str k ": " v "\r\n"))) |
| 56 | - [_ b] (str/split resp #"\r\n\r\n" 2)] | 56 | + (when body |
| 57 | - (or b "")) | 57 | + (str "Content-Type: application/json\r\n" |
| 58 | - (finally (try (tls/tls-close t) (catch Exception _ nil)))))) | 58 | + "Content-Length: " (count (.getBytes payload)) "\r\n")) |
| 59 | + "Connection: close\r\n\r\n")] | ||
| 60 | + (try | ||
| 61 | + (tls/tls-write t (.getBytes (str head payload))) | ||
| 62 | + (let [resp (read-all! t) | ||
| 63 | + [_ b] (str/split resp #"\r\n\r\n" 2)] | ||
| 64 | + (or b "")) | ||
| 65 | + (finally (try (tls/tls-close t) (catch Exception _ nil))))))) | ||
| 59 | 66 | ||
| 60 | ;; ------------------------------------------------------------------ JSON | 67 | ;; ------------------------------------------------------------------ JSON |
| 61 | 68 | ||
modified
src/frq/io/jolt.clj +19 -0 | @@ -5,6 +5,7 @@ | ||
| 5 | 5 | the seam is that `frq.store` and `frq.clock` under `common/` never mention a |
| 6 | 6 | backend. Every desktop entry point requires this before `frq.app`." |
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | + [frq.atproto :as atproto] | |
| 8 | 9 | [frq.io :as io] |
| 9 | 10 | [frq.platform :as platform] |
| 10 | 11 | [jolt.host :as host])) |
| @@ -101,9 +102,27 @@ | ||
| 101 | 102 | true |
| 102 | 103 | (catch Exception _ false))) |
| 103 | 104 | |
| 105 | +(defn- fetch-text! | |
| 106 | + "`frq.io/fetch-text!` over the same hand-rolled HTTPS `frq.atproto` uses. | |
| 107 | + | |
| 108 | + On a future, because the caller is a UI thread and this is a socket: the | |
| 109 | + answer arrives at `on-done` whenever it arrives, and a request that throws | |
| 110 | + answers nil rather than taking the thread down with it. https only — every | |
| 111 | + URL this is asked for is one frq built out of the host the reader signed in | |
| 112 | + to." | |
| 113 | + [url headers on-done] | |
| 114 | + (future | |
| 115 | + (on-done | |
| 116 | + (try | |
| 117 | + (let [[_ host path] (re-matches #"https://([^/]+)(/.*)?" (str url))] | |
| 118 | + (when host | |
| 119 | + (atproto/request host (or path "/") nil headers))) | |
| 120 | + (catch Exception _ nil))))) | |
| 121 | + | |
| 104 | 122 | (io/install! |
| 105 | 123 | {:getenv host/getenv |
| 106 | 124 | :open-url! platform/open-url! |
| 125 | + :fetch-text! fetch-text! | |
| 107 | 126 | :config-dir config-dir |
| 108 | 127 | :file-exists? host/file-exists? |
| 109 | 128 | :directory? host/directory? |
| @@ -5,6 +5,7 @@ | |||
| 5 | the seam is that `frq.store` and `frq.clock` under `common/` never mention a | 5 | the seam is that `frq.store` and `frq.clock` under `common/` never mention a |
| 6 | backend. Every desktop entry point requires this before `frq.app`." | 6 | backend. Every desktop entry point requires this before `frq.app`." |
| 7 | (:require [clojure.string :as str] | 7 | (:require [clojure.string :as str] |
| 8 | + [frq.atproto :as atproto] | ||
| 8 | [frq.io :as io] | 9 | [frq.io :as io] |
| 9 | [frq.platform :as platform] | 10 | [frq.platform :as platform] |
| 10 | [jolt.host :as host])) | 11 | [jolt.host :as host])) |
| @@ -101,9 +102,27 @@ | |||
| 101 | true | 102 | true |
| 102 | (catch Exception _ false))) | 103 | (catch Exception _ false))) |
| 103 | 104 | ||
| 105 | +(defn- fetch-text! | ||
| 106 | + "`frq.io/fetch-text!` over the same hand-rolled HTTPS `frq.atproto` uses. | ||
| 107 | + | ||
| 108 | + On a future, because the caller is a UI thread and this is a socket: the | ||
| 109 | + answer arrives at `on-done` whenever it arrives, and a request that throws | ||
| 110 | + answers nil rather than taking the thread down with it. https only — every | ||
| 111 | + URL this is asked for is one frq built out of the host the reader signed in | ||
| 112 | + to." | ||
| 113 | + [url headers on-done] | ||
| 114 | + (future | ||
| 115 | + (on-done | ||
| 116 | + (try | ||
| 117 | + (let [[_ host path] (re-matches #"https://([^/]+)(/.*)?" (str url))] | ||
| 118 | + (when host | ||
| 119 | + (atproto/request host (or path "/") nil headers))) | ||
| 120 | + (catch Exception _ nil))))) | ||
| 121 | + | ||
| 104 | (io/install! | 122 | (io/install! |
| 105 | {:getenv host/getenv | 123 | {:getenv host/getenv |
| 106 | :open-url! platform/open-url! | 124 | :open-url! platform/open-url! |
| 125 | + :fetch-text! fetch-text! | ||
| 107 | :config-dir config-dir | 126 | :config-dir config-dir |
| 108 | :file-exists? host/file-exists? | 127 | :file-exists? host/file-exists? |
| 109 | :directory? host/directory? | 128 | :directory? host/directory? |
modified
src/frq/state.clj +57 -18 | @@ -12,6 +12,7 @@ | ||
| 12 | 12 | [glimmer.ratom :as r :refer [atom]] |
| 13 | 13 | [frq.actions :as actions] |
| 14 | 14 | [frq.cells :as cells] |
| 15 | + [frq.replies :as replies] | |
| 15 | 16 | [jolt.host :as host] |
| 16 | 17 | [frq.atproto :as atproto] |
| 17 | 18 | [frq.av :as av] |
| @@ -298,7 +299,7 @@ | ||
| 298 | 299 | and `:reply-to` is the one it answers. `:reactions` is what people have put |
| 299 | 300 | on it already, which on a replayed backlog the server hands over in full." |
| 300 | 301 | ([channel from text] (push-message! channel from text {})) |
| 301 | - ([channel from text {:keys [at did id reply-to reactions edited?]}] | |
| 302 | + ([channel from text {:keys [at did id reply-to reactions edited? edit-ids]}] | |
| 302 | 303 | (let [at (or at (clock/now-ms)) |
| 303 | 304 | ;; A name of our own where the server gave none. See `local-id`. |
| 304 | 305 | mine (when-not id (local-id channel from text at)) |
| @@ -350,6 +351,9 @@ | ||
| 350 | 351 | ;; `:id` is what a reply points at, and |
| 351 | 352 | ;; `:reply-to` is what this one points at. |
| 352 | 353 | :id id :local-id mine :reply-to reply-to |
| 354 | + ;; Any other msgid this same line answers to | |
| 355 | + ;; — a revision's. See `frq.rooms/answers-to?`. | |
| 356 | + :edit-ids (set (remove nil? edit-ids)) | |
| 353 | 357 | ;; The sender has since rewritten this line. |
| 354 | 358 | ;; Replay says so with a tag rather than by |
| 355 | 359 | ;; sending the revision, so a message can |
| @@ -460,12 +464,16 @@ | ||
| 460 | 464 | |
| 461 | 465 | `frq.edits` is the fold and what it answers; the atom and the picture links |
| 462 | 466 | are this half's. A message keeps the id it was born with across every |
| 463 | - revision, which is what keeps its reactions, replies and pins attached to it." | |
| 464 | - [channel msgid from text] | |
| 465 | - (let [out (edits/apply-edit @channels channel msgid from text | |
| 466 | - #(assoc % :images (media/image-urls text)))] | |
| 467 | - (reset! channels (:channels out)) | |
| 468 | - (:result out))) | |
| 467 | + revision, which is what keeps its reactions, replies and pins attached to it | |
| 468 | + — and `revision`, the msgid of the edit itself, is kept on it too, because a | |
| 469 | + reply to an already-rewritten line names that one." | |
| 470 | + ([channel msgid from text] (edit-message! channel msgid from text nil)) | |
| 471 | + ([channel msgid from text revision] | |
| 472 | + (let [out (edits/apply-edit @channels channel msgid from text | |
| 473 | + {:decorate #(assoc % :images (media/image-urls text)) | |
| 474 | + :revision revision})] | |
| 475 | + (reset! channels (:channels out)) | |
| 476 | + (:result out)))) | |
| 469 | 477 | |
| 470 | 478 | (defn- names-line [channel names] |
| 471 | 479 | (swap! channels #(members/with-names (ensure-channel % channel) channel names))) |
| @@ -650,15 +658,23 @@ | ||
| 650 | 658 | replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))] |
| 651 | 659 | (if edit-of |
| 652 | 660 | ;; A revision is not a new line: it replaces the one it |
| 653 | - ;; names, under that line's own id — never the revision's | |
| 654 | - ;; wire msgid, which nothing else refers to. | |
| 655 | - (when (= :absent (edit-message! buffer edit-of from text)) | |
| 661 | + ;; names, under that line's own id rather than its own | |
| 662 | + ;; wire msgid. That msgid is not nothing, though — an | |
| 663 | + ;; answer to a line already rewritten names the revision, | |
| 664 | + ;; because the revision is the wording being answered — so | |
| 665 | + ;; it rides along on the message as a name it also | |
| 666 | + ;; answers to. | |
| 667 | + (when (= :absent (edit-message! buffer edit-of from text | |
| 668 | + (irc/tag-value tags "msgid"))) | |
| 656 | 669 | ;; The original is outside the backlog we hold, so show |
| 657 | 670 | ;; the current text rather than dropping what was said. |
| 658 | 671 | (push-message! buffer from text |
| 659 | 672 | {:at at |
| 660 | 673 | :did (:account msg) |
| 661 | 674 | :id edit-of |
| 675 | + ;; Same two names, for the line we are | |
| 676 | + ;; showing in place of the original. | |
| 677 | + :edit-ids #{(irc/tag-value tags "msgid")} | |
| 662 | 678 | :edited? true |
| 663 | 679 | :reply-to (or (irc/tag-value tags "+reply") |
| 664 | 680 | (irc/tag-value tags "+draft/reply"))})) |
| @@ -799,10 +815,21 @@ | ||
| 799 | 815 | (when (str/starts-with? (or target "") "#") |
| 800 | 816 | (apply-mode! target modes args))) |
| 801 | 817 | "NOTICE" |
| 802 | - (if-let [ch @policy-asking] | |
| 803 | - (swap! channels #(update-in (ensure-channel % ch) [ch :policy-text] | |
| 804 | - (fnil conj []) (str/trimr (or (last params) "")))) | |
| 805 | - (reset! status (or (last params) @status))) | |
| 818 | + (let [text (str/trimr (or (last params) ""))] | |
| 819 | + (cond | |
| 820 | + ;; The server's half of freeq that is REST rather than IRC: sent | |
| 821 | + ;; once, straight after SASL succeeds, and the only way to get one. | |
| 822 | + ;; Kept rather than shown — see `frq.replies`, which spends it | |
| 823 | + ;; asking what a msgid was. | |
| 824 | + (str/starts-with? text "API-BEARER ") | |
| 825 | + (reset! cells/api-bearer (str/trim (subs text (count "API-BEARER ")))) | |
| 826 | + | |
| 827 | + @policy-asking | |
| 828 | + (swap! channels #(update-in (ensure-channel % @policy-asking) | |
| 829 | + [@policy-asking :policy-text] | |
| 830 | + (fnil conj []) text)) | |
| 831 | + | |
| 832 | + :else (reset! status (or (last params) @status)))) | |
| 806 | 833 | ("372" "375" "376" "002" "003" "004") |
| 807 | 834 | (reset! status (or (last params) @status)) |
| 808 | 835 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, |
| @@ -848,6 +875,11 @@ | ||
| 848 | 875 | ;; when the session ends, and signing with it afterwards would be |
| 849 | 876 | ;; signing with a key nobody can check. |
| 850 | 877 | "*DISCONNECTED*" (do (msgsig/forget!) |
| 878 | + ;; The bearer belongs to the session that is over, | |
| 879 | + ;; and what was asked under it deserves asking | |
| 880 | + ;; again under the next one. | |
| 881 | + (reset! cells/api-bearer nil) | |
| 882 | + (replies/forget-asks!) | |
| 851 | 883 | (reset! conn nil) |
| 852 | 884 | (reset! connecting? false) |
| 853 | 885 | (swap! channels |
| @@ -1004,6 +1036,8 @@ | ||
| 1004 | 1036 | [] |
| 1005 | 1037 | (store/clear-session!) |
| 1006 | 1038 | (reset! broker-token nil) |
| 1039 | + (reset! cells/api-bearer nil) | |
| 1040 | + (replies/forget-asks!) | |
| 1007 | 1041 | (reset! session nil) |
| 1008 | 1042 | (reset! auth-mode :guest) |
| 1009 | 1043 | (reset! status "Not connected")) |
| @@ -1530,11 +1564,11 @@ | ||
| 1530 | 1564 | (defn message-by-id |
| 1531 | 1565 | "The message a reply points at, if this buffer still holds it. |
| 1532 | 1566 | |
| 1533 | - By either name, since a jump may be aiming at a line the server never gave | |
| 1534 | - one to. See `local-id`." | |
| 1567 | + By any name it has had — a `:local-id` for a line the server never named, | |
| 1568 | + and the msgid of any revision of it. `frq.rooms/answers-to?` is that rule, | |
| 1569 | + shared so both halves resolve a reply the same way." | |
| 1535 | 1570 | [channel id] |
| 1536 | - (when id | |
| 1537 | - (first (filter #(= id (rooms/row-id %)) (get-in @channels [channel :messages]))))) | |
| 1571 | + (rooms/message-by-id @channels channel id)) | |
| 1538 | 1572 | |
| 1539 | 1573 | (defn react-from-picker! |
| 1540 | 1574 | "Put the chosen emoji on the message the picker was opened for, and close it. |
| @@ -1626,6 +1660,11 @@ | ||
| 1626 | 1660 | :react-from-picker! react-from-picker! |
| 1627 | 1661 | :recent-everywhere recent-everywhere |
| 1628 | 1662 | :reply-to! reply-to! |
| 1663 | + ;; A reply chip that found nothing asks what that msgid was; the repaint is | |
| 1664 | + ;; the same tick a picture or a face arriving uses, because it is the same | |
| 1665 | + ;; shape of answer — something a row read, arriving after the row was drawn. | |
| 1666 | + :resolve-reply! (fn [channel id] | |
| 1667 | + (replies/resolve! channel id #(swap! media-tick inc))) | |
| 1629 | 1668 | :start-edit! start-edit! |
| 1630 | 1669 | :toggle-reaction! toggle-reaction! |
| 1631 | 1670 | :unhover-reaction! unhover-reaction!}) |
| @@ -12,6 +12,7 @@ | |||
| 12 | [glimmer.ratom :as r :refer [atom]] | 12 | [glimmer.ratom :as r :refer [atom]] |
| 13 | [frq.actions :as actions] | 13 | [frq.actions :as actions] |
| 14 | [frq.cells :as cells] | 14 | [frq.cells :as cells] |
| 15 | + [frq.replies :as replies] | ||
| 15 | [jolt.host :as host] | 16 | [jolt.host :as host] |
| 16 | [frq.atproto :as atproto] | 17 | [frq.atproto :as atproto] |
| 17 | [frq.av :as av] | 18 | [frq.av :as av] |
| @@ -298,7 +299,7 @@ | |||
| 298 | and `:reply-to` is the one it answers. `:reactions` is what people have put | 299 | and `:reply-to` is the one it answers. `:reactions` is what people have put |
| 299 | on it already, which on a replayed backlog the server hands over in full." | 300 | on it already, which on a replayed backlog the server hands over in full." |
| 300 | ([channel from text] (push-message! channel from text {})) | 301 | ([channel from text] (push-message! channel from text {})) |
| 301 | - ([channel from text {:keys [at did id reply-to reactions edited?]}] | 302 | + ([channel from text {:keys [at did id reply-to reactions edited? edit-ids]}] |
| 302 | (let [at (or at (clock/now-ms)) | 303 | (let [at (or at (clock/now-ms)) |
| 303 | ;; A name of our own where the server gave none. See `local-id`. | 304 | ;; A name of our own where the server gave none. See `local-id`. |
| 304 | mine (when-not id (local-id channel from text at)) | 305 | mine (when-not id (local-id channel from text at)) |
| @@ -350,6 +351,9 @@ | |||
| 350 | ;; `:id` is what a reply points at, and | 351 | ;; `:id` is what a reply points at, and |
| 351 | ;; `:reply-to` is what this one points at. | 352 | ;; `:reply-to` is what this one points at. |
| 352 | :id id :local-id mine :reply-to reply-to | 353 | :id id :local-id mine :reply-to reply-to |
| 354 | + ;; Any other msgid this same line answers to | ||
| 355 | + ;; — a revision's. See `frq.rooms/answers-to?`. | ||
| 356 | + :edit-ids (set (remove nil? edit-ids)) | ||
| 353 | ;; The sender has since rewritten this line. | 357 | ;; The sender has since rewritten this line. |
| 354 | ;; Replay says so with a tag rather than by | 358 | ;; Replay says so with a tag rather than by |
| 355 | ;; sending the revision, so a message can | 359 | ;; sending the revision, so a message can |
| @@ -460,12 +464,16 @@ | |||
| 460 | 464 | ||
| 461 | `frq.edits` is the fold and what it answers; the atom and the picture links | 465 | `frq.edits` is the fold and what it answers; the atom and the picture links |
| 462 | are this half's. A message keeps the id it was born with across every | 466 | are this half's. A message keeps the id it was born with across every |
| 463 | - revision, which is what keeps its reactions, replies and pins attached to it." | 467 | + revision, which is what keeps its reactions, replies and pins attached to it |
| 464 | - [channel msgid from text] | 468 | + — and `revision`, the msgid of the edit itself, is kept on it too, because a |
| 465 | - (let [out (edits/apply-edit @channels channel msgid from text | 469 | + reply to an already-rewritten line names that one." |
| 466 | - #(assoc % :images (media/image-urls text)))] | 470 | + ([channel msgid from text] (edit-message! channel msgid from text nil)) |
| 467 | - (reset! channels (:channels out)) | 471 | + ([channel msgid from text revision] |
| 468 | - (:result out))) | 472 | + (let [out (edits/apply-edit @channels channel msgid from text |
| 473 | + {:decorate #(assoc % :images (media/image-urls text)) | ||
| 474 | + :revision revision})] | ||
| 475 | + (reset! channels (:channels out)) | ||
| 476 | + (:result out)))) | ||
| 469 | 477 | ||
| 470 | (defn- names-line [channel names] | 478 | (defn- names-line [channel names] |
| 471 | (swap! channels #(members/with-names (ensure-channel % channel) channel names))) | 479 | (swap! channels #(members/with-names (ensure-channel % channel) channel names))) |
| @@ -650,15 +658,23 @@ | |||
| 650 | replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))] | 658 | replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))] |
| 651 | (if edit-of | 659 | (if edit-of |
| 652 | ;; A revision is not a new line: it replaces the one it | 660 | ;; A revision is not a new line: it replaces the one it |
| 653 | - ;; names, under that line's own id — never the revision's | 661 | + ;; names, under that line's own id rather than its own |
| 654 | - ;; wire msgid, which nothing else refers to. | 662 | + ;; wire msgid. That msgid is not nothing, though — an |
| 655 | - (when (= :absent (edit-message! buffer edit-of from text)) | 663 | + ;; answer to a line already rewritten names the revision, |
| 664 | + ;; because the revision is the wording being answered — so | ||
| 665 | + ;; it rides along on the message as a name it also | ||
| 666 | + ;; answers to. | ||
| 667 | + (when (= :absent (edit-message! buffer edit-of from text | ||
| 668 | + (irc/tag-value tags "msgid"))) | ||
| 656 | ;; The original is outside the backlog we hold, so show | 669 | ;; The original is outside the backlog we hold, so show |
| 657 | ;; the current text rather than dropping what was said. | 670 | ;; the current text rather than dropping what was said. |
| 658 | (push-message! buffer from text | 671 | (push-message! buffer from text |
| 659 | {:at at | 672 | {:at at |
| 660 | :did (:account msg) | 673 | :did (:account msg) |
| 661 | :id edit-of | 674 | :id edit-of |
| 675 | + ;; Same two names, for the line we are | ||
| 676 | + ;; showing in place of the original. | ||
| 677 | + :edit-ids #{(irc/tag-value tags "msgid")} | ||
| 662 | :edited? true | 678 | :edited? true |
| 663 | :reply-to (or (irc/tag-value tags "+reply") | 679 | :reply-to (or (irc/tag-value tags "+reply") |
| 664 | (irc/tag-value tags "+draft/reply"))})) | 680 | (irc/tag-value tags "+draft/reply"))})) |
| @@ -799,10 +815,21 @@ | |||
| 799 | (when (str/starts-with? (or target "") "#") | 815 | (when (str/starts-with? (or target "") "#") |
| 800 | (apply-mode! target modes args))) | 816 | (apply-mode! target modes args))) |
| 801 | "NOTICE" | 817 | "NOTICE" |
| 802 | - (if-let [ch @policy-asking] | 818 | + (let [text (str/trimr (or (last params) ""))] |
| 803 | - (swap! channels #(update-in (ensure-channel % ch) [ch :policy-text] | 819 | + (cond |
| 804 | - (fnil conj []) (str/trimr (or (last params) "")))) | 820 | + ;; The server's half of freeq that is REST rather than IRC: sent |
| 805 | - (reset! status (or (last params) @status))) | 821 | + ;; once, straight after SASL succeeds, and the only way to get one. |
| 822 | + ;; Kept rather than shown — see `frq.replies`, which spends it | ||
| 823 | + ;; asking what a msgid was. | ||
| 824 | + (str/starts-with? text "API-BEARER ") | ||
| 825 | + (reset! cells/api-bearer (str/trim (subs text (count "API-BEARER ")))) | ||
| 826 | + | ||
| 827 | + @policy-asking | ||
| 828 | + (swap! channels #(update-in (ensure-channel % @policy-asking) | ||
| 829 | + [@policy-asking :policy-text] | ||
| 830 | + (fnil conj []) text)) | ||
| 831 | + | ||
| 832 | + :else (reset! status (or (last params) @status)))) | ||
| 806 | ("372" "375" "376" "002" "003" "004") | 833 | ("372" "375" "376" "002" "003" "004") |
| 807 | (reset! status (or (last params) @status)) | 834 | (reset! status (or (last params) @status)) |
| 808 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, | 835 | ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration, |
| @@ -848,6 +875,11 @@ | |||
| 848 | ;; when the session ends, and signing with it afterwards would be | 875 | ;; when the session ends, and signing with it afterwards would be |
| 849 | ;; signing with a key nobody can check. | 876 | ;; signing with a key nobody can check. |
| 850 | "*DISCONNECTED*" (do (msgsig/forget!) | 877 | "*DISCONNECTED*" (do (msgsig/forget!) |
| 878 | + ;; The bearer belongs to the session that is over, | ||
| 879 | + ;; and what was asked under it deserves asking | ||
| 880 | + ;; again under the next one. | ||
| 881 | + (reset! cells/api-bearer nil) | ||
| 882 | + (replies/forget-asks!) | ||
| 851 | (reset! conn nil) | 883 | (reset! conn nil) |
| 852 | (reset! connecting? false) | 884 | (reset! connecting? false) |
| 853 | (swap! channels | 885 | (swap! channels |
| @@ -1004,6 +1036,8 @@ | |||
| 1004 | [] | 1036 | [] |
| 1005 | (store/clear-session!) | 1037 | (store/clear-session!) |
| 1006 | (reset! broker-token nil) | 1038 | (reset! broker-token nil) |
| 1039 | + (reset! cells/api-bearer nil) | ||
| 1040 | + (replies/forget-asks!) | ||
| 1007 | (reset! session nil) | 1041 | (reset! session nil) |
| 1008 | (reset! auth-mode :guest) | 1042 | (reset! auth-mode :guest) |
| 1009 | (reset! status "Not connected")) | 1043 | (reset! status "Not connected")) |
| @@ -1530,11 +1564,11 @@ | |||
| 1530 | (defn message-by-id | 1564 | (defn message-by-id |
| 1531 | "The message a reply points at, if this buffer still holds it. | 1565 | "The message a reply points at, if this buffer still holds it. |
| 1532 | 1566 | ||
| 1533 | - By either name, since a jump may be aiming at a line the server never gave | 1567 | + By any name it has had — a `:local-id` for a line the server never named, |
| 1534 | - one to. See `local-id`." | 1568 | + and the msgid of any revision of it. `frq.rooms/answers-to?` is that rule, |
| 1569 | + shared so both halves resolve a reply the same way." | ||
| 1535 | [channel id] | 1570 | [channel id] |
| 1536 | - (when id | 1571 | + (rooms/message-by-id @channels channel id)) |
| 1537 | - (first (filter #(= id (rooms/row-id %)) (get-in @channels [channel :messages]))))) | ||
| 1538 | 1572 | ||
| 1539 | (defn react-from-picker! | 1573 | (defn react-from-picker! |
| 1540 | "Put the chosen emoji on the message the picker was opened for, and close it. | 1574 | "Put the chosen emoji on the message the picker was opened for, and close it. |
| @@ -1626,6 +1660,11 @@ | |||
| 1626 | :react-from-picker! react-from-picker! | 1660 | :react-from-picker! react-from-picker! |
| 1627 | :recent-everywhere recent-everywhere | 1661 | :recent-everywhere recent-everywhere |
| 1628 | :reply-to! reply-to! | 1662 | :reply-to! reply-to! |
| 1663 | + ;; A reply chip that found nothing asks what that msgid was; the repaint is | ||
| 1664 | + ;; the same tick a picture or a face arriving uses, because it is the same | ||
| 1665 | + ;; shape of answer — something a row read, arriving after the row was drawn. | ||
| 1666 | + :resolve-reply! (fn [channel id] | ||
| 1667 | + (replies/resolve! channel id #(swap! media-tick inc))) | ||
| 1629 | :start-edit! start-edit! | 1668 | :start-edit! start-edit! |
| 1630 | :toggle-reaction! toggle-reaction! | 1669 | :toggle-reaction! toggle-reaction! |
| 1631 | :unhover-reaction! unhover-reaction!}) | 1670 | :unhover-reaction! unhover-reaction!}) |