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>
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. | ||
| 110 | 110 | the day changes |
| 111 | 111 | * A chip above a reply quoting what it answers, and a click that goes there; |
| 112 | 112 | ↩ 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 | 116 | * Inline previews for PNG links, fetched once and cached under |
| 114 | 117 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 115 | 118 | * 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 changes | 110 | 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 out | 112 | ↩ 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 under | 116 | * Inline previews for PNG links, fetched once and cached under |
| 114 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size | 117 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 115 | * Join/part notices, DMs bucketed under the sender's nick | 118 | * Join/part notices, DMs bucketed under the sender's nick |
modified
src/frq/app.jolt +35 -0 | @@ -254,6 +254,35 @@ | ||
| 254 | 254 | ;; The message it answers is older than this buffer goes. |
| 255 | 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 | 286 | (defn message-row |
| 258 | 287 | "One message. `prev` is the message above it, which decides whether this one |
| 259 | 288 | repeats the sender. |
| @@ -319,6 +348,12 @@ | ||
| 319 | 348 | [:vbox {:key :text :spacing 2} |
| 320 | 349 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 321 | 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 | 357 | ;; Pictures under the line that linked them. The link stays: it is what a |
| 323 | 358 | ;; failed fetch, an unsupported format, or a phone with no TLS leaves you. |
| 324 | 359 | [: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-row | 286 | (defn message-row |
| 258 | "One message. `prev` is the message above it, which decides whether this one | 287 | "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 a | 357 | ;; 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 @@ | ||
| 97 | 97 | (recur (rest in) (conj out c))) |
| 98 | 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 | 111 | (defn tag-value |
| 101 | 112 | "One IRCv3 tag's value, unescaped, or nil." |
| 102 | 113 | [tags key] |
| @@ -345,6 +356,25 @@ | ||
| 345 | 356 | (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " ")) |
| 346 | 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 | 378 | (defn close! [conn] |
| 349 | 379 | ;; Written straight out rather than queued: the reader may already be gone, |
| 350 | 380 | ;; 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-value | 111 | (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 @@ | ||
| 122 | 122 | said, from the server's own `time` tag where there is one, and `:did` who |
| 123 | 123 | said it, from the `account` tag — an identity that outlasts whatever nick |
| 124 | 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 | 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 | 129 | (let [at (or at (clock/now-ms)) |
| 129 | 130 | who (avatars/actor did from)] |
| 130 | 131 | (doseq [url (media/image-urls text)] |
| @@ -144,7 +145,9 @@ | ||
| 144 | 145 | :at at |
| 145 | 146 | ;; `:id` is what a reply points at, and |
| 146 | 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 | 151 | (update-in [channel :unread] (if viewing? (constantly 0) inc))))))))) |
| 149 | 152 | |
| 150 | 153 | (defn open-channel! |
| @@ -168,6 +171,49 @@ | ||
| 168 | 171 | (swap! channels #(assoc-in % [name :joining?] true)) |
| 169 | 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 | 217 | (declare join!) |
| 172 | 218 | |
| 173 | 219 | (defn apply-msg! |
| @@ -199,7 +245,24 @@ | ||
| 199 | 245 | ;; +reply; a client that sent the draft name |
| 200 | 246 | ;; may still reach us before it does. |
| 201 | 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 | 266 | "JOIN" (let [ch (first params)] |
| 204 | 267 | (if (= from @form-nick) |
| 205 | 268 | (let [fresh? (empty? (get-in @channels [ch :messages]))] |
| @@ -449,6 +512,33 @@ | ||
| 449 | 512 | (reset! replying-to nil) |
| 450 | 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 | 542 | (defn channel-list |
| 453 | 543 | "Buffers most recently opened first, filtered by the search box. |
| 454 | 544 | |
| @@ -501,6 +591,11 @@ | ||
| 501 | 591 | (when id |
| 502 | 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 | 599 | ;; The message a "go to" is currently aiming at. Set for the frame that scrolls |
| 505 | 600 | ;; to it and taken off again — a scroll target that stays set would pin the |
| 506 | 601 | ;; 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` who | 122 | 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 nick | 123 | 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 at | 145 | :at at |
| 145 | ;; `:id` is what a reply points at, and | 146 | ;; `: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 name | 245 | ;; +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-list | 542 | (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 id | 591 | (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 scrolls | 599 | ;; 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 the | 600 | ;; 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. |