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

React and reply from the phone

The rules were `frq.state`'s and none of them were the desktop's. A
reaction is a tally — emoji to the nicks on it, added to, taken off, and
read back out of what CHATHISTORY replayed — so that is `frq.reactions`,
pure over the channels map like `frq.members`. What one is *sent* as is
`frq.irc.mutate`, which answers with the line and lets the caller write it,
the same bargain `frq.irc.handshake` struck. Signed either way: freeq
answers an unsigned mutation from an account with SIGNATURE_REQUIRED, and
`frq.msgsig` was already shared. The picker moved too — it is the cells and
the catalog, both of which were already common.

The phone needed two things before any of it could work. A message had no
msgid, so nothing could be said about a line that was already on screen;
and an incoming TAGMSG was ignored, so a reaction from anyone else would
never have appeared.

Then three in the renderer, each found by the one before it.

`expand` applied a component with every element of the vector as an
argument. glimmer strips a leading map carrying `:key` — it is for the
reconciler — and `[action-chips {:key :actions} channel m]` is the one line
in the shared screens written that way, so the row with react and reply on
it threw NoSuchMethodError. It takes glimmer's rule now, Form-2 components
with it.

`render` then applied components *again*, itself, bypassing that fix
entirely: a component could be asked whether it fills by one set of rules
and drawn by another. One door now.

And `:reaction` was not a tag this backend had, so what came out was the
orange `?reaction` marker the unknown-tag branch exists to show. It is a
pill: the glyph, the tally where there is one, and the accent when it is
yours — the only thing it has to say at a glance is whether pressing again
takes yours off.

