nandi/frqpublic Fork 0
6908605
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-13T11:17:19-07:00 Browse files
6908605 parent: e8f0b5f
modified common/frq/actions.cljc +1 -0
@@ -108,6 +108,7 @@
108108 (defn react-from-picker! [& args] (call :react-from-picker! args))
109109 (defn recent-everywhere [& args] (call :recent-everywhere args))
110110 (defn reply-to! [& args] (call :reply-to! args))
111+(defn resolve-reply! [& args] (call :resolve-reply! args))
111112 (defn start-edit! [& args] (call :start-edit! args))
112113 (defn toggle-reaction! [& args] (call :toggle-reaction! args))
113114 (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 @@
5151 (defonce search (atom ""))
5252
5353 (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))
5460 (defonce login-url (atom nil)) ; shown while the browser is open
5561
5662 ;; ------------------------------------------------------------------- chat
@@ -265,7 +271,7 @@
265271 []
266272 [screen status error connecting? form-host form-port form-tls? form-nick
267273 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
269275 attachment jump-tick show-users? hide-chat-list? overview? at-present?
270276 emoji-group emoji-search highlight jump-to lightbox overview-return
271277 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 open60 (defonce login-url (atom nil)) ; shown while the browser is open
55 61
56 ;; ------------------------------------------------------------------- chat62 ;; ------------------------------------------------------------------- chat
@@ -265,7 +271,7 @@
265 []271 []
266 [screen status error connecting? form-host form-port form-tls? form-nick272 [screen status error connecting? form-host form-port form-tls? form-nick
267 auth-mode form-handle form-app-password session channels current273 auth-mode form-handle form-app-password session channels current
268- join-input search broker-token login-url draft editing replying-to274+ 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-return276 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 @@
55 keeps its reactions, replies and pins attached to it. So an edit is not a new
66 line: it replaces the one it names, in place, under that line's own id.
77
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+
815 Pure over the channels map, like `frq.members` and `frq.reactions`, and
916 shared for the same reason who may rewrite what is the server's rule and
1017 neither half of frq gets a say in it."
@@ -24,15 +31,20 @@
2431 that believed the wire alone would let a hostile relay put words in somebody
2532 else's mouth.
2633
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?`."
3040 ([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]
3343 (if-not (and channel msgid)
3444 {: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])]
3648 (if-not msgs
3749 {:channels channels :result :absent}
3850 (let [result (volatile! :absent)
@@ -41,9 +53,13 @@
4153 (if (= (str/lower-case (or (:from msg) ""))
4254 (str/lower-case (or from "")))
4355 (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))))
4763 (do (vreset! result :refused) msg))
4864 msg))
4965 msgs)]
@@ -5,6 +5,13 @@
5 keeps its reactions, replies and pins attached to it. So an edit is not a new5 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`, and15 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 and16 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 somebody31 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 caller34+ `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 links35+ 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 msgs48 (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 msg56+ (decorate (cond-> (assoc msg
45- :text text57+ :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 @@
120120 [bytes]
121121 (call :utf8-string [bytes]))
122122
123-;; ------------------------------------------------------------------- time
123+;; ---------------------------------------------------------------- network
124124
125125 (defn open-url!
126126 "Hand `url` to whatever shows web pages here, and say whether that worked.
@@ -132,6 +132,25 @@
132132 [url]
133133 (boolean (call :open-url! [url])))
134134
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+
135154 (defn wall-nanos [] (call :wall-nanos []))
136155 (defn mono-nanos [] (call :mono-nanos []))
137156
@@ -120,7 +120,7 @@
120 [bytes]120 [bytes]
121 (call :utf8-string [bytes]))121 (call :utf8-string [bytes]))
122 122
123-;; ------------------------------------------------------------------- time123+;; ---------------------------------------------------------------- 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 @@
1010 reader pressing a pill here."
1111 (:require [clojure.string :as str]
1212 [frq.cells :as cells]
13- [frq.emoji :as emoji]))
13+ [frq.emoji :as emoji]
14+ [frq.rooms :as rooms]))
1415
1516 (defn parse-tally
1617 "The server's tally of what is already on a message, as
@@ -49,13 +50,17 @@
4950
5051 The message it names may not be there — a reaction on something older than
5152 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?`."
5358 [channels channel msgid emoji nick on?]
5459 (if (and channel msgid (seq (or emoji "")))
5560 (if-let [msgs (get-in channels [channel :messages])]
5661 (assoc-in channels [channel :messages]
5762 (mapv (fn [msg]
58- (if (= msgid (:id msg))
63+ (if (rooms/answers-to? msg msgid)
5964 (update msg :reactions with-reaction emoji nick on?)
6065 msg))
6166 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-tally16 (defn parse-tally
16 "The server's tally of what is already on a message, as17 "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 than51 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, so52 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 @@
3737 [m]
3838 (or (:id m) (:local-id m)))
3939
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+
4065 (defn last-preview [buffer]
4166 (if-let [m (last (:messages buffer))]
4267 (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 @@
545545 (defn- goto-message!
546546 "Show message `id`, in `channel`, and say which one it was.
547547
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+
548554 Three things in a fixed order: be in the room, aim the scroll at the line,
549555 and mark it once it is there. `jump-to` comes off again as soon as the frame
550556 that scrolled has been painted — a scroll target that stays set pins the
@@ -584,15 +590,30 @@
584590 without knowing what it answers, and the message it answers is usually off
585591 the top of the screen. Clicking takes you there."
586592 [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)
587601 [:vbox {:key :reply}
588602 (if-let [target (actions/message-by-id channel id)]
589603 ;; A link, not a button: the chip is a pointer back to a line, not an
590604 ;; action, and a filled pill above every answer was the loudest thing in
591605 ;; the column.
592606 [: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+ ;;
593614 ;; The line being answered is in the room already open, so the
594615 ;; 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)}]
596617 ;; The message it answers is older than this buffer goes.
597618 [:dim-label {:label "↩ replying to an earlier message"}])])
598619
@@ -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 frame555 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 the556 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 off590 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 an603 ;; 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 in604 ;; 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 the614 ;; 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 @@
148148 (do (aset ary i (first s))
149149 (recur (next s) (inc i)))))))
150150
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+
151182 (defn install!
152183 "`dir` is the app's storage directory, already awaited from path_provider."
153184 [dir]
154185 (fio/install!
155186 {:getenv (fn [n] (get (.-environment io/Platform) n))
187+ :fetch-text! fetch-text!
156188 ;; Android picks the activity: whatever answers an https VIEW intent,
157189 ;; which is the browser the reader already uses and is already signed in
158190 ;; 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 in189 ;; 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 a190 ;; to. `externalApplication` and not an in-app web view on purpose a
modified flutter/src/frq/main.cljd +60 -18
@@ -17,6 +17,7 @@
1717 compiler: the clock and the saved session, read through exactly the
1818 namespaces the desktop reads them through."
1919 (:require ["dart:async" :as async]
20+ [clojure.string :as str]
2021 ["dart:io" :as dio]
2122 ["package:flutter/material.dart" :as m]
2223 ["package:path_provider/path_provider.dart" :as pp]
@@ -45,6 +46,7 @@
4546 [frq.members :as members]
4647 [frq.reactions :as reactions]
4748 [frq.edits :as edits]
49+ [frq.replies :as replies]
4850 [frq.profile :as profile]
4951 [frq.avatars.dart :as avatars]
5052 ["package:image_picker/image_picker.dart" :as picker]
@@ -122,12 +124,17 @@
122124 are this half's. The links are read again off the new text because a
123125 revision can add one or take one away, and a row drawing the picture its
124126 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))))
131138
132139 ;; ------------------------------------------------------------------- rooms
133140 ;;
@@ -418,10 +425,14 @@
418425 (avatars/fetch! (profile/actor (:account m) who) bump!)
419426 (if edit-of
420427 ;; 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")))
425436 (swap! cells/channels
426437 #(-> (rooms/ensure-channel % name)
427438 (update-in [name :messages] conj
@@ -430,6 +441,9 @@
430441 :images (images-in text)
431442 :did (:account m)
432443 :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")]))
433447 :at at
434448 :actor (profile/actor (:account m) who)
435449 :edited? true})
@@ -783,6 +797,20 @@
783797 ;; because "refused" is what a
784798 ;; reader needs and the code is what
785799 ;; 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+
786814 (= "FAIL" cmd)
787815 (let [ps (vec (:params m))
788816 why (str (or (first ps) "Request")
@@ -849,6 +877,9 @@
849877 (reset! closing? true)
850878 (when-let [c @conn] (net/close! c))
851879 (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!)
852883 (forget-memberships!)
853884 (reset! cells/connecting? false)
854885 (reset! cells/status "Not connected")
@@ -1044,10 +1075,11 @@
10441075 (reset! cells/replying-to nil)
10451076 (reset! cells/draft ""))))))
10461077
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))
10511083
10521084 (defn- toggle-reaction!
10531085 "Put my emoji on a message, or take it off if it is already mine.
@@ -1184,6 +1216,10 @@
11841216 ;; Answering. Both are a cell and nothing else what the composer does
11851217 ;; with `replying-to` is the shared screen's business.
11861218 :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!))
11871223 :cancel-reply! (fn [] (reset! cells/replying-to nil))
11881224
11891225 ;; Reacting. The pill itself, then the picker behind it.
@@ -1266,10 +1302,11 @@
12661302 :cancel-edit! (fn []
12671303 (reset! cells/editing nil)
12681304 (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))
12731310 ;; Where a face is. A URL and not a path, which is what the desktop
12741311 ;; answers see `frq.avatars.dart`, and `:avatar` in `frq.hiccup`,
12751312 ;; which is the one place that has to know which it got. Both names
@@ -1308,6 +1345,11 @@
13081345 :quit! (fn [] (dio/exit 0))
13091346 :forget-session! (fn []
13101347 (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!)
13111353 (reset! cells/session nil))
13121354 ;; Back to the newest line. Both halves, in the order `frq.state` gives
13131355 ;; 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 the17 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 a124 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 its125 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's126 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 text129+ `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 ;; ------------------------------------------------------------------- rooms139 ;; ------------------------------------------------------------------- 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-of426 (if edit-of
420 ;; A revision is not a new line: it replaces the one it names, under427 ;; 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, which428+ ;; 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 has429+ ;; 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/channels436 (swap! cells/channels
426 #(-> (rooms/ensure-channel % name)437 #(-> (rooms/ensure-channel % name)
427 (update-in [name :messages] conj438 (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-of443 :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 at447 :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 a797 ;; because "refused" is what a
784 ;; reader needs and the code is what798 ;; 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 id1079+ "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 does1216 ;; 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 desktop1310 ;; 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 names1312 ;; 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` gives1354 ;; 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 tick1355 ;; 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 @@
3737
3838 (defn request
3939 "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)))))))
5966
6067 ;; ------------------------------------------------------------------ JSON
6168
@@ -37,25 +37,32 @@
37 37
38 (defn request38 (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 body49+ (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- (try53+ "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 ;; ------------------------------------------------------------------ JSON67 ;; ------------------------------------------------------------------ JSON
61 68
modified src/frq/io/jolt.clj +19 -0
@@ -5,6 +5,7 @@
55 the seam is that `frq.store` and `frq.clock` under `common/` never mention a
66 backend. Every desktop entry point requires this before `frq.app`."
77 (:require [clojure.string :as str]
8+ [frq.atproto :as atproto]
89 [frq.io :as io]
910 [frq.platform :as platform]
1011 [jolt.host :as host]))
@@ -101,9 +102,27 @@
101102 true
102103 (catch Exception _ false)))
103104
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+
104122 (io/install!
105123 {:getenv host/getenv
106124 :open-url! platform/open-url!
125+ :fetch-text! fetch-text!
107126 :config-dir config-dir
108127 :file-exists? host/file-exists?
109128 :directory? host/directory?
@@ -5,6 +5,7 @@
5 the seam is that `frq.store` and `frq.clock` under `common/` never mention a5 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 true102 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/getenv123 {:getenv host/getenv
106 :open-url! platform/open-url!124 :open-url! platform/open-url!
125+ :fetch-text! fetch-text!
107 :config-dir config-dir126 :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 @@
1212 [glimmer.ratom :as r :refer [atom]]
1313 [frq.actions :as actions]
1414 [frq.cells :as cells]
15+ [frq.replies :as replies]
1516 [jolt.host :as host]
1617 [frq.atproto :as atproto]
1718 [frq.av :as av]
@@ -298,7 +299,7 @@
298299 and `:reply-to` is the one it answers. `:reactions` is what people have put
299300 on it already, which on a replayed backlog the server hands over in full."
300301 ([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]}]
302303 (let [at (or at (clock/now-ms))
303304 ;; A name of our own where the server gave none. See `local-id`.
304305 mine (when-not id (local-id channel from text at))
@@ -350,6 +351,9 @@
350351 ;; `:id` is what a reply points at, and
351352 ;; `:reply-to` is what this one points at.
352353 :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))
353357 ;; The sender has since rewritten this line.
354358 ;; Replay says so with a tag rather than by
355359 ;; sending the revision, so a message can
@@ -460,12 +464,16 @@
460464
461465 `frq.edits` is the fold and what it answers; the atom and the picture links
462466 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))))
469477
470478 (defn- names-line [channel names]
471479 (swap! channels #(members/with-names (ensure-channel % channel) channel names)))
@@ -650,15 +658,23 @@
650658 replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
651659 (if edit-of
652660 ;; 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")))
656669 ;; The original is outside the backlog we hold, so show
657670 ;; the current text rather than dropping what was said.
658671 (push-message! buffer from text
659672 {:at at
660673 :did (:account msg)
661674 :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")}
662678 :edited? true
663679 :reply-to (or (irc/tag-value tags "+reply")
664680 (irc/tag-value tags "+draft/reply"))}))
@@ -799,10 +815,21 @@
799815 (when (str/starts-with? (or target "") "#")
800816 (apply-mode! target modes args)))
801817 "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))))
806833 ("372" "375" "376" "002" "003" "004")
807834 (reset! status (or (last params) @status))
808835 ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
@@ -848,6 +875,11 @@
848875 ;; when the session ends, and signing with it afterwards would be
849876 ;; signing with a key nobody can check.
850877 "*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!)
851883 (reset! conn nil)
852884 (reset! connecting? false)
853885 (swap! channels
@@ -1004,6 +1036,8 @@
10041036 []
10051037 (store/clear-session!)
10061038 (reset! broker-token nil)
1039+ (reset! cells/api-bearer nil)
1040+ (replies/forget-asks!)
10071041 (reset! session nil)
10081042 (reset! auth-mode :guest)
10091043 (reset! status "Not connected"))
@@ -1530,11 +1564,11 @@
15301564 (defn message-by-id
15311565 "The message a reply points at, if this buffer still holds it.
15321566
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."
15351570 [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))
15381572
15391573 (defn react-from-picker!
15401574 "Put the chosen emoji on the message the picker was opened for, and close it.
@@ -1626,6 +1660,11 @@
16261660 :react-from-picker! react-from-picker!
16271661 :recent-everywhere recent-everywhere
16281662 :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)))
16291668 :start-edit! start-edit!
16301669 :toggle-reaction! toggle-reaction!
16311670 :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 put299 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, and351 ;; `: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-to353 :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 by358 ;; Replay says so with a tag rather than by
355 ;; sending the revision, so a message can359 ;; 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 links465 `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 every466 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 text469+ 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-of659 (if edit-of
652 ;; A revision is not a new line: it replaces the one it660 ;; A revision is not a new line: it replaces the one it
653- ;; names, under that line's own id — never the revision's661+ ;; 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 show669 ;; 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 text671 (push-message! buffer from text
659 {:at at672 {:at at
660 :did (:account msg)673 :did (:account msg)
661 :id edit-of674 :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? true678 :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 be875 ;; 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! channels885 (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-id1564 (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 gave1567+ 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 id1571+ (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-everywhere1661 :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!})