nandi/frqpublic Fork 0
c2e02a9
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 to a message, and see what others put there

Reactions ride the same two halves the reply chip already reads: freeq stamps
every message with a `msgid`, and a reaction is a `TAGMSG` carrying `+react`
with `+reply` naming what it is on. So this is the reply work's other side —
pills under the message, a `+` that opens a short row of emoji, and a second
click on a pill you are already on that takes yours off (`+freeq.at/unreact`,
which the server keys by DID so a nick change does not strand it).

A quick row rather than a picker: the point of a reaction is that it costs
less than typing, and a grid of a thousand glyphs costs more.

The server relays a TAGMSG to everyone in the channel *except* the client that
sent it, so a reaction of ours is applied here as well as sent — otherwise the
pill would only appear once somebody else reacted too. Coming the other way,
CHATHISTORY carries `+freeq.at/reactions` with the whole tally, so a reopened
buffer shows what was already there instead of starting blank.

That tag is the first one whose value uses the IRCv3 escapes — it packs
`emoji:nick,nick;emoji:nick` and escapes every separator — so `tag-value` now
unescapes, and there is an `escape-tag-value` beside it for what we send.

Our own lines are still pushed locally with no id, so there is nothing to
react to on them; that wants `echo-message`, which is a change to how sending
works rather than to reactions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T12:16:00-07:00 Browse files
c2e02a9 parent: 8c76dd9
modified README.md +3 -0
@@ -110,6 +110,9 @@ surface — that surface does not work on Android either, while the syscalls do.
110110 the day changes
111111 * A chip above a reply quoting what it answers, and a click that goes there;
112112 ↩ beside a sender to answer them, with `+draft/reply` on the way out
113+* Emoji reactions: pills under a message, a quick row to add one, a second
114+ click to take yours off — sent as `TAGMSG`, and restored from the server's
115+ own tally when the backlog comes back
113116 * Inline previews for PNG links, fetched once and cached under
114117 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
115118 * Join/part notices, DMs bucketed under the sender's nick
@@ -110,6 +110,9 @@ surface — that surface does not work on Android either, while the syscalls do.
110 the day changes110 the day changes
111 * A chip above a reply quoting what it answers, and a click that goes there;111 * A chip above a reply quoting what it answers, and a click that goes there;
112 ↩ beside a sender to answer them, with `+draft/reply` on the way out112 ↩ beside a sender to answer them, with `+draft/reply` on the way out
113+* Emoji reactions: pills under a message, a quick row to add one, a second
114+ click to take yours off — sent as `TAGMSG`, and restored from the server's
115+ own tally when the backlog comes back
113 * Inline previews for PNG links, fetched once and cached under116 * Inline previews for PNG links, fetched once and cached under
114 `$XDG_CACHE_HOME/frq/media`; click one to see it full size117 `$XDG_CACHE_HOME/frq/media`; click one to see it full size
115 * Join/part notices, DMs bucketed under the sender's nick118 * Join/part notices, DMs bucketed under the sender's nick
modified src/frq/app.jolt +35 -0
@@ -254,6 +254,35 @@
254254 ;; The message it answers is older than this buffer goes.
255255 [:dim-label {:label "↩ replying to an earlier message"}])])
256256
257+(defn- reaction-row
258+ "What people have put on a message, and the way to put something there too.
259+
260+ A pill carries its count and toggles: clicking one you are already on takes
261+ yours off, which is the same gesture that put it there. The `+` opens the
262+ quick row for this message alone — the emoji are only worth their space while
263+ someone is choosing one."
264+ [channel m]
265+ (let [reactions (:reactions m)
266+ open? (= (:id m) @s/reacting)]
267+ [:vbox {:key :reactions :spacing 2}
268+ [:hbox {:key :pills :spacing 4}
269+ (for [emoji (sort (keys reactions))]
270+ (let [nicks (get reactions emoji)]
271+ [:button {:key emoji
272+ :label (str emoji " " (count nicks))
273+ :kind (if (s/my-reaction? m emoji) :primary :normal)
274+ :on-click #(s/toggle-reaction! channel m emoji)}]))
275+ [:button {:key :add
276+ :label (if open? "×" "+")
277+ :on-click #(reset! s/reacting (when-not open? (:id m)))}]]
278+ [:hbox {:key :picker :spacing 4}
279+ (when open?
280+ (for [emoji s/quick-reactions]
281+ [:button {:key emoji
282+ :label emoji
283+ :on-click #(do (s/toggle-reaction! channel m emoji)
284+ (reset! s/reacting nil))}]))]]))
285+
257286 (defn message-row
258287 "One message. `prev` is the message above it, which decides whether this one
259288 repeats the sender.
@@ -319,6 +348,12 @@
319348 [:vbox {:key :text :spacing 2}
320349 (map-indexed (fn [j run] (run-node j run (:system? m)))
321350 (text-runs (:text m)))]]
351+ ;; Reactions under the message they are about, and only where there is a
352+ ;; message to name: a line this client wrote itself, or one from before
353+ ;; the server started stamping ids, cannot be pointed at.
354+ [:vbox {:key :reactions-row}
355+ (when (and (:id m) (not (:system? m)))
356+ [reaction-row @s/current m])]
322357 ;; Pictures under the line that linked them. The link stays: it is what a
323358 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
324359 [:vbox {:key :images :spacing 4}
@@ -254,6 +254,35 @@
254 ;; The message it answers is older than this buffer goes.254 ;; The message it answers is older than this buffer goes.
255 [:dim-label {:label "↩ replying to an earlier message"}])])255 [:dim-label {:label "↩ replying to an earlier message"}])])
256 256
257+(defn- reaction-row
258+ "What people have put on a message, and the way to put something there too.
259+
260+ A pill carries its count and toggles: clicking one you are already on takes
261+ yours off, which is the same gesture that put it there. The `+` opens the
262+ quick row for this message alone — the emoji are only worth their space while
263+ someone is choosing one."
264+ [channel m]
265+ (let [reactions (:reactions m)
266+ open? (= (:id m) @s/reacting)]
267+ [:vbox {:key :reactions :spacing 2}
268+ [:hbox {:key :pills :spacing 4}
269+ (for [emoji (sort (keys reactions))]
270+ (let [nicks (get reactions emoji)]
271+ [:button {:key emoji
272+ :label (str emoji " " (count nicks))
273+ :kind (if (s/my-reaction? m emoji) :primary :normal)
274+ :on-click #(s/toggle-reaction! channel m emoji)}]))
275+ [:button {:key :add
276+ :label (if open? "×" "+")
277+ :on-click #(reset! s/reacting (when-not open? (:id m)))}]]
278+ [:hbox {:key :picker :spacing 4}
279+ (when open?
280+ (for [emoji s/quick-reactions]
281+ [:button {:key emoji
282+ :label emoji
283+ :on-click #(do (s/toggle-reaction! channel m emoji)
284+ (reset! s/reacting nil))}]))]]))
285+
257 (defn message-row286 (defn message-row
258 "One message. `prev` is the message above it, which decides whether this one287 "One message. `prev` is the message above it, which decides whether this one
259 repeats the sender.288 repeats the sender.
@@ -319,6 +348,12 @@
319 [:vbox {:key :text :spacing 2}348 [:vbox {:key :text :spacing 2}
320 (map-indexed (fn [j run] (run-node j run (:system? m)))349 (map-indexed (fn [j run] (run-node j run (:system? m)))
321 (text-runs (:text m)))]]350 (text-runs (:text m)))]]
351+ ;; Reactions under the message they are about, and only where there is a
352+ ;; message to name: a line this client wrote itself, or one from before
353+ ;; the server started stamping ids, cannot be pointed at.
354+ [:vbox {:key :reactions-row}
355+ (when (and (:id m) (not (:system? m)))
356+ [reaction-row @s/current m])]
322 ;; Pictures under the line that linked them. The link stays: it is what a357 ;; Pictures under the line that linked them. The link stays: it is what a
323 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.358 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
324 [:vbox {:key :images :spacing 4}359 [:vbox {:key :images :spacing 4}
modified src/frq/irc.jolt +30 -0
@@ -97,6 +97,17 @@
9797 (recur (rest in) (conj out c)))
9898 (apply str out)))))
9999
100+(defn escape-tag-value
101+ "The inverse, for a tag this client sends. An emoji needs none of it; a
102+ message id could, and the cost of being right is a pass over a short string."
103+ [v]
104+ (-> (str v)
105+ (str/replace "\\" "\\\\")
106+ (str/replace ";" "\\:")
107+ (str/replace " " "\\s")
108+ (str/replace "\r" "\\r")
109+ (str/replace "\n" "\\n")))
110+
100111 (defn tag-value
101112 "One IRCv3 tag's value, unescaped, or nil."
102113 [tags key]
@@ -345,6 +356,25 @@
345356 (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " "))
346357 "PRIVMSG " target " :" text))))
347358
359+(defn tagmsg!
360+ "A message that is only tags: how freeq carries a reaction, a typing hint or
361+ a delete. `tags` is a map of name to value, sent in no particular order — the
362+ server reads them by name."
363+ [conn target tags]
364+ (let [pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))]
365+ (send-line! conn (str "@" (str/join ";" pairs) " TAGMSG " target))))
366+
367+(defn react!
368+ "Put `emoji` on the message `msgid`, for everyone in `target` to see."
369+ [conn target msgid emoji]
370+ (tagmsg! conn target {"+react" emoji "+reply" msgid}))
371+
372+(defn unreact!
373+ "Take it off again. The server keys the removal by DID where there is one, so
374+ it survives a nick change and cannot be done on someone else's behalf."
375+ [conn target msgid emoji]
376+ (tagmsg! conn target {"+freeq.at/unreact" emoji "+reply" msgid}))
377+
348378 (defn close! [conn]
349379 ;; Written straight out rather than queued: the reader may already be gone,
350380 ;; and there is nothing left to lose if this one is.
@@ -97,6 +97,17 @@
97 (recur (rest in) (conj out c)))97 (recur (rest in) (conj out c)))
98 (apply str out)))))98 (apply str out)))))
99 99
100+(defn escape-tag-value
101+ "The inverse, for a tag this client sends. An emoji needs none of it; a
102+ message id could, and the cost of being right is a pass over a short string."
103+ [v]
104+ (-> (str v)
105+ (str/replace "\\" "\\\\")
106+ (str/replace ";" "\\:")
107+ (str/replace " " "\\s")
108+ (str/replace "\r" "\\r")
109+ (str/replace "\n" "\\n")))
110+
100 (defn tag-value111 (defn tag-value
101 "One IRCv3 tag's value, unescaped, or nil."112 "One IRCv3 tag's value, unescaped, or nil."
102 [tags key]113 [tags key]
@@ -345,6 +356,25 @@
345 (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " "))356 (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " "))
346 "PRIVMSG " target " :" text))))357 "PRIVMSG " target " :" text))))
347 358
359+(defn tagmsg!
360+ "A message that is only tags: how freeq carries a reaction, a typing hint or
361+ a delete. `tags` is a map of name to value, sent in no particular order — the
362+ server reads them by name."
363+ [conn target tags]
364+ (let [pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))]
365+ (send-line! conn (str "@" (str/join ";" pairs) " TAGMSG " target))))
366+
367+(defn react!
368+ "Put `emoji` on the message `msgid`, for everyone in `target` to see."
369+ [conn target msgid emoji]
370+ (tagmsg! conn target {"+react" emoji "+reply" msgid}))
371+
372+(defn unreact!
373+ "Take it off again. The server keys the removal by DID where there is one, so
374+ it survives a nick change and cannot be done on someone else's behalf."
375+ [conn target msgid emoji]
376+ (tagmsg! conn target {"+freeq.at/unreact" emoji "+reply" msgid}))
377+
348 (defn close! [conn]378 (defn close! [conn]
349 ;; Written straight out rather than queued: the reader may already be gone,379 ;; Written straight out rather than queued: the reader may already be gone,
350 ;; and there is nothing left to lose if this one is.380 ;; and there is nothing left to lose if this one is.
modified src/frq/state.jolt +99 -4
@@ -122,9 +122,10 @@
122122 said, from the server's own `time` tag where there is one, and `:did` who
123123 said it, from the `account` tag an identity that outlasts whatever nick
124124 they are using today. `:id` names this message so a reply can point at it,
125- and `:reply-to` is the one it answers."
125+ and `:reply-to` is the one it answers. `:reactions` is what people have put
126+ on it already, which on a replayed backlog the server hands over in full."
126127 ([channel from text] (push-message! channel from text {}))
127- ([channel from text {:keys [at did id reply-to]}]
128+ ([channel from text {:keys [at did id reply-to reactions]}]
128129 (let [at (or at (clock/now-ms))
129130 who (avatars/actor did from)]
130131 (doseq [url (media/image-urls text)]
@@ -144,7 +145,9 @@
144145 :at at
145146 ;; `:id` is what a reply points at, and
146147 ;; `:reply-to` is what this one points at.
147- :id id :reply-to reply-to})
148+ :id id :reply-to reply-to
149+ ;; emoji -> the nicks who put it there
150+ :reactions (or reactions {})})
148151 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
149152
150153 (defn open-channel!
@@ -168,6 +171,49 @@
168171 (swap! channels #(assoc-in % [name :joining?] true))
169172 (irc/join! @conn name))))
170173
174+(defn- parse-reactions
175+ "The server's tally of what is already on a message, as
176+ `emoji:nick,nick;emoji:nick` what CHATHISTORY sends so reactions survive a
177+ reconnect rather than starting empty every time the app opens."
178+ [encoded]
179+ (when (seq (or encoded ""))
180+ (reduce (fn [acc part]
181+ (let [[emoji nicks] (str/split part #":" 2)]
182+ (if (and (seq emoji) (seq (or nicks "")))
183+ (assoc acc emoji (vec (remove str/blank? (str/split nicks #","))))
184+ acc)))
185+ {}
186+ (str/split encoded #";"))))
187+
188+(defn- with-reaction
189+ "One nick's reaction added to or taken off a tally. An emoji nobody is left
190+ on goes away with them: an empty pill is a pill that says nothing."
191+ [reactions emoji nick on?]
192+ (let [nicks (vec (get reactions emoji []))
193+ nicks (if on?
194+ (if (some #{nick} nicks) nicks (conj nicks nick))
195+ (vec (remove #{nick} nicks)))]
196+ (if (seq nicks)
197+ (assoc reactions emoji nicks)
198+ (dissoc reactions emoji))))
199+
200+(defn update-reaction!
201+ "Fold one reaction into the buffer it belongs to. The message it names may
202+ not be there a reaction on something older than the backlog we asked for
203+ and then there is nothing to show it on, so nothing happens."
204+ [channel msgid emoji nick on?]
205+ (when (and channel msgid (seq (or emoji "")))
206+ (swap! channels
207+ (fn [m]
208+ (if-let [msgs (get-in m [channel :messages])]
209+ (assoc-in m [channel :messages]
210+ (mapv (fn [msg]
211+ (if (= msgid (:id msg))
212+ (update msg :reactions with-reaction emoji nick on?)
213+ msg))
214+ msgs))
215+ m)))))
216+
171217 (declare join!)
172218
173219 (defn apply-msg!
@@ -199,7 +245,24 @@
199245 ;; +reply; a client that sent the draft name
200246 ;; may still reach us before it does.
201247 :reply-to (or (irc/tag-value tags "+reply")
202- (irc/tag-value tags "+draft/reply"))}))
248+ (irc/tag-value tags "+draft/reply"))
249+ :reactions (parse-reactions
250+ (irc/tag-value tags "+freeq.at/reactions"))}))
251+ ;; A message that is only tags. A reaction is the one this client reads:
252+ ;; `+react` puts an emoji on the message `+reply` names, and the server's
253+ ;; own `+freeq.at/unreact` takes it off again.
254+ "TAGMSG" (let [tags (:tags msg)
255+ target (first params)
256+ buffer (if (str/starts-with? (or target "") "#") target from)
257+ msgid (or (irc/tag-value tags "+reply")
258+ (irc/tag-value tags "+draft/reply"))
259+ add (or (irc/tag-value tags "+react")
260+ (irc/tag-value tags "+draft/react"))
261+ remove-it (irc/tag-value tags "+freeq.at/unreact")]
262+ (cond
263+ add (update-reaction! buffer msgid add from true)
264+ remove-it (update-reaction! buffer msgid remove-it from false)
265+ :else nil))
203266 "JOIN" (let [ch (first params)]
204267 (if (= from @form-nick)
205268 (let [fresh? (empty? (get-in @channels [ch :messages]))]
@@ -449,6 +512,33 @@
449512 (reset! replying-to nil)
450513 (reset! draft ""))))
451514
515+(def quick-reactions
516+ "The emoji a click away. A short row rather than a picker: the point of a
517+ reaction is that it costs less than typing, and a grid of a thousand glyphs
518+ costs more than typing."
519+ ["👍" "" "😂" "🎉" "👀" "🔥"])
520+
521+(defn my-reaction?
522+ "Whether this nick is already on that emoji — which is what makes a second
523+ click take it off rather than send the same reaction twice."
524+ [m emoji]
525+ (boolean (some #{@form-nick} (get (:reactions m) emoji))))
526+
527+(defn toggle-reaction!
528+ "Put my emoji on a message, or take it off if it is already mine.
529+
530+ Applied here as well as sent: the server relays a TAGMSG to everyone in the
531+ channel *except* the client that sent it, so without this the pill would only
532+ appear once someone else reacted too."
533+ [channel m emoji]
534+ (when-let [msgid (:id m)]
535+ (let [on? (not (my-reaction? m emoji))]
536+ (when-let [c @conn]
537+ (if on?
538+ (irc/react! c channel msgid emoji)
539+ (irc/unreact! c channel msgid emoji)))
540+ (update-reaction! channel msgid emoji @form-nick on?))))
541+
452542 (defn channel-list
453543 "Buffers most recently opened first, filtered by the search box.
454544
@@ -501,6 +591,11 @@
501591 (when id
502592 (first (filter #(= id (:id %)) (get-in @channels [channel :messages])))))
503593
594+;; The message whose reaction row is open, by id. One at a time: the row of
595+;; emoji is only worth its space while it is being used, and leaving it under
596+;; every message would push the conversation apart.
597+(defonce reacting (atom nil))
598+
504599 ;; The message a "go to" is currently aiming at. Set for the frame that scrolls
505600 ;; to it and taken off again a scroll target that stays set would pin the
506601 ;; view there and take scrolling away from the reader.
@@ -122,9 +122,10 @@
122 said, from the server's own `time` tag where there is one, and `:did` who122 said, from the server's own `time` tag where there is one, and `:did` who
123 said it, from the `account` tag an identity that outlasts whatever nick123 said it, from the `account` tag an identity that outlasts whatever nick
124 they are using today. `:id` names this message so a reply can point at it,124 they are using today. `:id` names this message so a reply can point at it,
125- and `:reply-to` is the one it answers."125+ and `:reply-to` is the one it answers. `:reactions` is what people have put
126+ on it already, which on a replayed backlog the server hands over in full."
126 ([channel from text] (push-message! channel from text {}))127 ([channel from text] (push-message! channel from text {}))
127- ([channel from text {:keys [at did id reply-to]}]128+ ([channel from text {:keys [at did id reply-to reactions]}]
128 (let [at (or at (clock/now-ms))129 (let [at (or at (clock/now-ms))
129 who (avatars/actor did from)]130 who (avatars/actor did from)]
130 (doseq [url (media/image-urls text)]131 (doseq [url (media/image-urls text)]
@@ -144,7 +145,9 @@
144 :at at145 :at at
145 ;; `:id` is what a reply points at, and146 ;; `:id` is what a reply points at, and
146 ;; `:reply-to` is what this one points at.147 ;; `:reply-to` is what this one points at.
147- :id id :reply-to reply-to})148+ :id id :reply-to reply-to
149+ ;; emoji -> the nicks who put it there
150+ :reactions (or reactions {})})
148 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))151 (update-in [channel :unread] (if viewing? (constantly 0) inc)))))))))
149 152
150 (defn open-channel!153 (defn open-channel!
@@ -168,6 +171,49 @@
168 (swap! channels #(assoc-in % [name :joining?] true))171 (swap! channels #(assoc-in % [name :joining?] true))
169 (irc/join! @conn name))))172 (irc/join! @conn name))))
170 173
174+(defn- parse-reactions
175+ "The server's tally of what is already on a message, as
176+ `emoji:nick,nick;emoji:nick` what CHATHISTORY sends so reactions survive a
177+ reconnect rather than starting empty every time the app opens."
178+ [encoded]
179+ (when (seq (or encoded ""))
180+ (reduce (fn [acc part]
181+ (let [[emoji nicks] (str/split part #":" 2)]
182+ (if (and (seq emoji) (seq (or nicks "")))
183+ (assoc acc emoji (vec (remove str/blank? (str/split nicks #","))))
184+ acc)))
185+ {}
186+ (str/split encoded #";"))))
187+
188+(defn- with-reaction
189+ "One nick's reaction added to or taken off a tally. An emoji nobody is left
190+ on goes away with them: an empty pill is a pill that says nothing."
191+ [reactions emoji nick on?]
192+ (let [nicks (vec (get reactions emoji []))
193+ nicks (if on?
194+ (if (some #{nick} nicks) nicks (conj nicks nick))
195+ (vec (remove #{nick} nicks)))]
196+ (if (seq nicks)
197+ (assoc reactions emoji nicks)
198+ (dissoc reactions emoji))))
199+
200+(defn update-reaction!
201+ "Fold one reaction into the buffer it belongs to. The message it names may
202+ not be there a reaction on something older than the backlog we asked for
203+ and then there is nothing to show it on, so nothing happens."
204+ [channel msgid emoji nick on?]
205+ (when (and channel msgid (seq (or emoji "")))
206+ (swap! channels
207+ (fn [m]
208+ (if-let [msgs (get-in m [channel :messages])]
209+ (assoc-in m [channel :messages]
210+ (mapv (fn [msg]
211+ (if (= msgid (:id msg))
212+ (update msg :reactions with-reaction emoji nick on?)
213+ msg))
214+ msgs))
215+ m)))))
216+
171 (declare join!)217 (declare join!)
172 218
173 (defn apply-msg!219 (defn apply-msg!
@@ -199,7 +245,24 @@
199 ;; +reply; a client that sent the draft name245 ;; +reply; a client that sent the draft name
200 ;; may still reach us before it does.246 ;; may still reach us before it does.
201 :reply-to (or (irc/tag-value tags "+reply")247 :reply-to (or (irc/tag-value tags "+reply")
202- (irc/tag-value tags "+draft/reply"))}))248+ (irc/tag-value tags "+draft/reply"))
249+ :reactions (parse-reactions
250+ (irc/tag-value tags "+freeq.at/reactions"))}))
251+ ;; A message that is only tags. A reaction is the one this client reads:
252+ ;; `+react` puts an emoji on the message `+reply` names, and the server's
253+ ;; own `+freeq.at/unreact` takes it off again.
254+ "TAGMSG" (let [tags (:tags msg)
255+ target (first params)
256+ buffer (if (str/starts-with? (or target "") "#") target from)
257+ msgid (or (irc/tag-value tags "+reply")
258+ (irc/tag-value tags "+draft/reply"))
259+ add (or (irc/tag-value tags "+react")
260+ (irc/tag-value tags "+draft/react"))
261+ remove-it (irc/tag-value tags "+freeq.at/unreact")]
262+ (cond
263+ add (update-reaction! buffer msgid add from true)
264+ remove-it (update-reaction! buffer msgid remove-it from false)
265+ :else nil))
203 "JOIN" (let [ch (first params)]266 "JOIN" (let [ch (first params)]
204 (if (= from @form-nick)267 (if (= from @form-nick)
205 (let [fresh? (empty? (get-in @channels [ch :messages]))]268 (let [fresh? (empty? (get-in @channels [ch :messages]))]
@@ -449,6 +512,33 @@
449 (reset! replying-to nil)512 (reset! replying-to nil)
450 (reset! draft ""))))513 (reset! draft ""))))
451 514
515+(def quick-reactions
516+ "The emoji a click away. A short row rather than a picker: the point of a
517+ reaction is that it costs less than typing, and a grid of a thousand glyphs
518+ costs more than typing."
519+ ["👍" "" "😂" "🎉" "👀" "🔥"])
520+
521+(defn my-reaction?
522+ "Whether this nick is already on that emoji — which is what makes a second
523+ click take it off rather than send the same reaction twice."
524+ [m emoji]
525+ (boolean (some #{@form-nick} (get (:reactions m) emoji))))
526+
527+(defn toggle-reaction!
528+ "Put my emoji on a message, or take it off if it is already mine.
529+
530+ Applied here as well as sent: the server relays a TAGMSG to everyone in the
531+ channel *except* the client that sent it, so without this the pill would only
532+ appear once someone else reacted too."
533+ [channel m emoji]
534+ (when-let [msgid (:id m)]
535+ (let [on? (not (my-reaction? m emoji))]
536+ (when-let [c @conn]
537+ (if on?
538+ (irc/react! c channel msgid emoji)
539+ (irc/unreact! c channel msgid emoji)))
540+ (update-reaction! channel msgid emoji @form-nick on?))))
541+
452 (defn channel-list542 (defn channel-list
453 "Buffers most recently opened first, filtered by the search box.543 "Buffers most recently opened first, filtered by the search box.
454 544
@@ -501,6 +591,11 @@
501 (when id591 (when id
502 (first (filter #(= id (:id %)) (get-in @channels [channel :messages])))))592 (first (filter #(= id (:id %)) (get-in @channels [channel :messages])))))
503 593
594+;; The message whose reaction row is open, by id. One at a time: the row of
595+;; emoji is only worth its space while it is being used, and leaving it under
596+;; every message would push the conversation apart.
597+(defonce reacting (atom nil))
598+
504 ;; The message a "go to" is currently aiming at. Set for the frame that scrolls599 ;; The message a "go to" is currently aiming at. Set for the frame that scrolls
505 ;; to it and taken off again a scroll target that stays set would pin the600 ;; to it and taken off again a scroll target that stays set would pin the
506 ;; view there and take scrolling away from the reader.601 ;; view there and take scrolling away from the reader.