Verified on a Pixel 6a signed in as an account: react, un-react, and reply
all round-trip. Five group buttons also ran off the right of a handset, so
the picker takes four to a row where there is no pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T01:40:52-07:00 Browse files
709af9f parent: 77d4bec
added common/frq/irc/mutate.cljc +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+(ns frq.irc.mutate
2+ "Changing a message that is already said, as lines to send.
3+
4+ The same bargain `frq.irc.handshake` strikes: what to send is shared, the
5+ sending is the caller's. A reaction is a TAGMSG carrying tags and no body,
6+ and every part of building one is arithmetic over strings — the tag names
7+ freeq reads, the escaping IRCv3 asks for, and the signature `frq.msgsig`
8+ mints. None of that is a socket, and none of it differs between a desktop
9+ reader thread and a phone's Stream.
10+
11+ Signed, because from an account freeq will not take it otherwise: an
12+ unsigned mutation comes back as `FAIL TAGMSG SIGNATURE_REQUIRED` and the
13+ message stays as it was. A guest has no key and sends none, which the
14+ server allows and the tags simply omit."
15+ (:require [clojure.string :as str]
16+ [frq.irc.parse :as parse]
17+ [frq.msgsig :as msgsig]))
18+
19+(defn tag-line
20+ "`tags` in front of `rest-of-line`, escaped the way IRCv3 wants them.
21+
22+ Sent in no particular order — the server reads them by name."
23+ [tags rest-of-line]
24+ (str "@" (str/join ";" (for [[k v] tags]
25+ (str k "=" (parse/escape-tag-value v))))
26+ " " rest-of-line))
27+
28+(defn react-line
29+ "Put `emoji` on `msgid`, for everyone in `target` to see.
30+
31+ `peer-did` is who the DM is with, and is what a DM signature names the
32+ conversation by — freeq names a DM by both DIDs rather than by a nick, and
33+ nothing else in a buffer says which account the other side is. nil in a
34+ channel, which is named by itself."
35+ ([target msgid emoji] (react-line target msgid emoji nil))
36+ ([target msgid emoji peer-did]
37+ (tag-line (merge {"+react" emoji "+reply" msgid}
38+ (msgsig/mutation-tags "react" target msgid emoji peer-did))
39+ (str "TAGMSG " target))))
40+
41+(defn unreact-line
42+ "Take it off again. The server keys the removal by DID where there is one, so
43+ it survives a nick change and cannot be done on someone else's behalf.
44+
45+ Signed like the reaction it undoes — taking a pill off is as much a change to
46+ a message as putting one on, and the server asks for the same proof."
47+ ([target msgid emoji] (unreact-line target msgid emoji nil))
48+ ([target msgid emoji peer-did]
49+ (tag-line (merge {"+freeq.at/unreact" emoji "+reply" msgid}
50+ (msgsig/mutation-tags "unreact" target msgid emoji peer-did))
51+ (str "TAGMSG " target))))
new file mode 100644
@@ -0,0 +1,51 @@
1+(ns frq.irc.mutate
2+ "Changing a message that is already said, as lines to send.
3+
4+ The same bargain `frq.irc.handshake` strikes: what to send is shared, the
5+ sending is the caller's. A reaction is a TAGMSG carrying tags and no body,
6+ and every part of building one is arithmetic over strings — the tag names
7+ freeq reads, the escaping IRCv3 asks for, and the signature `frq.msgsig`
8+ mints. None of that is a socket, and none of it differs between a desktop
9+ reader thread and a phone's Stream.
10+
11+ Signed, because from an account freeq will not take it otherwise: an
12+ unsigned mutation comes back as `FAIL TAGMSG SIGNATURE_REQUIRED` and the
13+ message stays as it was. A guest has no key and sends none, which the
14+ server allows and the tags simply omit."
15+ (:require [clojure.string :as str]
16+ [frq.irc.parse :as parse]
17+ [frq.msgsig :as msgsig]))
18+
19+(defn tag-line
20+ "`tags` in front of `rest-of-line`, escaped the way IRCv3 wants them.
21+
22+ Sent in no particular order — the server reads them by name."
23+ [tags rest-of-line]
24+ (str "@" (str/join ";" (for [[k v] tags]
25+ (str k "=" (parse/escape-tag-value v))))
26+ " " rest-of-line))
27+
28+(defn react-line
29+ "Put `emoji` on `msgid`, for everyone in `target` to see.
30+
31+ `peer-did` is who the DM is with, and is what a DM signature names the
32+ conversation by — freeq names a DM by both DIDs rather than by a nick, and
33+ nothing else in a buffer says which account the other side is. nil in a
34+ channel, which is named by itself."
35+ ([target msgid emoji] (react-line target msgid emoji nil))
36+ ([target msgid emoji peer-did]
37+ (tag-line (merge {"+react" emoji "+reply" msgid}
38+ (msgsig/mutation-tags "react" target msgid emoji peer-did))
39+ (str "TAGMSG " target))))
40+
41+(defn unreact-line
42+ "Take it off again. The server keys the removal by DID where there is one, so
43+ it survives a nick change and cannot be done on someone else's behalf.
44+
45+ Signed like the reaction it undoes — taking a pill off is as much a change to
46+ a message as putting one on, and the server asks for the same proof."
47+ ([target msgid emoji] (unreact-line target msgid emoji nil))
48+ ([target msgid emoji peer-did]
49+ (tag-line (merge {"+freeq.at/unreact" emoji "+reply" msgid}
50+ (msgsig/mutation-tags "unreact" target msgid emoji peer-did))
51+ (str "TAGMSG " target))))
added common/frq/reactions.cljc +97 -0
new file mode 100644
@@ -0,0 +1,97 @@
1+(ns frq.reactions
2+ "The tally of who put what on a message.
3+
4+ A message's `:reactions` is emoji -> the nicks who are on it. Pure over the
5+ channels map, like `frq.members`, and shared for the same reason: the rules
6+ are the server's and neither half of frq gets a say in them.
7+
8+ What a reaction is *sent* as lives in `frq.irc.mutate`; this is only what
9+ the buffer does about one, whether it came from the server or from the
10+ reader pressing a pill here."
11+ (:require [clojure.string :as str]
12+ [frq.cells :as cells]
13+ [frq.emoji :as emoji]))
14+
15+(defn parse-tally
16+ "The server's tally of what is already on a message, as
17+ `emoji:nick,nick;emoji:nick` — what CHATHISTORY sends so reactions survive a
18+ reconnect rather than starting empty every time the app opens."
19+ [encoded]
20+ (when (seq (or encoded ""))
21+ (reduce (fn [acc part]
22+ (let [[emoji nicks] (str/split part #":" 2)]
23+ (if (and (seq emoji) (seq (or nicks "")))
24+ (assoc acc emoji (vec (remove str/blank? (str/split nicks #","))))
25+ acc)))
26+ {}
27+ (str/split encoded #";"))))
28+
29+(defn with-reaction
30+ "One nick's reaction added to or taken off a tally. An emoji nobody is left
31+ on goes away with them: an empty pill is a pill that says nothing."
32+ [reactions emoji nick on?]
33+ (let [nicks (vec (get reactions emoji []))
34+ nicks (if on?
35+ (if (some #{nick} nicks) nicks (conj nicks nick))
36+ (vec (remove #{nick} nicks)))]
37+ (if (seq nicks)
38+ (assoc reactions emoji nicks)
39+ (dissoc reactions emoji))))
40+
41+(defn mine?
42+ "Whether `nick` is already on that emoji — which is what makes a second
43+ press take it off rather than send the same reaction twice."
44+ [m emoji nick]
45+ (boolean (some #{nick} (get (:reactions m) emoji))))
46+
47+(defn update-reaction
48+ "One reaction folded into the buffer it belongs to.
49+
50+ 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+ nothing happens."
53+ [channels channel msgid emoji nick on?]
54+ (if (and channel msgid (seq (or emoji "")))
55+ (if-let [msgs (get-in channels [channel :messages])]
56+ (assoc-in channels [channel :messages]
57+ (mapv (fn [msg]
58+ (if (= msgid (:id msg))
59+ (update msg :reactions with-reaction emoji nick on?)
60+ msg))
61+ msgs))
62+ channels)
63+ channels))
64+
65+(defn peer-did
66+ "The DID of whoever this DM buffer is with, from the last thing they said.
67+
68+ nil for a channel, and for a conversation where nobody with a DID has spoken
69+ — a signature over a DM needs both sides named, and there is nothing to
70+ name."
71+ [channels channel me]
72+ (when-not (str/starts-with? (or channel "") "#")
73+ (->> (get-in channels [channel :messages])
74+ (remove #(= me (:from %)))
75+ (keep :did)
76+ last)))
77+
78+
79+;; --------------------------------------------------------------- the picker
80+
81+(defn picker-emoji
82+ "What the picker is showing right now: the popular row, one group, or
83+ whatever the search matches — by name, so \"cat\" finds the cat and the cat
84+ face, and by the emoji itself, so pasting one finds it."
85+ []
86+ (let [q (str/lower-case (str/trim (str @cells/emoji-search)))
87+ ;; A blank group is no group: the popular row is what nothing selected
88+ ;; means, and an empty string would filter the catalog down to nothing.
89+ group (when (seq (str (or @cells/emoji-group ""))) @cells/emoji-group)]
90+ (cond
91+ (seq q) (->> emoji/catalog
92+ (filter (fn [[glyph name _]]
93+ (or (str/includes? (str/lower-case name) q)
94+ (str/includes? glyph q))))
95+ vec)
96+ group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))
97+ :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))
new file mode 100644
@@ -0,0 +1,97 @@
1+(ns frq.reactions
2+ "The tally of who put what on a message.
3+
4+ A message's `:reactions` is emoji -> the nicks who are on it. Pure over the
5+ channels map, like `frq.members`, and shared for the same reason: the rules
6+ are the server's and neither half of frq gets a say in them.
7+
8+ What a reaction is *sent* as lives in `frq.irc.mutate`; this is only what
9+ the buffer does about one, whether it came from the server or from the
10+ reader pressing a pill here."
11+ (:require [clojure.string :as str]
12+ [frq.cells :as cells]
13+ [frq.emoji :as emoji]))
14+
15+(defn parse-tally
16+ "The server's tally of what is already on a message, as
17+ `emoji:nick,nick;emoji:nick` — what CHATHISTORY sends so reactions survive a
18+ reconnect rather than starting empty every time the app opens."
19+ [encoded]
20+ (when (seq (or encoded ""))
21+ (reduce (fn [acc part]
22+ (let [[emoji nicks] (str/split part #":" 2)]
23+ (if (and (seq emoji) (seq (or nicks "")))
24+ (assoc acc emoji (vec (remove str/blank? (str/split nicks #","))))
25+ acc)))
26+ {}
27+ (str/split encoded #";"))))
28+
29+(defn with-reaction
30+ "One nick's reaction added to or taken off a tally. An emoji nobody is left
31+ on goes away with them: an empty pill is a pill that says nothing."
32+ [reactions emoji nick on?]
33+ (let [nicks (vec (get reactions emoji []))
34+ nicks (if on?
35+ (if (some #{nick} nicks) nicks (conj nicks nick))
36+ (vec (remove #{nick} nicks)))]
37+ (if (seq nicks)
38+ (assoc reactions emoji nicks)
39+ (dissoc reactions emoji))))
40+
41+(defn mine?
42+ "Whether `nick` is already on that emoji — which is what makes a second
43+ press take it off rather than send the same reaction twice."
44+ [m emoji nick]
45+ (boolean (some #{nick} (get (:reactions m) emoji))))
46+
47+(defn update-reaction
48+ "One reaction folded into the buffer it belongs to.
49+
50+ 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+ nothing happens."
53+ [channels channel msgid emoji nick on?]
54+ (if (and channel msgid (seq (or emoji "")))
55+ (if-let [msgs (get-in channels [channel :messages])]
56+ (assoc-in channels [channel :messages]
57+ (mapv (fn [msg]
58+ (if (= msgid (:id msg))
59+ (update msg :reactions with-reaction emoji nick on?)
60+ msg))
61+ msgs))
62+ channels)
63+ channels))
64+
65+(defn peer-did
66+ "The DID of whoever this DM buffer is with, from the last thing they said.
67+
68+ nil for a channel, and for a conversation where nobody with a DID has spoken
69+ — a signature over a DM needs both sides named, and there is nothing to
70+ name."
71+ [channels channel me]
72+ (when-not (str/starts-with? (or channel "") "#")
73+ (->> (get-in channels [channel :messages])
74+ (remove #(= me (:from %)))
75+ (keep :did)
76+ last)))
77+
78+
79+;; --------------------------------------------------------------- the picker
80+
81+(defn picker-emoji
82+ "What the picker is showing right now: the popular row, one group, or
83+ whatever the search matches — by name, so \"cat\" finds the cat and the cat
84+ face, and by the emoji itself, so pasting one finds it."
85+ []
86+ (let [q (str/lower-case (str/trim (str @cells/emoji-search)))
87+ ;; A blank group is no group: the popular row is what nothing selected
88+ ;; means, and an empty string would filter the catalog down to nothing.
89+ group (when (seq (str (or @cells/emoji-group ""))) @cells/emoji-group)]
90+ (cond
91+ (seq q) (->> emoji/catalog
92+ (filter (fn [[glyph name _]]
93+ (or (str/includes? (str/lower-case name) q)
94+ (str/includes? glyph q))))
95+ vec)
96+ group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))
97+ :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))
modified common/frq/screens/chat.cljc +9 -2
@@ -363,11 +363,18 @@
363363 [:button {:label "✕" :on-click actions/close-picker!}]]
364364 ;; The groups are what the search box is not: a way in for someone who has
365365 ;; no word for what they want. Their first word is enough to tell them
366- ;; apart, and is what keeps them to two rows. They give way to the
366+ ;; apart, and is what keeps them to a couple of rows. They give way to the
367367 ;; search's own answer while something is typed.
368+ ;;
369+ ;; Four to a row on a phone and five in a window. The row does not wrap —
370+ ;; it is a row — so the count is the only thing deciding whether the last
371+ ;; button is on screen, and five of them ran off the right-hand edge of a
372+ ;; handset by about the width of the word they were trying to show.
368373 [:vbox {:key :groups :spacing 4}
369374 (when-not searching?
370- (for [[i row] (map-indexed vector (partition-all 5 (cons nil emoji-groups)))]
375+ (for [[i row] (map-indexed vector
376+ (partition-all (if (actions/desktop?) 5 4)
377+ (cons nil emoji-groups)))]
371378 [:hbox {:key i :spacing 4}
372379 (for [g row]
373380 [:button {:key (or g "popular")
@@ -363,11 +363,18 @@
363 [:button {:label "✕" :on-click actions/close-picker!}]]363 [:button {:label "✕" :on-click actions/close-picker!}]]
364 ;; The groups are what the search box is not: a way in for someone who has364 ;; The groups are what the search box is not: a way in for someone who has
365 ;; no word for what they want. Their first word is enough to tell them365 ;; no word for what they want. Their first word is enough to tell them
366- ;; apart, and is what keeps them to two rows. They give way to the366+ ;; apart, and is what keeps them to a couple of rows. They give way to the
367 ;; search's own answer while something is typed.367 ;; search's own answer while something is typed.
368+ ;;
369+ ;; Four to a row on a phone and five in a window. The row does not wrap —
370+ ;; it is a row — so the count is the only thing deciding whether the last
371+ ;; button is on screen, and five of them ran off the right-hand edge of a
372+ ;; handset by about the width of the word they were trying to show.
368 [:vbox {:key :groups :spacing 4}373 [:vbox {:key :groups :spacing 4}
369 (when-not searching?374 (when-not searching?
370- (for [[i row] (map-indexed vector (partition-all 5 (cons nil emoji-groups)))]375+ (for [[i row] (map-indexed vector
376+ (partition-all (if (actions/desktop?) 5 4)
377+ (cons nil emoji-groups)))]
371 [:hbox {:key i :spacing 4}378 [:hbox {:key i :spacing 4}
372 (for [g row]379 (for [g row]
373 [:button {:key (or g "popular")380 [:button {:key (or g "popular")
modified flutter/src/frq/hiccup.cljd +70 -6
@@ -87,10 +87,30 @@
8787 `[chat-screen]` is a function, and asking whether it fills means calling it
8888 the root wraps every screen in a plain `:vbox`, and without this the wrapper
8989 could not see that the screen inside it wants the whole height. Expanded
90- once and both asked and rendered, so the component runs once either way."
90+ once and both asked and rendered, so the component runs once either way.
91+
92+ Two rules taken from glimmer, because the screens are written against it
93+ and a renderer that reads them differently reads them wrong.
94+
95+ A leading map carrying `:key` is for the reconciler, not for the component:
96+ `:key` is dropped, and a map that held nothing else goes with it.
97+ `[action-chips {:key :actions} channel m]` is a two-argument call, and
98+ passing the map on threw NoSuchMethodError on the one line in the shared
99+ screens written that way — the line that draws react and reply.
100+
101+ And a component may answer with its render function rather than with
102+ hiccup: glimmer's Form-2, where the outer call is the mount and the inner
103+ one is the render. Nothing here caches the inner fn the way glimmer does,
104+ so this calls it every time — a call more, and the same output."
91105 [node]
92106 (if (and (vector? node) (seq node) (not (keyword? (first node))))
93- (expand (apply (first node) (rest node)))
107+ (let [raw (rest node)
108+ args (if (and (seq raw) (map? (first raw)) (contains? (first raw) :key))
109+ (let [m (dissoc (first raw) :key)]
110+ (if (seq m) (cons m (rest raw)) (rest raw)))
111+ raw)
112+ out (apply (first node) args)]
113+ (expand (if (fn? out) (out) out)))
94114 node))
95115
96116 (defn- fills-row?
@@ -412,6 +432,45 @@
412432 .borderRadius (m/BorderRadius.circular t/radius-s))
413433 .child (col (dbl (:spacing p) t/space-xxs) (body node)))
414434
435+ ;; A reaction pill: the glyph, and the tally beside it where there is
436+ ;; one to show. The same shape whether it is a reaction under a message,
437+ ;; a swatch in the picker or the react/reply/edit chips on the sender's
438+ ;; row — which is the point, as `action-chips` says: what you press to
439+ ;; react and what appears once you have should look like one family.
440+ ;;
441+ ;; A count of zero is no count. The picker passes 0 for every swatch
442+ ;; because none of them has been pressed, and a grid of little grey
443+ ;; zeroes is noise where a reader is scanning for a face.
444+ ;;
445+ ;; `:mine` is the accent, because the only thing a pill has to say at a
446+ ;; glance is whether pressing it again would take yours off.
447+ :reaction
448+ (let [size (dbl (:size p) 14.0)
449+ n (:count p)
450+ mine (boolean (:mine p))
451+ pad (max 2.0 (* 0.25 size))]
452+ (m/InkWell
453+ .onTap (when-let [on (:on-click p)] #(on))
454+ .borderRadius (m/BorderRadius.circular t/radius-s)
455+ .child
456+ (m/Container
457+ .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad))
458+ .decoration (m/BoxDecoration
459+ .color (if mine t/accent t/card-component)
460+ .borderRadius (m/BorderRadius.circular t/radius-s))
461+ .child
462+ (m/Row
463+ .mainAxisSize m/MainAxisSize.min
464+ .children
465+ (into [(m/Text (str (:emoji p ""))
466+ .style (m/TextStyle .fontSize size))]
467+ (when (and (number? n) (pos? n))
468+ [(m/SizedBox .width pad)
469+ (m/Text (str n)
470+ .style (m/TextStyle
471+ .fontSize (* 0.85 size)
472+ .color (if mine t/on-accent t/on-card)))]))))))
473+
415474 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
416475
417476 ;; A 16px indeterminate circle, with the label as a caption beside it.
@@ -580,9 +639,14 @@
580639 (defn render
581640 "One hiccup node as a Flutter widget.
582641
583- A vector whose head is a function is a component: glimmer calls it with the
584- rest of the vector as arguments and renders what comes back, and so does
585- this. Everything else is a tag."
642+ A vector whose head is a function is a component, and `expand` is what calls
643+ one — here as well as in the fills- questions above. It used to be applied
644+ again right here, which is how a component could be asked whether it fills
645+ by one set of rules and rendered by another: the `:key` map that `expand`
646+ learned to strip still reached the function on the way to the screen, and
647+ the chat screen still threw. One door now.
648+
649+ Everything else is a tag."
586650 [node]
587651 (cond
588652 (nil? node) (m/SizedBox .width 0.0 .height 0.0)
@@ -591,7 +655,7 @@
591655 (let [head (first node)]
592656 (if (keyword? head)
593657 (render-tag head node)
594- (render (apply head (rest node)))))
658+ (render (expand node))))
595659 (seq? node)
596660 (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
597661 .mainAxisSize m/MainAxisSize.min
@@ -87,10 +87,30 @@
87 `[chat-screen]` is a function, and asking whether it fills means calling it 87 `[chat-screen]` is a function, and asking whether it fills means calling it
88 the root wraps every screen in a plain `:vbox`, and without this the wrapper88 the root wraps every screen in a plain `:vbox`, and without this the wrapper
89 could not see that the screen inside it wants the whole height. Expanded89 could not see that the screen inside it wants the whole height. Expanded
90- once and both asked and rendered, so the component runs once either way."90+ once and both asked and rendered, so the component runs once either way.
91+
92+ Two rules taken from glimmer, because the screens are written against it
93+ and a renderer that reads them differently reads them wrong.
94+
95+ A leading map carrying `:key` is for the reconciler, not for the component:
96+ `:key` is dropped, and a map that held nothing else goes with it.
97+ `[action-chips {:key :actions} channel m]` is a two-argument call, and
98+ passing the map on threw NoSuchMethodError on the one line in the shared
99+ screens written that way — the line that draws react and reply.
100+
101+ And a component may answer with its render function rather than with
102+ hiccup: glimmer's Form-2, where the outer call is the mount and the inner
103+ one is the render. Nothing here caches the inner fn the way glimmer does,
104+ so this calls it every time — a call more, and the same output."
91 [node]105 [node]
92 (if (and (vector? node) (seq node) (not (keyword? (first node))))106 (if (and (vector? node) (seq node) (not (keyword? (first node))))
93- (expand (apply (first node) (rest node)))107+ (let [raw (rest node)
108+ args (if (and (seq raw) (map? (first raw)) (contains? (first raw) :key))
109+ (let [m (dissoc (first raw) :key)]
110+ (if (seq m) (cons m (rest raw)) (rest raw)))
111+ raw)
112+ out (apply (first node) args)]
113+ (expand (if (fn? out) (out) out)))
94 node))114 node))
95 115
96 (defn- fills-row?116 (defn- fills-row?
@@ -412,6 +432,45 @@
412 .borderRadius (m/BorderRadius.circular t/radius-s))432 .borderRadius (m/BorderRadius.circular t/radius-s))
413 .child (col (dbl (:spacing p) t/space-xxs) (body node)))433 .child (col (dbl (:spacing p) t/space-xxs) (body node)))
414 434
435+ ;; A reaction pill: the glyph, and the tally beside it where there is
436+ ;; one to show. The same shape whether it is a reaction under a message,
437+ ;; a swatch in the picker or the react/reply/edit chips on the sender's
438+ ;; row — which is the point, as `action-chips` says: what you press to
439+ ;; react and what appears once you have should look like one family.
440+ ;;
441+ ;; A count of zero is no count. The picker passes 0 for every swatch
442+ ;; because none of them has been pressed, and a grid of little grey
443+ ;; zeroes is noise where a reader is scanning for a face.
444+ ;;
445+ ;; `:mine` is the accent, because the only thing a pill has to say at a
446+ ;; glance is whether pressing it again would take yours off.
447+ :reaction
448+ (let [size (dbl (:size p) 14.0)
449+ n (:count p)
450+ mine (boolean (:mine p))
451+ pad (max 2.0 (* 0.25 size))]
452+ (m/InkWell
453+ .onTap (when-let [on (:on-click p)] #(on))
454+ .borderRadius (m/BorderRadius.circular t/radius-s)
455+ .child
456+ (m/Container
457+ .padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad))
458+ .decoration (m/BoxDecoration
459+ .color (if mine t/accent t/card-component)
460+ .borderRadius (m/BorderRadius.circular t/radius-s))
461+ .child
462+ (m/Row
463+ .mainAxisSize m/MainAxisSize.min
464+ .children
465+ (into [(m/Text (str (:emoji p ""))
466+ .style (m/TextStyle .fontSize size))]
467+ (when (and (number? n) (pos? n))
468+ [(m/SizedBox .width pad)
469+ (m/Text (str n)
470+ .style (m/TextStyle
471+ .fontSize (* 0.85 size)
472+ .color (if mine t/on-accent t/on-card)))]))))))
473+
415 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)474 :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
416 475
417 ;; A 16px indeterminate circle, with the label as a caption beside it.476 ;; A 16px indeterminate circle, with the label as a caption beside it.
@@ -580,9 +639,14 @@
580 (defn render639 (defn render
581 "One hiccup node as a Flutter widget.640 "One hiccup node as a Flutter widget.
582 641
583- A vector whose head is a function is a component: glimmer calls it with the642+ A vector whose head is a function is a component, and `expand` is what calls
584- rest of the vector as arguments and renders what comes back, and so does643+ one — here as well as in the fills- questions above. It used to be applied
585- this. Everything else is a tag."644+ again right here, which is how a component could be asked whether it fills
645+ by one set of rules and rendered by another: the `:key` map that `expand`
646+ learned to strip still reached the function on the way to the screen, and
647+ the chat screen still threw. One door now.
648+
649+ Everything else is a tag."
586 [node]650 [node]
587 (cond651 (cond
588 (nil? node) (m/SizedBox .width 0.0 .height 0.0)652 (nil? node) (m/SizedBox .width 0.0 .height 0.0)
@@ -591,7 +655,7 @@
591 (let [head (first node)]655 (let [head (first node)]
592 (if (keyword? head)656 (if (keyword? head)
593 (render-tag head node)657 (render-tag head node)
594- (render (apply head (rest node)))))658+ (render (expand node))))
595 (seq? node)659 (seq? node)
596 (m/Column .crossAxisAlignment m/CrossAxisAlignment.start660 (m/Column .crossAxisAlignment m/CrossAxisAlignment.start
597 .mainAxisSize m/MainAxisSize.min661 .mainAxisSize m/MainAxisSize.min
modified flutter/src/frq/main.cljd +97 -2
@@ -40,6 +40,8 @@
4040 [frq.screens.app :as screens]
4141 [frq.rooms :as rooms]
4242 [frq.members :as members]
43+ [frq.reactions :as reactions]
44+ [frq.irc.mutate :as mutate]
4345 [frq.oauth.core :as oauth]
4446 [frq.oauth.dart :as oauth-dart]
4547 [frq.irc.parse :as irc]
@@ -123,12 +125,49 @@
123125 (first params) (nth params 1 "") (drop 2 params))
124126
125127 (= "PRIVMSG" cmd)
126- (let [target (first params)
128+ (let [tags (:tags m)
129+ target (first params)
127130 text (last params)
128131 name (if (rooms/dm? target) who target)]
129132 (swap! cells/channels update name
130133 #(-> (merge {:name name :messages [] :unread 0} %)
131- (update :messages conj {:from who :text text})))))))
134+ (update :messages conj
135+ ;; The msgid is what everything after a message
136+ ;; names it by — a reply points at one, a reaction
137+ ;; lands on one. Without it a line is on screen and
138+ ;; nothing can be said about it.
139+ {:from who
140+ :text text
141+ :did (:account m)
142+ :id (irc/tag-value tags "msgid")
143+ ;; The server canonicalises +draft/reply to
144+ ;; +reply; a client that sent the draft name may
145+ ;; still reach us before it does.
146+ :reply-to (or (irc/tag-value tags "+reply")
147+ (irc/tag-value tags "+draft/reply"))
148+ ;; What is already on it, so a reconnect does not
149+ ;; start every message empty.
150+ :reactions (reactions/parse-tally
151+ (irc/tag-value tags
152+ "+freeq.at/reactions"))}))))
153+
154+ ;; A message that is only tags. A reaction is the one this reads:
155+ ;; `+react` puts an emoji on the message `+reply` names, and the
156+ ;; server's own `+freeq.at/unreact` takes it off again.
157+ (= "TAGMSG" cmd)
158+ (let [tags (:tags m)
159+ target (first params)
160+ buffer (if (rooms/dm? target) who target)
161+ msgid (or (irc/tag-value tags "+reply")
162+ (irc/tag-value tags "+draft/reply"))
163+ add (or (irc/tag-value tags "+react")
164+ (irc/tag-value tags "+draft/react"))
165+ gone (irc/tag-value tags "+freeq.at/unreact")]
166+ (cond
167+ add (swap! cells/channels reactions/update-reaction
168+ buffer msgid add who true)
169+ gone (swap! cells/channels reactions/update-reaction
170+ buffer msgid gone who false))))))
132171
133172 (defn- note! [m]
134173 (reset! last-line (str (:command m) " " (last (:params m))))
@@ -443,6 +482,31 @@
443482 #(update % :messages conj {:from @cells/form-nick :text text})))
444483 (reset! cells/draft ""))))
445484
485+(defn- message-by-id [room id]
486+ (when id
487+ (first (filter #(= id (rooms/row-id %))
488+ (get-in @cells/channels [room :messages])))))
489+
490+(defn- toggle-reaction!
491+ "Put my emoji on a message, or take it off if it is already mine.
492+
493+ Applied here as well as sent: the server relays a TAGMSG to everyone in the
494+ channel *except* the client that sent it, so without this the pill would
495+ only appear once someone else reacted too."
496+ [channel m emoji]
497+ (when-let [msgid (:id m)]
498+ (let [me (str @cells/form-nick)
499+ on? (not (reactions/mine? m emoji me))
500+ peer (reactions/peer-did @cells/channels channel me)]
501+ (when-let [c @conn]
502+ (net/send-line! c (if on?
503+ (mutate/react-line channel msgid emoji peer)
504+ (mutate/unreact-line channel msgid emoji peer))))
505+ (swap! cells/channels reactions/update-reaction
506+ channel msgid emoji me on?))))
507+
508+(defn- close-picker! [] (reset! cells/reacting nil))
509+
446510 (defn ^:async main []
447511 (m/WidgetsFlutterBinding.ensureInitialized)
448512 ;; Layout errors do not come back as exceptions — they happen after the
@@ -488,6 +552,37 @@
488552 (when-let [c @conn] (net/send-line! c (str "PART " name)))
489553 (swap! cells/channels dissoc name))
490554 :send-draft! send-draft!
555+
556+ ;; Answering. Both are a cell and nothing else — what the composer does
557+ ;; with `replying-to` is the shared screen's business.
558+ :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))
559+ :cancel-reply! (fn [] (reset! cells/replying-to nil))
560+
561+ ;; Reacting. The pill itself, then the picker behind it.
562+ :toggle-reaction! toggle-reaction!
563+ :my-reaction? (fn [m emoji]
564+ (reactions/mine? m emoji (str @cells/form-nick)))
565+ :open-picker! (fn [channel m]
566+ (when (:id m)
567+ ;; Fresh: a leftover search from last time is a screen
568+ ;; of somebody else's question.
569+ (reset! cells/emoji-search "")
570+ (reset! cells/emoji-group nil)
571+ (reset! cells/reacting {:channel channel :id (:id m)})))
572+ :close-picker! close-picker!
573+ :picker-emoji reactions/picker-emoji
574+ :react-from-picker! (fn [emoji]
575+ ;; One choice and back to the conversation: a
576+ ;; picker left open is asking a question that has
577+ ;; been answered.
578+ (when-let [{:keys [channel id]} @cells/reacting]
579+ (when-let [m (message-by-id channel id)]
580+ (toggle-reaction! channel m emoji))
581+ (close-picker!)))
582+ ;; Hover is a pointer idea. A finger is either on a pill or not on it,
583+ ;; so there is nothing here to raise a card about.
584+ :hover-reaction! (fn [_ _] nil)
585+ :unhover-reaction! (fn [_ _] nil)
491586 :wide? (fn [] false)
492587 :desktop? (fn [] false)
493588 :mine? (fn [from] (= (str from) (str @cells/form-nick)))
@@ -40,6 +40,8 @@
40 [frq.screens.app :as screens]40 [frq.screens.app :as screens]
41 [frq.rooms :as rooms]41 [frq.rooms :as rooms]
42 [frq.members :as members]42 [frq.members :as members]
43+ [frq.reactions :as reactions]
44+ [frq.irc.mutate :as mutate]
43 [frq.oauth.core :as oauth]45 [frq.oauth.core :as oauth]
44 [frq.oauth.dart :as oauth-dart]46 [frq.oauth.dart :as oauth-dart]
45 [frq.irc.parse :as irc]47 [frq.irc.parse :as irc]
@@ -123,12 +125,49 @@
123 (first params) (nth params 1 "") (drop 2 params))125 (first params) (nth params 1 "") (drop 2 params))
124 126
125 (= "PRIVMSG" cmd)127 (= "PRIVMSG" cmd)
126- (let [target (first params)128+ (let [tags (:tags m)
129+ target (first params)
127 text (last params)130 text (last params)
128 name (if (rooms/dm? target) who target)]131 name (if (rooms/dm? target) who target)]
129 (swap! cells/channels update name132 (swap! cells/channels update name
130 #(-> (merge {:name name :messages [] :unread 0} %)133 #(-> (merge {:name name :messages [] :unread 0} %)
131- (update :messages conj {:from who :text text})))))))134+ (update :messages conj
135+ ;; The msgid is what everything after a message
136+ ;; names it by — a reply points at one, a reaction
137+ ;; lands on one. Without it a line is on screen and
138+ ;; nothing can be said about it.
139+ {:from who
140+ :text text
141+ :did (:account m)
142+ :id (irc/tag-value tags "msgid")
143+ ;; The server canonicalises +draft/reply to
144+ ;; +reply; a client that sent the draft name may
145+ ;; still reach us before it does.
146+ :reply-to (or (irc/tag-value tags "+reply")
147+ (irc/tag-value tags "+draft/reply"))
148+ ;; What is already on it, so a reconnect does not
149+ ;; start every message empty.
150+ :reactions (reactions/parse-tally
151+ (irc/tag-value tags
152+ "+freeq.at/reactions"))}))))
153+
154+ ;; A message that is only tags. A reaction is the one this reads:
155+ ;; `+react` puts an emoji on the message `+reply` names, and the
156+ ;; server's own `+freeq.at/unreact` takes it off again.
157+ (= "TAGMSG" cmd)
158+ (let [tags (:tags m)
159+ target (first params)
160+ buffer (if (rooms/dm? target) who target)
161+ msgid (or (irc/tag-value tags "+reply")
162+ (irc/tag-value tags "+draft/reply"))
163+ add (or (irc/tag-value tags "+react")
164+ (irc/tag-value tags "+draft/react"))
165+ gone (irc/tag-value tags "+freeq.at/unreact")]
166+ (cond
167+ add (swap! cells/channels reactions/update-reaction
168+ buffer msgid add who true)
169+ gone (swap! cells/channels reactions/update-reaction
170+ buffer msgid gone who false))))))
132 171
133 (defn- note! [m]172 (defn- note! [m]
134 (reset! last-line (str (:command m) " " (last (:params m))))173 (reset! last-line (str (:command m) " " (last (:params m))))
@@ -443,6 +482,31 @@
443 #(update % :messages conj {:from @cells/form-nick :text text})))482 #(update % :messages conj {:from @cells/form-nick :text text})))
444 (reset! cells/draft ""))))483 (reset! cells/draft ""))))
445 484
485+(defn- message-by-id [room id]
486+ (when id
487+ (first (filter #(= id (rooms/row-id %))
488+ (get-in @cells/channels [room :messages])))))
489+
490+(defn- toggle-reaction!
491+ "Put my emoji on a message, or take it off if it is already mine.
492+
493+ Applied here as well as sent: the server relays a TAGMSG to everyone in the
494+ channel *except* the client that sent it, so without this the pill would
495+ only appear once someone else reacted too."
496+ [channel m emoji]
497+ (when-let [msgid (:id m)]
498+ (let [me (str @cells/form-nick)
499+ on? (not (reactions/mine? m emoji me))
500+ peer (reactions/peer-did @cells/channels channel me)]
501+ (when-let [c @conn]
502+ (net/send-line! c (if on?
503+ (mutate/react-line channel msgid emoji peer)
504+ (mutate/unreact-line channel msgid emoji peer))))
505+ (swap! cells/channels reactions/update-reaction
506+ channel msgid emoji me on?))))
507+
508+(defn- close-picker! [] (reset! cells/reacting nil))
509+
446 (defn ^:async main []510 (defn ^:async main []
447 (m/WidgetsFlutterBinding.ensureInitialized)511 (m/WidgetsFlutterBinding.ensureInitialized)
448 ;; Layout errors do not come back as exceptions — they happen after the512 ;; Layout errors do not come back as exceptions — they happen after the
@@ -488,6 +552,37 @@
488 (when-let [c @conn] (net/send-line! c (str "PART " name)))552 (when-let [c @conn] (net/send-line! c (str "PART " name)))
489 (swap! cells/channels dissoc name))553 (swap! cells/channels dissoc name))
490 :send-draft! send-draft!554 :send-draft! send-draft!
555+
556+ ;; Answering. Both are a cell and nothing else — what the composer does
557+ ;; with `replying-to` is the shared screen's business.
558+ :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))
559+ :cancel-reply! (fn [] (reset! cells/replying-to nil))
560+
561+ ;; Reacting. The pill itself, then the picker behind it.
562+ :toggle-reaction! toggle-reaction!
563+ :my-reaction? (fn [m emoji]
564+ (reactions/mine? m emoji (str @cells/form-nick)))
565+ :open-picker! (fn [channel m]
566+ (when (:id m)
567+ ;; Fresh: a leftover search from last time is a screen
568+ ;; of somebody else's question.
569+ (reset! cells/emoji-search "")
570+ (reset! cells/emoji-group nil)
571+ (reset! cells/reacting {:channel channel :id (:id m)})))
572+ :close-picker! close-picker!
573+ :picker-emoji reactions/picker-emoji
574+ :react-from-picker! (fn [emoji]
575+ ;; One choice and back to the conversation: a
576+ ;; picker left open is asking a question that has
577+ ;; been answered.
578+ (when-let [{:keys [channel id]} @cells/reacting]
579+ (when-let [m (message-by-id channel id)]
580+ (toggle-reaction! channel m emoji))
581+ (close-picker!)))
582+ ;; Hover is a pointer idea. A finger is either on a pill or not on it,
583+ ;; so there is nothing here to raise a card about.
584+ :hover-reaction! (fn [_ _] nil)
585+ :unhover-reaction! (fn [_ _] nil)
491 :wide? (fn [] false)586 :wide? (fn [] false)
492 :desktop? (fn [] false)587 :desktop? (fn [] false)
493 :mine? (fn [from] (= (str from) (str @cells/form-nick)))588 :mine? (fn [from] (= (str from) (str @cells/form-nick)))
modified src/frq/irc.clj +6 -13
@@ -18,6 +18,7 @@
1818 [frq.atproto :as atproto]
1919 [frq.irc.parse :as parse]
2020 [frq.irc.handshake :as handshake]
21+ [frq.irc.mutate :as mutate]
2122 [frq.msgsig :as msgsig]
2223 [frq.wire :as wire]
2324 [jolt.ffi :as ffi]
@@ -269,25 +270,17 @@
269270 (defn react!
270271 "Put `emoji` on the message `msgid`, for everyone in `target` to see.
271272
272- Signed when this connection has a key. `peer-did` is who the DM is with, and
273- is what a DM signature names the conversation by; a channel does not need it."
273+ The line is `frq.irc.mutate`'s; this writes it."
274274 ([conn target msgid emoji] (react! conn target msgid emoji nil))
275275 ([conn target msgid emoji peer-did]
276- (tagmsg! conn target (merge {"+react" emoji "+reply" msgid}
277- (msgsig/mutation-tags "react" target msgid
278- emoji peer-did)))))
276+ (send-line! conn (mutate/react-line target msgid emoji peer-did))))
279277
280278 (defn unreact!
281- "Take it off again. The server keys the removal by DID where there is one, so
282- it survives a nick change and cannot be done on someone else's behalf.
283-
284- Signed like the reaction it undoes — taking a pill off is as much a change to
285- a message as putting one on, and the server asks for the same proof."
279+ "Take it off again."
286280 ([conn target msgid emoji] (unreact! conn target msgid emoji nil))
287281 ([conn target msgid emoji peer-did]
288- (tagmsg! conn target (merge {"+freeq.at/unreact" emoji "+reply" msgid}
289- (msgsig/mutation-tags "unreact" target msgid
290- emoji peer-did)))))
282+ (send-line! conn (mutate/unreact-line target msgid emoji peer-did))))
283+
291284
292285 (defn close! [conn]
293286 ;; Written straight out rather than queued: the reader may already be gone,
@@ -18,6 +18,7 @@
18 [frq.atproto :as atproto]18 [frq.atproto :as atproto]
19 [frq.irc.parse :as parse]19 [frq.irc.parse :as parse]
20 [frq.irc.handshake :as handshake]20 [frq.irc.handshake :as handshake]
21+ [frq.irc.mutate :as mutate]
21 [frq.msgsig :as msgsig]22 [frq.msgsig :as msgsig]
22 [frq.wire :as wire]23 [frq.wire :as wire]
23 [jolt.ffi :as ffi]24 [jolt.ffi :as ffi]
@@ -269,25 +270,17 @@
269 (defn react!270 (defn react!
270 "Put `emoji` on the message `msgid`, for everyone in `target` to see.271 "Put `emoji` on the message `msgid`, for everyone in `target` to see.
271 272
272- Signed when this connection has a key. `peer-did` is who the DM is with, and273+ The line is `frq.irc.mutate`'s; this writes it."
273- is what a DM signature names the conversation by; a channel does not need it."
274 ([conn target msgid emoji] (react! conn target msgid emoji nil))274 ([conn target msgid emoji] (react! conn target msgid emoji nil))
275 ([conn target msgid emoji peer-did]275 ([conn target msgid emoji peer-did]
276- (tagmsg! conn target (merge {"+react" emoji "+reply" msgid}276+ (send-line! conn (mutate/react-line target msgid emoji peer-did))))
277- (msgsig/mutation-tags "react" target msgid
278- emoji peer-did)))))
279 277
280 (defn unreact!278 (defn unreact!
281- "Take it off again. The server keys the removal by DID where there is one, so279+ "Take it off again."
282- it survives a nick change and cannot be done on someone else's behalf.
283-
284- Signed like the reaction it undoes — taking a pill off is as much a change to
285- a message as putting one on, and the server asks for the same proof."
286 ([conn target msgid emoji] (unreact! conn target msgid emoji nil))280 ([conn target msgid emoji] (unreact! conn target msgid emoji nil))
287 ([conn target msgid emoji peer-did]281 ([conn target msgid emoji peer-did]
288- (tagmsg! conn target (merge {"+freeq.at/unreact" emoji "+reply" msgid}282+ (send-line! conn (mutate/unreact-line target msgid emoji peer-did))))
289- (msgsig/mutation-tags "unreact" target msgid283+
290- emoji peer-did)))))
291 284
292 (defn close! [conn]285 (defn close! [conn]
293 ;; Written straight out rather than queued: the reader may already be gone,286 ;; Written straight out rather than queued: the reader may already be gone,
modified src/frq/state.clj +13 -65
@@ -7,6 +7,7 @@
77 (:require [clojure.string :as str]
88 [frq.rooms :as rooms]
99 [frq.members :as members]
10+ [frq.reactions :as reactions]
1011 [glimmer.ratom :as r :refer [atom]]
1112 [frq.actions :as actions]
1213 [frq.cells :as cells]
@@ -553,48 +554,14 @@
553554 (reset! screen :chats))
554555 (remember-rooms! true))
555556
556-(defn- parse-reactions
557- "The server's tally of what is already on a message, as
558- `emoji:nick,nick;emoji:nick` — what CHATHISTORY sends so reactions survive a
559- reconnect rather than starting empty every time the app opens."
560- [encoded]
561- (when (seq (or encoded ""))
562- (reduce (fn [acc part]
563- (let [[emoji nicks] (str/split part #":" 2)]
564- (if (and (seq emoji) (seq (or nicks "")))
565- (assoc acc emoji (vec (remove str/blank? (str/split nicks #","))))
566- acc)))
567- {}
568- (str/split encoded #";"))))
569-
570-(defn- with-reaction
571- "One nick's reaction added to or taken off a tally. An emoji nobody is left
572- on goes away with them: an empty pill is a pill that says nothing."
573- [reactions emoji nick on?]
574- (let [nicks (vec (get reactions emoji []))
575- nicks (if on?
576- (if (some #{nick} nicks) nicks (conj nicks nick))
577- (vec (remove #{nick} nicks)))]
578- (if (seq nicks)
579- (assoc reactions emoji nicks)
580- (dissoc reactions emoji))))
557+(def ^:private parse-reactions reactions/parse-tally)
558+(def ^:private with-reaction reactions/with-reaction)
581559
582560 (defn update-reaction!
583- "Fold one reaction into the buffer it belongs to. The message it names may
584- not be there — a reaction on something older than the backlog we asked for —
585- and then there is nothing to show it on, so nothing happens."
561+ "One reaction folded into the buffer it belongs to. `frq.reactions` says what
562+ that means; this is the atom it means it to."
586563 [channel msgid emoji nick on?]
587- (when (and channel msgid (seq (or emoji "")))
588- (swap! channels
589- (fn [m]
590- (if-let [msgs (get-in m [channel :messages])]
591- (assoc-in m [channel :messages]
592- (mapv (fn [msg]
593- (if (= msgid (:id msg))
594- (update msg :reactions with-reaction emoji nick on?)
595- msg))
596- msgs))
597- m)))))
564+ (swap! channels reactions/update-reaction channel msgid emoji nick on?))
598565
599566 (defn edit-message!
600567 "Rewrite a message in place, and say so. `msgid` names the line as it was
@@ -1450,15 +1417,9 @@
14501417 (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
14511418
14521419 (defn- dm-peer-did
1453- "The DID of whoever this DM buffer is with, from the last thing they said.
1454- nil for a channel, and for a conversation where nobody with a DID has spoken
1455- — a signature over a DM needs both sides named, and there is nothing to name."
1420+ "Who this DM is with. `frq.reactions` reads it out of the buffer."
14561421 [channel]
1457- (when-not (str/starts-with? (or channel "") "#")
1458- (->> (get-in @channels [channel :messages])
1459- (remove #(= @form-nick (:from %)))
1460- (keep :did)
1461- last)))
1422+ (reactions/peer-did @channels channel @form-nick))
14621423
14631424 (defn mine?
14641425 "Whether we are the one who said this. Nick against nick, which is what the
@@ -1596,29 +1557,16 @@
15961557
15971558 (defn close-picker! [] (reset! reacting nil))
15981559
1599-(defn picker-emoji
1600- "What the picker is showing right now: the popular row, one group, or
1601- whatever the search matches — by name, so \"cat\" finds the cat and the cat
1602- face, and by the emoji itself, so pasting one finds it."
1603- []
1604- (let [q (str/lower-case (str/trim @emoji-search))
1605- ;; A blank group is no group: the popular row is what nothing selected
1606- ;; means, and an empty string would filter the catalog down to nothing.
1607- group (when (seq (or @emoji-group "")) @emoji-group)]
1608- (cond
1609- (seq q) (->> emoji/catalog
1610- (filter (fn [[glyph name _]]
1611- (or (str/includes? (str/lower-case name) q)
1612- (str/includes? glyph q))))
1613- vec)
1614- group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))
1615- :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))
1560+(def picker-emoji
1561+ "Moved to `frq.reactions`: it is the cells and the catalog, both of which
1562+ are shared, and the phone shows the same picker."
1563+ reactions/picker-emoji)
16161564
16171565 (defn my-reaction?
16181566 "Whether this nick is already on that emoji — which is what makes a second
16191567 click take it off rather than send the same reaction twice."
16201568 [m emoji]
1621- (boolean (some #{@form-nick} (get (:reactions m) emoji))))
1569+ (reactions/mine? m emoji @form-nick))
16221570
16231571 (def reaction-hover cells/reaction-hover)
16241572
@@ -7,6 +7,7 @@
7 (:require [clojure.string :as str]7 (:require [clojure.string :as str]
8 [frq.rooms :as rooms]8 [frq.rooms :as rooms]
9 [frq.members :as members]9 [frq.members :as members]
10+ [frq.reactions :as reactions]
10 [glimmer.ratom :as r :refer [atom]]11 [glimmer.ratom :as r :refer [atom]]
11 [frq.actions :as actions]12 [frq.actions :as actions]
12 [frq.cells :as cells]13 [frq.cells :as cells]
@@ -553,48 +554,14 @@
553 (reset! screen :chats))554 (reset! screen :chats))
554 (remember-rooms! true))555 (remember-rooms! true))
555 556
556-(defn- parse-reactions557+(def ^:private parse-reactions reactions/parse-tally)
557- "The server's tally of what is already on a message, as558+(def ^:private with-reaction reactions/with-reaction)
558- `emoji:nick,nick;emoji:nick` — what CHATHISTORY sends so reactions survive a
559- reconnect rather than starting empty every time the app opens."
560- [encoded]
561- (when (seq (or encoded ""))
562- (reduce (fn [acc part]
563- (let [[emoji nicks] (str/split part #":" 2)]
564- (if (and (seq emoji) (seq (or nicks "")))
565- (assoc acc emoji (vec (remove str/blank? (str/split nicks #","))))
566- acc)))
567- {}
568- (str/split encoded #";"))))
569-
570-(defn- with-reaction
571- "One nick's reaction added to or taken off a tally. An emoji nobody is left
572- on goes away with them: an empty pill is a pill that says nothing."
573- [reactions emoji nick on?]
574- (let [nicks (vec (get reactions emoji []))
575- nicks (if on?
576- (if (some #{nick} nicks) nicks (conj nicks nick))
577- (vec (remove #{nick} nicks)))]
578- (if (seq nicks)
579- (assoc reactions emoji nicks)
580- (dissoc reactions emoji))))
581 559
582 (defn update-reaction!560 (defn update-reaction!
583- "Fold one reaction into the buffer it belongs to. The message it names may561+ "One reaction folded into the buffer it belongs to. `frq.reactions` says what
584- not be there — a reaction on something older than the backlog we asked for —562+ that means; this is the atom it means it to."
585- and then there is nothing to show it on, so nothing happens."
586 [channel msgid emoji nick on?]563 [channel msgid emoji nick on?]
587- (when (and channel msgid (seq (or emoji "")))564+ (swap! channels reactions/update-reaction channel msgid emoji nick on?))
588- (swap! channels
589- (fn [m]
590- (if-let [msgs (get-in m [channel :messages])]
591- (assoc-in m [channel :messages]
592- (mapv (fn [msg]
593- (if (= msgid (:id msg))
594- (update msg :reactions with-reaction emoji nick on?)
595- msg))
596- msgs))
597- m)))))
598 565
599 (defn edit-message!566 (defn edit-message!
600 "Rewrite a message in place, and say so. `msgid` names the line as it was567 "Rewrite a message in place, and say so. `msgid` names the line as it was
@@ -1450,15 +1417,9 @@
1450 (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))1417 (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
1451 1418
1452 (defn- dm-peer-did1419 (defn- dm-peer-did
1453- "The DID of whoever this DM buffer is with, from the last thing they said.1420+ "Who this DM is with. `frq.reactions` reads it out of the buffer."
1454- nil for a channel, and for a conversation where nobody with a DID has spoken
1455- — a signature over a DM needs both sides named, and there is nothing to name."
1456 [channel]1421 [channel]
1457- (when-not (str/starts-with? (or channel "") "#")1422+ (reactions/peer-did @channels channel @form-nick))
1458- (->> (get-in @channels [channel :messages])
1459- (remove #(= @form-nick (:from %)))
1460- (keep :did)
1461- last)))
1462 1423
1463 (defn mine?1424 (defn mine?
1464 "Whether we are the one who said this. Nick against nick, which is what the1425 "Whether we are the one who said this. Nick against nick, which is what the
@@ -1596,29 +1557,16 @@
1596 1557
1597 (defn close-picker! [] (reset! reacting nil))1558 (defn close-picker! [] (reset! reacting nil))
1598 1559
1599-(defn picker-emoji1560+(def picker-emoji
1600- "What the picker is showing right now: the popular row, one group, or1561+ "Moved to `frq.reactions`: it is the cells and the catalog, both of which
1601- whatever the search matches — by name, so \"cat\" finds the cat and the cat1562+ are shared, and the phone shows the same picker."
1602- face, and by the emoji itself, so pasting one finds it."1563+ reactions/picker-emoji)
1603- []
1604- (let [q (str/lower-case (str/trim @emoji-search))
1605- ;; A blank group is no group: the popular row is what nothing selected
1606- ;; means, and an empty string would filter the catalog down to nothing.
1607- group (when (seq (or @emoji-group "")) @emoji-group)]
1608- (cond
1609- (seq q) (->> emoji/catalog
1610- (filter (fn [[glyph name _]]
1611- (or (str/includes? (str/lower-case name) q)
1612- (str/includes? glyph q))))
1613- vec)
1614- group (vec (filter (fn [[_ _ g]] (= g group)) emoji/catalog))
1615- :else (mapv (fn [glyph] [glyph glyph nil]) emoji/popular))))
1616 1564
1617 (defn my-reaction?1565 (defn my-reaction?
1618 "Whether this nick is already on that emoji — which is what makes a second1566 "Whether this nick is already on that emoji — which is what makes a second
1619 click take it off rather than send the same reaction twice."1567 click take it off rather than send the same reaction twice."
1620 [m emoji]1568 [m emoji]
1621- (boolean (some #{@form-nick} (get (:reactions m) emoji))))1569+ (reactions/mine? m emoji @form-nick))
1622 1570
1623 (def reaction-hover cells/reaction-hover)1571 (def reaction-hover cells/reaction-hover)
1624 1572