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>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 @@ | ||
| 363 | 363 | [:button {:label "✕" :on-click actions/close-picker!}]] |
| 364 | 364 | ;; The groups are what the search box is not: a way in for someone who has |
| 365 | 365 | ;; 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 | |
| 367 | 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 | 373 | [:vbox {:key :groups :spacing 4} |
| 369 | 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 | 378 | [:hbox {:key i :spacing 4} |
| 372 | 379 | (for [g row] |
| 373 | 380 | [: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 has | 364 | ;; 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 them | 365 | ;; 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 |
| 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 @@ | ||
| 87 | 87 | `[chat-screen]` is a function, and asking whether it fills means calling it — |
| 88 | 88 | the root wraps every screen in a plain `:vbox`, and without this the wrapper |
| 89 | 89 | 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 | 105 | [node] |
| 92 | 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 | 114 | node)) |
| 95 | 115 | |
| 96 | 116 | (defn- fills-row? |
| @@ -412,6 +432,45 @@ | ||
| 412 | 432 | .borderRadius (m/BorderRadius.circular t/radius-s)) |
| 413 | 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 | 474 | :separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider) |
| 416 | 475 | |
| 417 | 476 | ;; A 16px indeterminate circle, with the label as a caption beside it. |
| @@ -580,9 +639,14 @@ | ||
| 580 | 639 | (defn render |
| 581 | 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 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." | |
| 586 | 650 | [node] |
| 587 | 651 | (cond |
| 588 | 652 | (nil? node) (m/SizedBox .width 0.0 .height 0.0) |
| @@ -591,7 +655,7 @@ | ||
| 591 | 655 | (let [head (first node)] |
| 592 | 656 | (if (keyword? head) |
| 593 | 657 | (render-tag head node) |
| 594 | - (render (apply head (rest node))))) | |
| 658 | + (render (expand node)))) | |
| 595 | 659 | (seq? node) |
| 596 | 660 | (m/Column .crossAxisAlignment m/CrossAxisAlignment.start |
| 597 | 661 | .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 wrapper | 88 | 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. Expanded | 89 | 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 render | 639 | (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 the | 642 | + 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 does | 643 | + 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 | (cond | 651 | (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.start | 660 | (m/Column .crossAxisAlignment m/CrossAxisAlignment.start |
| 597 | .mainAxisSize m/MainAxisSize.min | 661 | .mainAxisSize m/MainAxisSize.min |
modified
flutter/src/frq/main.cljd +97 -2 | @@ -40,6 +40,8 @@ | ||
| 40 | 40 | [frq.screens.app :as screens] |
| 41 | 41 | [frq.rooms :as rooms] |
| 42 | 42 | [frq.members :as members] |
| 43 | + [frq.reactions :as reactions] | |
| 44 | + [frq.irc.mutate :as mutate] | |
| 43 | 45 | [frq.oauth.core :as oauth] |
| 44 | 46 | [frq.oauth.dart :as oauth-dart] |
| 45 | 47 | [frq.irc.parse :as irc] |
| @@ -123,12 +125,49 @@ | ||
| 123 | 125 | (first params) (nth params 1 "") (drop 2 params)) |
| 124 | 126 | |
| 125 | 127 | (= "PRIVMSG" cmd) |
| 126 | - (let [target (first params) | |
| 128 | + (let [tags (:tags m) | |
| 129 | + target (first params) | |
| 127 | 130 | text (last params) |
| 128 | 131 | name (if (rooms/dm? target) who target)] |
| 129 | 132 | (swap! cells/channels update name |
| 130 | 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 | 172 | (defn- note! [m] |
| 134 | 173 | (reset! last-line (str (:command m) " " (last (:params m)))) |
| @@ -443,6 +482,31 @@ | ||
| 443 | 482 | #(update % :messages conj {:from @cells/form-nick :text text}))) |
| 444 | 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 | 510 | (defn ^:async main [] |
| 447 | 511 | (m/WidgetsFlutterBinding.ensureInitialized) |
| 448 | 512 | ;; Layout errors do not come back as exceptions — they happen after the |
| @@ -488,6 +552,37 @@ | ||
| 488 | 552 | (when-let [c @conn] (net/send-line! c (str "PART " name))) |
| 489 | 553 | (swap! cells/channels dissoc name)) |
| 490 | 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 | 586 | :wide? (fn [] false) |
| 492 | 587 | :desktop? (fn [] false) |
| 493 | 588 | :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 name | 132 | (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 the | 512 | ;; 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 @@ | ||
| 18 | 18 | [frq.atproto :as atproto] |
| 19 | 19 | [frq.irc.parse :as parse] |
| 20 | 20 | [frq.irc.handshake :as handshake] |
| 21 | + [frq.irc.mutate :as mutate] | |
| 21 | 22 | [frq.msgsig :as msgsig] |
| 22 | 23 | [frq.wire :as wire] |
| 23 | 24 | [jolt.ffi :as ffi] |
| @@ -269,25 +270,17 @@ | ||
| 269 | 270 | (defn react! |
| 270 | 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, 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." | |
| 274 | 274 | ([conn target msgid emoji] (react! conn target msgid emoji nil)) |
| 275 | 275 | ([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)))) | |
| 279 | 277 | |
| 280 | 278 | (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." | |
| 286 | 280 | ([conn target msgid emoji] (unreact! conn target msgid emoji nil)) |
| 287 | 281 | ([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 | + | |
| 291 | 284 | |
| 292 | 285 | (defn close! [conn] |
| 293 | 286 | ;; 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, and | 273 | + 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, so | 279 | + "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 msgid | 283 | + |
| 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 @@ | ||
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | 8 | [frq.rooms :as rooms] |
| 9 | 9 | [frq.members :as members] |
| 10 | + [frq.reactions :as reactions] | |
| 10 | 11 | [glimmer.ratom :as r :refer [atom]] |
| 11 | 12 | [frq.actions :as actions] |
| 12 | 13 | [frq.cells :as cells] |
| @@ -553,48 +554,14 @@ | ||
| 553 | 554 | (reset! screen :chats)) |
| 554 | 555 | (remember-rooms! true)) |
| 555 | 556 | |
| 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) | |
| 581 | 559 | |
| 582 | 560 | (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." | |
| 586 | 563 | [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?)) | |
| 598 | 565 | |
| 599 | 566 | (defn edit-message! |
| 600 | 567 | "Rewrite a message in place, and say so. `msgid` names the line as it was |
| @@ -1450,15 +1417,9 @@ | ||
| 1450 | 1417 | (reset! error (str "Could not read that picture: " (or (ex-message e) e))))))) |
| 1451 | 1418 | |
| 1452 | 1419 | (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." | |
| 1456 | 1421 | [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)) | |
| 1462 | 1423 | |
| 1463 | 1424 | (defn mine? |
| 1464 | 1425 | "Whether we are the one who said this. Nick against nick, which is what the |
| @@ -1596,29 +1557,16 @@ | ||
| 1596 | 1557 | |
| 1597 | 1558 | (defn close-picker! [] (reset! reacting nil)) |
| 1598 | 1559 | |
| 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) | |
| 1616 | 1564 | |
| 1617 | 1565 | (defn my-reaction? |
| 1618 | 1566 | "Whether this nick is already on that emoji — which is what makes a second |
| 1619 | 1567 | click take it off rather than send the same reaction twice." |
| 1620 | 1568 | [m emoji] |
| 1621 | - (boolean (some #{@form-nick} (get (:reactions m) emoji)))) | |
| 1569 | + (reactions/mine? m emoji @form-nick)) | |
| 1622 | 1570 | |
| 1623 | 1571 | (def reaction-hover cells/reaction-hover) |
| 1624 | 1572 | |
| @@ -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-reactions | 557 | +(def ^:private parse-reactions reactions/parse-tally) |
| 557 | - "The server's tally of what is already on a message, as | 558 | +(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 may | 561 | + "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 was | 567 | "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-did | 1419 | (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 the | 1425 | "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-emoji | 1560 | +(def picker-emoji |
| 1600 | - "What the picker is showing right now: the popular row, one group, or | 1561 | + "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 cat | 1562 | + 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 second | 1566 | "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 | ||