Answer a message, not just read that one was answered
The chip said what a message replied to; there was no way to make one. ↩ sits beside the sender's name — where a run of lines from one person is answered as the thing it is, rather than every line of it carrying a button — and the compose bar then says who is being answered, with a way out of it. The draft goes out tagged `+draft/reply`, which is what freeq's other clients thread on and what this one draws its chips from. The local echo carries the same link, so the chip is there before the server's copy comes back, if it ever does. The banner's row is added to the scroll's reserve, and does move the compose bar. That is the difference between it and the jump button: this row appears because the reader asked to answer something, and knows to expect it. Verified on irc.freeq.at with a second client watching: the line arrives tagged, pointing at the message that was answered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4876db8 parent: cbbcecd modified
README.md +2 -1 | @@ -108,7 +108,8 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 108 | 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 109 | 109 | * Twelve-hour timestamps from the server's own clock, with a heading wherever |
| 110 | 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 | 113 | * Inline previews for PNG links, fetched once and cached under |
| 113 | 114 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 114 | 115 | * Join/part notices, DMs bucketed under the sender's nick |
| @@ -108,7 +108,8 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead | 108 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| 109 | * Twelve-hour timestamps from the server's own clock, with a heading wherever | 109 | * Twelve-hour timestamps from the server's own clock, with a heading wherever |
| 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 | * Inline previews for PNG links, fetched once and cached under | 113 | * Inline previews for PNG links, fetched once and cached under |
| 113 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size | 114 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 114 | * Join/part notices, DMs bucketed under the sender's nick | 115 | * Join/part notices, DMs bucketed under the sender's nick |
modified
src/frq/app.jolt +18 -2 | @@ -280,7 +280,13 @@ | ||
| 280 | 280 | :size 22}]) |
| 281 | 281 | [:dim-label {:label (:from m)}] |
| 282 | 282 | (when-let [at (:at m)] |
| 283 | - [:dim-label {:label (clock/clock-time at)}])])] | |
| 283 | + [:dim-label {:label (clock/clock-time at)}]) | |
| 284 | + ;; Answering is offered where the sender is named: a run of lines from | |
| 285 | + ;; one person is answered as the thing it is, rather than every line | |
| 286 | + ;; of it carrying a button of its own. | |
| 287 | + [:vbox {:key :reply-action} | |
| 288 | + (when (:id m) | |
| 289 | + [:button {:label "↩" :on-click #(s/reply-to! m)}])]])] | |
| 284 | 290 | [:vbox {:key :text :spacing 2} |
| 285 | 291 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 286 | 292 | (text-runs (:text m)))] |
| @@ -357,7 +363,11 @@ | ||
| 357 | 363 | ;; move the compose bar under the reader every time the button came and |
| 358 | 364 | ;; went. |
| 359 | 365 | [:scroll {:orientation :vertical |
| 360 | - :reserve 116 | |
| 366 | + ;; Plus the reply banner's row when there is one. That row does | |
| 367 | + ;; move the compose bar, and should: it appears because the | |
| 368 | + ;; reader asked to answer something, unlike the jump button, | |
| 369 | + ;; which appears on its own and must not shift what is under it. | |
| 370 | + :reserve (if @s/replying-to 150 116) | |
| 361 | 371 | :stick-to-bottom true |
| 362 | 372 | :scroll-to-bottom @s/jump-tick |
| 363 | 373 | :on-change #(reset! s/at-present? (= "end" %))} |
| @@ -373,6 +383,12 @@ | ||
| 373 | 383 | [:spacer {:size 34}] |
| 374 | 384 | [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])] |
| 375 | 385 | [:separator {}] |
| 386 | + ;; What the draft is answering, directly above where it is being typed. | |
| 387 | + [:vbox {:key :replying} | |
| 388 | + (when-let [target @s/replying-to] | |
| 389 | + [:hbox {:spacing 8} | |
| 390 | + [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}] | |
| 391 | + [:button {:label "✕" :on-click s/cancel-reply!}]])] | |
| 376 | 392 | [:hbox {:spacing 8} |
| 377 | 393 | ;; narrow enough that Send keeps its place on a phone-width row |
| 378 | 394 | [:entry {:text @s/draft |
| @@ -280,7 +280,13 @@ | |||
| 280 | :size 22}]) | 280 | :size 22}]) |
| 281 | [:dim-label {:label (:from m)}] | 281 | [:dim-label {:label (:from m)}] |
| 282 | (when-let [at (:at m)] | 282 | (when-let [at (:at m)] |
| 283 | - [:dim-label {:label (clock/clock-time at)}])])] | 283 | + [:dim-label {:label (clock/clock-time at)}]) |
| 284 | + ;; Answering is offered where the sender is named: a run of lines from | ||
| 285 | + ;; one person is answered as the thing it is, rather than every line | ||
| 286 | + ;; of it carrying a button of its own. | ||
| 287 | + [:vbox {:key :reply-action} | ||
| 288 | + (when (:id m) | ||
| 289 | + [:button {:label "↩" :on-click #(s/reply-to! m)}])]])] | ||
| 284 | [:vbox {:key :text :spacing 2} | 290 | [:vbox {:key :text :spacing 2} |
| 285 | (map-indexed (fn [j run] (run-node j run (:system? m))) | 291 | (map-indexed (fn [j run] (run-node j run (:system? m))) |
| 286 | (text-runs (:text m)))] | 292 | (text-runs (:text m)))] |
| @@ -357,7 +363,11 @@ | |||
| 357 | ;; move the compose bar under the reader every time the button came and | 363 | ;; move the compose bar under the reader every time the button came and |
| 358 | ;; went. | 364 | ;; went. |
| 359 | [:scroll {:orientation :vertical | 365 | [:scroll {:orientation :vertical |
| 360 | - :reserve 116 | 366 | + ;; Plus the reply banner's row when there is one. That row does |
| 367 | + ;; move the compose bar, and should: it appears because the | ||
| 368 | + ;; reader asked to answer something, unlike the jump button, | ||
| 369 | + ;; which appears on its own and must not shift what is under it. | ||
| 370 | + :reserve (if @s/replying-to 150 116) | ||
| 361 | :stick-to-bottom true | 371 | :stick-to-bottom true |
| 362 | :scroll-to-bottom @s/jump-tick | 372 | :scroll-to-bottom @s/jump-tick |
| 363 | :on-change #(reset! s/at-present? (= "end" %))} | 373 | :on-change #(reset! s/at-present? (= "end" %))} |
| @@ -373,6 +383,12 @@ | |||
| 373 | [:spacer {:size 34}] | 383 | [:spacer {:size 34}] |
| 374 | [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])] | 384 | [:button {:label "↓ Jump to present" :on-click s/jump-to-present!}])] |
| 375 | [:separator {}] | 385 | [:separator {}] |
| 386 | + ;; What the draft is answering, directly above where it is being typed. | ||
| 387 | + [:vbox {:key :replying} | ||
| 388 | + (when-let [target @s/replying-to] | ||
| 389 | + [:hbox {:spacing 8} | ||
| 390 | + [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}] | ||
| 391 | + [:button {:label "✕" :on-click s/cancel-reply!}]])] | ||
| 376 | [:hbox {:spacing 8} | 392 | [:hbox {:spacing 8} |
| 377 | ;; narrow enough that Send keeps its place on a phone-width row | 393 | ;; narrow enough that Send keeps its place on a phone-width row |
| 378 | [:entry {:text @s/draft | 394 | [:entry {:text @s/draft |
modified
src/frq/irc.jolt +8 -2 | @@ -313,8 +313,14 @@ | ||
| 313 | 313 | (defn join! [conn channel] (send-line! conn (str "JOIN " channel))) |
| 314 | 314 | (defn part! [conn channel] (send-line! conn (str "PART " channel))) |
| 315 | 315 | |
| 316 | -(defn privmsg! [conn target text] | |
| 317 | - (send-line! conn (str "PRIVMSG " target " :" text))) | |
| 316 | +(defn privmsg! | |
| 317 | + "Say something. With `reply-to`, say it as an answer to that message: the | |
| 318 | + `+draft/reply` tag is what every other freeq client reads to thread it, and | |
| 319 | + what this one draws its chips from." | |
| 320 | + ([conn target text] (privmsg! conn target text nil)) | |
| 321 | + ([conn target text reply-to] | |
| 322 | + (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " ")) | |
| 323 | + "PRIVMSG " target " :" text)))) | |
| 318 | 324 | |
| 319 | 325 | (defn close! [conn] |
| 320 | 326 | ;; Written straight out rather than queued: the reader may already be gone, |
| @@ -313,8 +313,14 @@ | |||
| 313 | (defn join! [conn channel] (send-line! conn (str "JOIN " channel))) | 313 | (defn join! [conn channel] (send-line! conn (str "JOIN " channel))) |
| 314 | (defn part! [conn channel] (send-line! conn (str "PART " channel))) | 314 | (defn part! [conn channel] (send-line! conn (str "PART " channel))) |
| 315 | 315 | ||
| 316 | -(defn privmsg! [conn target text] | 316 | +(defn privmsg! |
| 317 | - (send-line! conn (str "PRIVMSG " target " :" text))) | 317 | + "Say something. With `reply-to`, say it as an answer to that message: the |
| 318 | + `+draft/reply` tag is what every other freeq client reads to thread it, and | ||
| 319 | + what this one draws its chips from." | ||
| 320 | + ([conn target text] (privmsg! conn target text nil)) | ||
| 321 | + ([conn target text reply-to] | ||
| 322 | + (send-line! conn (str (when (seq reply-to) (str "@+draft/reply=" reply-to " ")) | ||
| 323 | + "PRIVMSG " target " :" text)))) | ||
| 318 | 324 | ||
| 319 | (defn close! [conn] | 325 | (defn close! [conn] |
| 320 | ;; Written straight out rather than queued: the reader may already be gone, | 326 | ;; Written straight out rather than queued: the reader may already be gone, |
modified
src/frq/state.jolt +15 -3 | @@ -61,6 +61,13 @@ | ||
| 61 | 61 | (defonce channels (atom {})) |
| 62 | 62 | (defonce current (atom nil)) |
| 63 | 63 | (defonce draft (atom "")) |
| 64 | +;; The message the draft is answering, as {:id :from :text}, or nil. Held whole | |
| 65 | +;; rather than as an id alone so the compose bar can say who is being answered | |
| 66 | +;; without going looking for them. | |
| 67 | +(defonce replying-to (atom nil)) | |
| 68 | + | |
| 69 | +(defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text]))) | |
| 70 | +(defn cancel-reply! [] (reset! replying-to nil)) | |
| 64 | 71 | |
| 65 | 72 | ;; The picture being looked at full size, or nil. Vidya's tree has no overlay, |
| 66 | 73 | ;; so this is a screen of its own rather than a layer over the chat. |
| @@ -430,12 +437,16 @@ | ||
| 430 | 437 | |
| 431 | 438 | (defn send-draft! [] |
| 432 | 439 | (let [text (str/trim @draft) |
| 433 | - target @current] | |
| 440 | + target @current | |
| 441 | + reply-to @replying-to] | |
| 434 | 442 | (when (and (seq text) target) |
| 435 | 443 | ;; Saying something is a way of asking to see it. |
| 436 | 444 | (jump-to-present!) |
| 437 | - (when-let [c @conn] (irc/privmsg! c target text)) | |
| 438 | - (push-message! target @form-nick text) | |
| 445 | + (when-let [c @conn] (irc/privmsg! c target text (:id reply-to))) | |
| 446 | + ;; The local echo carries the same link, so the chip is there before the | |
| 447 | + ;; server's copy of the line comes back — if it ever does. | |
| 448 | + (push-message! target @form-nick text {:reply-to (:id reply-to)}) | |
| 449 | + (reset! replying-to nil) | |
| 439 | 450 | (reset! draft "")))) |
| 440 | 451 | |
| 441 | 452 | (defn channel-list |
| @@ -496,6 +507,7 @@ | ||
| 496 | 507 | (defonce jump-to (atom nil)) |
| 497 | 508 | |
| 498 | 509 | |
| 510 | + | |
| 499 | 511 | (defn last-preview [buffer] |
| 500 | 512 | (if-let [m (last (:messages buffer))] |
| 501 | 513 | (str (:from m) ": " (:text m)) |
| @@ -61,6 +61,13 @@ | |||
| 61 | (defonce channels (atom {})) | 61 | (defonce channels (atom {})) |
| 62 | (defonce current (atom nil)) | 62 | (defonce current (atom nil)) |
| 63 | (defonce draft (atom "")) | 63 | (defonce draft (atom "")) |
| 64 | +;; The message the draft is answering, as {:id :from :text}, or nil. Held whole | ||
| 65 | +;; rather than as an id alone so the compose bar can say who is being answered | ||
| 66 | +;; without going looking for them. | ||
| 67 | +(defonce replying-to (atom nil)) | ||
| 68 | + | ||
| 69 | +(defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text]))) | ||
| 70 | +(defn cancel-reply! [] (reset! replying-to nil)) | ||
| 64 | 71 | ||
| 65 | ;; The picture being looked at full size, or nil. Vidya's tree has no overlay, | 72 | ;; The picture being looked at full size, or nil. Vidya's tree has no overlay, |
| 66 | ;; so this is a screen of its own rather than a layer over the chat. | 73 | ;; so this is a screen of its own rather than a layer over the chat. |
| @@ -430,12 +437,16 @@ | |||
| 430 | 437 | ||
| 431 | (defn send-draft! [] | 438 | (defn send-draft! [] |
| 432 | (let [text (str/trim @draft) | 439 | (let [text (str/trim @draft) |
| 433 | - target @current] | 440 | + target @current |
| 441 | + reply-to @replying-to] | ||
| 434 | (when (and (seq text) target) | 442 | (when (and (seq text) target) |
| 435 | ;; Saying something is a way of asking to see it. | 443 | ;; Saying something is a way of asking to see it. |
| 436 | (jump-to-present!) | 444 | (jump-to-present!) |
| 437 | - (when-let [c @conn] (irc/privmsg! c target text)) | 445 | + (when-let [c @conn] (irc/privmsg! c target text (:id reply-to))) |
| 438 | - (push-message! target @form-nick text) | 446 | + ;; The local echo carries the same link, so the chip is there before the |
| 447 | + ;; server's copy of the line comes back — if it ever does. | ||
| 448 | + (push-message! target @form-nick text {:reply-to (:id reply-to)}) | ||
| 449 | + (reset! replying-to nil) | ||
| 439 | (reset! draft "")))) | 450 | (reset! draft "")))) |
| 440 | 451 | ||
| 441 | (defn channel-list | 452 | (defn channel-list |
| @@ -496,6 +507,7 @@ | |||
| 496 | (defonce jump-to (atom nil)) | 507 | (defonce jump-to (atom nil)) |
| 497 | 508 | ||
| 498 | 509 | ||
| 510 | + | ||
| 499 | (defn last-preview [buffer] | 511 | (defn last-preview [buffer] |
| 500 | (if-let [m (last (:messages buffer))] | 512 | (if-let [m (last (:messages buffer))] |
| 501 | (str (:from m) ": " (:text m)) | 513 | (str (:from m) ": " (:text m)) |