Send a picture by pasting it
IRC carries text, so sharing a picture has always meant uploading it somewhere and sending the link — work this client left to the reader and whatever other tool they had to hand. Ctrl+V in the draft now does it. The picture on the clipboard is written out as a PNG, uploaded to freeq's media endpoint while the line it goes with is being written, and turned into a link only on the way out. Until then it is a picture: shown under the box, with an ✕ to drop it, rather than a URL pasted into the middle of what someone was typing. The upload is authorised by the connection itself — the endpoint takes a DID with a live session — so it is for signed-in users, and desktop only, since the clipboard is read through the ABI and Android has no picture on one to read. A send while the upload is still going is held rather than sent without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8a491ba parent: bcc2876 modified
README.md +13 -0 | @@ -17,6 +17,7 @@ src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | ||
| 17 | 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory |
| 18 | 18 | src/frq/avatars.jolt profile pictures, by DID or handle |
| 19 | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 20 | +src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart | |
| 20 | 21 | src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings |
| 21 | 22 | src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name |
| 22 | 23 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| @@ -118,6 +119,12 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 118 | 119 | backlog comes back |
| 119 | 120 | * Inline previews for PNG links, fetched once and cached under |
| 120 | 121 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 122 | +* Ctrl+V in the draft attaches the picture on the clipboard: it is previewed | |
| 123 | + under the box and uploaded to freeq's media endpoint while you write the line | |
| 124 | + it goes with, and only on the way out does it become the link — which is the | |
| 125 | + whole of what sending an image over IRC means. The draft itself is never | |
| 126 | + written into. Text pastes as text, as it always did: the picture path is the | |
| 127 | + keystroke the field had no text to answer with | |
| 121 | 128 | * Join/part notices, DMs bucketed under the sender's nick |
| 122 | 129 | * Discover list, search over buffers, disconnect |
| 123 | 130 | * The rooms you have opened, remembered across runs and listed in the order |
| @@ -138,6 +145,12 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 138 | 145 | format, and a fetch needs TLS, so the phone shows links. The link is left in |
| 139 | 146 | place either way. |
| 140 | 147 | * **Nothing evicts the media cache.** |
| 148 | +* **Pasting a picture needs a sign-in and a desktop.** The upload is filed | |
| 149 | + under the DID of a live session, so a guest cannot make one; and it is read | |
| 150 | + off the clipboard through the ABI's `vidya_clipboard_image_png`, which | |
| 151 | + arboard backs on desktop and nothing backs on Android. It also shares | |
| 152 | + nothing to your PDS and posts nothing to Bluesky — those fields are opt-in | |
| 153 | + and this client does not send them. | |
| 141 | 154 | * **No scrollback trimming, reactions, threads, or calls.** |
| 142 | 155 | * A sent line waits up to 200ms for the reader thread to flush it. |
| 143 | 156 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
| @@ -17,6 +17,7 @@ src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | |||
| 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory | 17 | src/frq/store.jolt the saved sign-in, mode 600 in the config directory |
| 18 | src/frq/avatars.jolt profile pictures, by DID or handle | 18 | src/frq/avatars.jolt profile pictures, by DID or handle |
| 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk | 19 | src/frq/media.jolt image links: spot them, fetch them once, cache on disk |
| 20 | +src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart | ||
| 20 | src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings | 21 | src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings |
| 21 | src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name | 22 | src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name |
| 22 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 23 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| @@ -118,6 +119,12 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 118 | backlog comes back | 119 | backlog comes back |
| 119 | * Inline previews for PNG links, fetched once and cached under | 120 | * Inline previews for PNG links, fetched once and cached under |
| 120 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size | 121 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| 122 | +* Ctrl+V in the draft attaches the picture on the clipboard: it is previewed | ||
| 123 | + under the box and uploaded to freeq's media endpoint while you write the line | ||
| 124 | + it goes with, and only on the way out does it become the link — which is the | ||
| 125 | + whole of what sending an image over IRC means. The draft itself is never | ||
| 126 | + written into. Text pastes as text, as it always did: the picture path is the | ||
| 127 | + keystroke the field had no text to answer with | ||
| 121 | * Join/part notices, DMs bucketed under the sender's nick | 128 | * Join/part notices, DMs bucketed under the sender's nick |
| 122 | * Discover list, search over buffers, disconnect | 129 | * Discover list, search over buffers, disconnect |
| 123 | * The rooms you have opened, remembered across runs and listed in the order | 130 | * The rooms you have opened, remembered across runs and listed in the order |
| @@ -138,6 +145,12 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 138 | format, and a fetch needs TLS, so the phone shows links. The link is left in | 145 | format, and a fetch needs TLS, so the phone shows links. The link is left in |
| 139 | place either way. | 146 | place either way. |
| 140 | * **Nothing evicts the media cache.** | 147 | * **Nothing evicts the media cache.** |
| 148 | +* **Pasting a picture needs a sign-in and a desktop.** The upload is filed | ||
| 149 | + under the DID of a live session, so a guest cannot make one; and it is read | ||
| 150 | + off the clipboard through the ABI's `vidya_clipboard_image_png`, which | ||
| 151 | + arboard backs on desktop and nothing backs on Android. It also shares | ||
| 152 | + nothing to your PDS and posts nothing to Bluesky — those fields are opt-in | ||
| 153 | + and this client does not send them. | ||
| 141 | * **No scrollback trimming, reactions, threads, or calls.** | 154 | * **No scrollback trimming, reactions, threads, or calls.** |
| 142 | * A sent line waits up to 200ms for the reader thread to flush it. | 155 | * A sent line waits up to 200ms for the reader thread to flush it. |
| 143 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. | 156 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
modified
src/frq/app.jolt +48 -19 | @@ -486,22 +486,22 @@ | ||
| 486 | 486 | messages)) |
| 487 | 487 | |
| 488 | 488 | (defn lightbox-screen |
| 489 | - "One picture, as big as the window allows. | |
| 489 | + "One picture, as big as the window will paint it. | |
| 490 | 490 | |
| 491 | - A screen rather than an overlay: the tree backend paints in one layer and | |
| 492 | - has no z-order to put something on top of everything else with." | |
| 491 | + A screen rather than an overlay: the tree backend paints in one layer and has | |
| 492 | + no z-order to put something on top of everything else with. So the picture | |
| 493 | + takes the window — `:fit` gives it every point below the one row that is not | |
| 494 | + it, centred and in proportion, scaled up as readily as down. | |
| 495 | + | |
| 496 | + That row is the way back. Clicking the picture closes it too — the same | |
| 497 | + gesture that opened it — but a way out you have to guess at is not one, and | |
| 498 | + the row costs the picture a line." | |
| 493 | 499 | [] |
| 494 | - (let [{:keys [path url]} @s/lightbox] | |
| 495 | - [:vbox {:spacing 8 :margin 12} | |
| 500 | + (let [{:keys [path]} @s/lightbox] | |
| 501 | + [:vbox {:spacing 4 :margin 4} | |
| 496 | 502 | [:hbox {:spacing 8} |
| 497 | - [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}] | |
| 498 | - [:dim-label {:label url}]] | |
| 499 | - ;; A picture fits the window's width, and a tall one is then taller than | |
| 500 | - ;; the window — so it scrolls. No `:reserve`: nothing follows it here, so | |
| 501 | - ;; the rest of the window is the picture's to use. | |
| 502 | - [:scroll {:orientation :vertical} | |
| 503 | - ;; Clicking the picture closes it too — the same gesture that opened it. | |
| 504 | - [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]])) | |
| 503 | + [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] | |
| 504 | + [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) | |
| 505 | 505 | |
| 506 | 506 | (defn chat-screen [] |
| 507 | 507 | (let [name @s/current |
| @@ -521,12 +521,21 @@ | ||
| 521 | 521 | ;; is showing, and neither does that row — a reserve that changed would |
| 522 | 522 | ;; move the compose bar under the reader every time the button came and |
| 523 | 523 | ;; went. |
| 524 | - [:scroll {:orientation :vertical | |
| 525 | - ;; Plus the reply banner's row when there is one. That row does | |
| 526 | - ;; move the compose bar, and should: it appears because the | |
| 527 | - ;; reader asked to answer something, unlike the jump button, | |
| 528 | - ;; which appears on its own and must not shift what is under it. | |
| 529 | - :reserve (if @s/replying-to 150 116) | |
| 524 | + ;; Named, so the list is the same list when the reader comes back to it. | |
| 525 | + ;; The lightbox is a screen rather than a layer, so looking at a picture | |
| 526 | + ;; unmounts the backlog behind it; without a name of its own the position | |
| 527 | + ;; would come back as a fresh one, and ← Back would answer a click on a | |
| 528 | + ;; message halfway up a week of history with the top of the buffer. | |
| 529 | + [:scroll {:scroll-key "chat-messages" | |
| 530 | + :orientation :vertical | |
| 531 | + ;; Plus the reply banner's row when there is one, and the | |
| 532 | + ;; pasted picture when there is one. Those rows do move the | |
| 533 | + ;; compose bar, and should: each appears because the reader just | |
| 534 | + ;; asked for something — unlike the jump button, which appears | |
| 535 | + ;; on its own and must not shift what is under it. | |
| 536 | + :reserve (+ 116 | |
| 537 | + (if @s/replying-to 34 0) | |
| 538 | + (if @s/attachment 76 0)) | |
| 530 | 539 | :stick-to-bottom true |
| 531 | 540 | :scroll-to-bottom @s/jump-tick |
| 532 | 541 | :on-change #(reset! s/at-present? (= "end" %))} |
| @@ -548,12 +557,32 @@ | ||
| 548 | 557 | [:hbox {:spacing 8} |
| 549 | 558 | [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}] |
| 550 | 559 | [:button {:label "✕" :on-click s/cancel-reply!}]])] |
| 560 | + ;; The pasted picture, above the line it will go out with. Shown rather | |
| 561 | + ;; than written into the draft: what is being sent is a picture, and a URL | |
| 562 | + ;; dropped into the entry would be an unreadable line of text sitting in | |
| 563 | + ;; the middle of whatever the reader was in the middle of typing. | |
| 564 | + [:vbox {:key :attachment} | |
| 565 | + (when-let [att @s/attachment] | |
| 566 | + [:hbox {:spacing 8} | |
| 567 | + ;; Small: it is a reminder of what is attached, not the picture | |
| 568 | + ;; itself, and the backlog above it is what the reader is here for. | |
| 569 | + [:image {:src (:path att) :max-height 64}] | |
| 570 | + [:dim-label {:label (if (= :uploading (:status att)) | |
| 571 | + "Uploading…" | |
| 572 | + "Picture attached")}] | |
| 573 | + [:button {:label "✕" :on-click s/clear-attachment!}]])] | |
| 551 | 574 | [:hbox {:spacing 8} |
| 552 | 575 | ;; narrow enough that Send keeps its place on a phone-width row |
| 576 | + ;; A picture is pasted where everything else is typed: Ctrl+V. The field | |
| 577 | + ;; answers a paste of text with the text, and one of a picture reaches | |
| 578 | + ;; `:on-paste-empty` — a keystroke the field had nothing to put in | |
| 579 | + ;; itself, which is exactly the one that means "the clipboard has | |
| 580 | + ;; something else on it". | |
| 553 | 581 | [:entry {:text @s/draft |
| 554 | 582 | :width-request 300 |
| 555 | 583 | :placeholder "Message" |
| 556 | 584 | :on-change #(reset! s/draft %) |
| 585 | + :on-paste-empty s/paste-image! | |
| 557 | 586 | :on-activate s/send-draft!}] |
| 558 | 587 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) |
| 559 | 588 | |
| @@ -486,22 +486,22 @@ | |||
| 486 | messages)) | 486 | messages)) |
| 487 | 487 | ||
| 488 | (defn lightbox-screen | 488 | (defn lightbox-screen |
| 489 | - "One picture, as big as the window allows. | 489 | + "One picture, as big as the window will paint it. |
| 490 | 490 | ||
| 491 | - A screen rather than an overlay: the tree backend paints in one layer and | 491 | + A screen rather than an overlay: the tree backend paints in one layer and has |
| 492 | - has no z-order to put something on top of everything else with." | 492 | + no z-order to put something on top of everything else with. So the picture |
| 493 | + takes the window — `:fit` gives it every point below the one row that is not | ||
| 494 | + it, centred and in proportion, scaled up as readily as down. | ||
| 495 | + | ||
| 496 | + That row is the way back. Clicking the picture closes it too — the same | ||
| 497 | + gesture that opened it — but a way out you have to guess at is not one, and | ||
| 498 | + the row costs the picture a line." | ||
| 493 | [] | 499 | [] |
| 494 | - (let [{:keys [path url]} @s/lightbox] | 500 | + (let [{:keys [path]} @s/lightbox] |
| 495 | - [:vbox {:spacing 8 :margin 12} | 501 | + [:vbox {:spacing 4 :margin 4} |
| 496 | [:hbox {:spacing 8} | 502 | [:hbox {:spacing 8} |
| 497 | - [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}] | 503 | + [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] |
| 498 | - [:dim-label {:label url}]] | 504 | + [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]])) |
| 499 | - ;; A picture fits the window's width, and a tall one is then taller than | ||
| 500 | - ;; the window — so it scrolls. No `:reserve`: nothing follows it here, so | ||
| 501 | - ;; the rest of the window is the picture's to use. | ||
| 502 | - [:scroll {:orientation :vertical} | ||
| 503 | - ;; Clicking the picture closes it too — the same gesture that opened it. | ||
| 504 | - [:image {:src path :max-height 20000 :on-click #(reset! s/lightbox nil)}]]])) | ||
| 505 | 505 | ||
| 506 | (defn chat-screen [] | 506 | (defn chat-screen [] |
| 507 | (let [name @s/current | 507 | (let [name @s/current |
| @@ -521,12 +521,21 @@ | |||
| 521 | ;; is showing, and neither does that row — a reserve that changed would | 521 | ;; is showing, and neither does that row — a reserve that changed would |
| 522 | ;; move the compose bar under the reader every time the button came and | 522 | ;; move the compose bar under the reader every time the button came and |
| 523 | ;; went. | 523 | ;; went. |
| 524 | - [:scroll {:orientation :vertical | 524 | + ;; Named, so the list is the same list when the reader comes back to it. |
| 525 | - ;; Plus the reply banner's row when there is one. That row does | 525 | + ;; The lightbox is a screen rather than a layer, so looking at a picture |
| 526 | - ;; move the compose bar, and should: it appears because the | 526 | + ;; unmounts the backlog behind it; without a name of its own the position |
| 527 | - ;; reader asked to answer something, unlike the jump button, | 527 | + ;; would come back as a fresh one, and ← Back would answer a click on a |
| 528 | - ;; which appears on its own and must not shift what is under it. | 528 | + ;; message halfway up a week of history with the top of the buffer. |
| 529 | - :reserve (if @s/replying-to 150 116) | 529 | + [:scroll {:scroll-key "chat-messages" |
| 530 | + :orientation :vertical | ||
| 531 | + ;; Plus the reply banner's row when there is one, and the | ||
| 532 | + ;; pasted picture when there is one. Those rows do move the | ||
| 533 | + ;; compose bar, and should: each appears because the reader just | ||
| 534 | + ;; asked for something — unlike the jump button, which appears | ||
| 535 | + ;; on its own and must not shift what is under it. | ||
| 536 | + :reserve (+ 116 | ||
| 537 | + (if @s/replying-to 34 0) | ||
| 538 | + (if @s/attachment 76 0)) | ||
| 530 | :stick-to-bottom true | 539 | :stick-to-bottom true |
| 531 | :scroll-to-bottom @s/jump-tick | 540 | :scroll-to-bottom @s/jump-tick |
| 532 | :on-change #(reset! s/at-present? (= "end" %))} | 541 | :on-change #(reset! s/at-present? (= "end" %))} |
| @@ -548,12 +557,32 @@ | |||
| 548 | [:hbox {:spacing 8} | 557 | [:hbox {:spacing 8} |
| 549 | [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}] | 558 | [:dim-label {:label (str "↩ " (:from target) ": " (summarise target 36))}] |
| 550 | [:button {:label "✕" :on-click s/cancel-reply!}]])] | 559 | [:button {:label "✕" :on-click s/cancel-reply!}]])] |
| 560 | + ;; The pasted picture, above the line it will go out with. Shown rather | ||
| 561 | + ;; than written into the draft: what is being sent is a picture, and a URL | ||
| 562 | + ;; dropped into the entry would be an unreadable line of text sitting in | ||
| 563 | + ;; the middle of whatever the reader was in the middle of typing. | ||
| 564 | + [:vbox {:key :attachment} | ||
| 565 | + (when-let [att @s/attachment] | ||
| 566 | + [:hbox {:spacing 8} | ||
| 567 | + ;; Small: it is a reminder of what is attached, not the picture | ||
| 568 | + ;; itself, and the backlog above it is what the reader is here for. | ||
| 569 | + [:image {:src (:path att) :max-height 64}] | ||
| 570 | + [:dim-label {:label (if (= :uploading (:status att)) | ||
| 571 | + "Uploading…" | ||
| 572 | + "Picture attached")}] | ||
| 573 | + [:button {:label "✕" :on-click s/clear-attachment!}]])] | ||
| 551 | [:hbox {:spacing 8} | 574 | [:hbox {:spacing 8} |
| 552 | ;; narrow enough that Send keeps its place on a phone-width row | 575 | ;; narrow enough that Send keeps its place on a phone-width row |
| 576 | + ;; A picture is pasted where everything else is typed: Ctrl+V. The field | ||
| 577 | + ;; answers a paste of text with the text, and one of a picture reaches | ||
| 578 | + ;; `:on-paste-empty` — a keystroke the field had nothing to put in | ||
| 579 | + ;; itself, which is exactly the one that means "the clipboard has | ||
| 580 | + ;; something else on it". | ||
| 553 | [:entry {:text @s/draft | 581 | [:entry {:text @s/draft |
| 554 | :width-request 300 | 582 | :width-request 300 |
| 555 | :placeholder "Message" | 583 | :placeholder "Message" |
| 556 | :on-change #(reset! s/draft %) | 584 | :on-change #(reset! s/draft %) |
| 585 | + :on-paste-empty s/paste-image! | ||
| 557 | :on-activate s/send-draft!}] | 586 | :on-activate s/send-draft!}] |
| 558 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) | 587 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) |
| 559 | 588 | ||
modified
src/frq/state.jolt +117 -12 | @@ -6,6 +6,7 @@ | ||
| 6 | 6 | is the only place a wire message turns into UI state." |
| 7 | 7 | (:require [clojure.string :as str] |
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | + [jolt.host :as host] | |
| 9 | 10 | [frq.atproto :as atproto] |
| 10 | 11 | [frq.clock :as clock] |
| 11 | 12 | [frq.emoji :as emoji] |
| @@ -13,7 +14,9 @@ | ||
| 13 | 14 | [frq.avatars :as avatars] |
| 14 | 15 | [frq.media :as media] |
| 15 | 16 | [frq.oauth :as oauth] |
| 16 | - [frq.store :as store])) | |
| 17 | + [frq.store :as store] | |
| 18 | + [frq.upload :as upload] | |
| 19 | + [glimmer-vidya.core :as vidya])) | |
| 17 | 20 | |
| 18 | 21 | (def default-host "irc.freeq.at") |
| 19 | 22 | (def default-port "6697") |
| @@ -512,19 +515,121 @@ | ||
| 512 | 515 | (when (seq ch) |
| 513 | 516 | (open-channel! ch)))) |
| 514 | 517 | |
| 515 | -(defn send-draft! [] | |
| 518 | +;; ------------------------------------------------------------------ pasting | |
| 519 | + | |
| 520 | +;; The picture waiting to go out with the next line, or nil: | |
| 521 | +;; | |
| 522 | +;; {:path the copy on disk, which is what the preview paints | |
| 523 | +;; :url where freeq serves it, once the upload has landed | |
| 524 | +;; :status :uploading | :ready} | |
| 525 | +;; | |
| 526 | +;; Held apart from the draft rather than written into it. A link pasted into | |
| 527 | +;; the entry is a line of unreadable text in the middle of whatever the reader | |
| 528 | +;; was typing, and it puts their cursor somewhere they did not put it. The | |
| 529 | +;; picture is a picture until it is sent; the draft stays theirs. | |
| 530 | +;; | |
| 531 | +;; One at a time — a second paste replaces the first, which is what a reader | |
| 532 | +;; who pasted the wrong thing means by pasting the right one. | |
| 533 | +(defonce attachment (atom nil)) | |
| 534 | + | |
| 535 | +;; Each paste gets a file of its own rather than overwriting the last: the | |
| 536 | +;; preview is painted from the file, and an upload may still be reading it. | |
| 537 | +(defonce ^:private paste-count (atom 0)) | |
| 538 | + | |
| 539 | +(defn- paste-path [] | |
| 540 | + (let [n (swap! paste-count inc)] | |
| 541 | + (str (media/cache-dir) "/outgoing/paste-" n ".png"))) | |
| 542 | + | |
| 543 | +(defn- discard-file! | |
| 544 | + "Drop a paste's copy on disk. Nothing else keeps it: the picture that matters | |
| 545 | + after sending is the one the server serves back, which the media cache | |
| 546 | + fetches like any other." | |
| 547 | + [path] | |
| 548 | + (when path (try (host/delete-file! path) (catch Exception _ nil)))) | |
| 549 | + | |
| 550 | +(defn clear-attachment! | |
| 551 | + "Drop the pasted picture without sending it." | |
| 552 | + [] | |
| 553 | + (when-let [a @attachment] | |
| 554 | + (reset! attachment nil) | |
| 555 | + (discard-file! (:path a)))) | |
| 556 | + | |
| 557 | +(defn paste-image! | |
| 558 | + "Take the picture on the clipboard and hold it against the next line. | |
| 559 | + | |
| 560 | + IRC has nowhere to put an image, so a link is the whole of what sending one | |
| 561 | + means — but that is a fact about the wire, not something the reader should | |
| 562 | + have to type around. The picture is attached: shown under the draft while | |
| 563 | + they write whatever they are sending it with, and turned into a link only on | |
| 564 | + the way out. | |
| 565 | + | |
| 566 | + The upload runs off the UI thread and starts at once rather than at send, so | |
| 567 | + by the time a line is written the picture is usually already up. A failure | |
| 568 | + lands in `error` like any other, and takes the attachment with it — there is | |
| 569 | + nothing to send and nothing to show." | |
| 570 | + [] | |
| 571 | + (let [path (paste-path)] | |
| 572 | + (host/mkdirs! (str (media/cache-dir) "/outgoing")) | |
| 573 | + (if-not (vidya/clipboard-image-png! path) | |
| 574 | + (reset! error "No picture on the clipboard.") | |
| 575 | + (let [did (:did @session) | |
| 576 | + host-name @form-host | |
| 577 | + channel @current] | |
| 578 | + (reset! error nil) | |
| 579 | + (clear-attachment!) | |
| 580 | + (reset! attachment {:path path :status :uploading}) | |
| 581 | + (future | |
| 582 | + (try | |
| 583 | + (let [url (upload/upload! host-name did channel path "paste.png")] | |
| 584 | + ;; Only if this is still the paste on screen: a reader who | |
| 585 | + ;; pasted again, or cleared it, has said what they want, and an | |
| 586 | + ;; upload landing afterwards does not get to undo that. | |
| 587 | + (swap! attachment #(if (= (:path %) path) | |
| 588 | + (assoc % :url url :status :ready) | |
| 589 | + %)) | |
| 590 | + (when-not (= (:path @attachment) path) (discard-file! path))) | |
| 591 | + (catch Exception e | |
| 592 | + (swap! attachment #(if (= (:path %) path) nil %)) | |
| 593 | + (discard-file! path) | |
| 594 | + (reset! error (or (ex-message e) (str e)))))))))) | |
| 595 | + | |
| 596 | +(defn send-draft! | |
| 597 | + "Send the draft, with whatever picture is attached to it. | |
| 598 | + | |
| 599 | + The picture becomes its link, at the end of the line: what goes on the wire | |
| 600 | + is the text the reader wrote and a URL after it, which is what every other | |
| 601 | + client in the channel knows how to show. A line that is only a picture is | |
| 602 | + only the link. | |
| 603 | + | |
| 604 | + A picture still on its way up holds the send rather than losing it: the line | |
| 605 | + is left in the box, said so, and the reader presses send again a moment | |
| 606 | + later. Sending the text without its picture would be the one outcome nobody | |
| 607 | + asked for." | |
| 608 | + [] | |
| 516 | 609 | (let [text (str/trim @draft) |
| 517 | 610 | target @current |
| 518 | - reply-to @replying-to] | |
| 519 | - (when (and (seq text) target) | |
| 520 | - ;; Saying something is a way of asking to see it. | |
| 521 | - (jump-to-present!) | |
| 522 | - (when-let [c @conn] (irc/privmsg! c target text (:id reply-to))) | |
| 523 | - ;; The local echo carries the same link, so the chip is there before the | |
| 524 | - ;; server's copy of the line comes back — if it ever does. | |
| 525 | - (push-message! target @form-nick text {:reply-to (:id reply-to)}) | |
| 526 | - (reset! replying-to nil) | |
| 527 | - (reset! draft "")))) | |
| 611 | + reply-to @replying-to | |
| 612 | + {:keys [url status path] :as att} @attachment] | |
| 613 | + (cond | |
| 614 | + (not target) nil | |
| 615 | + (= :uploading status) (reset! error "The picture is still uploading.") | |
| 616 | + (and (str/blank? text) (not url)) nil | |
| 617 | + :else | |
| 618 | + (let [line (str/trim (str text (when url (str " " url))))] | |
| 619 | + ;; Saying something is a way of asking to see it. | |
| 620 | + (jump-to-present!) | |
| 621 | + (when-let [c @conn] (irc/privmsg! c target line (:id reply-to))) | |
| 622 | + ;; The local echo carries the same link, so the chip is there before | |
| 623 | + ;; the server's copy of the line comes back — if it ever does. | |
| 624 | + (push-message! target @form-nick line {:reply-to (:id reply-to)}) | |
| 625 | + (reset! replying-to nil) | |
| 626 | + (reset! draft "") | |
| 627 | + (when att | |
| 628 | + (reset! attachment nil) | |
| 629 | + ;; The picture on screen from here on is the one fetched back from | |
| 630 | + ;; the link, like everyone else's. | |
| 631 | + (discard-file! path)))))) | |
| 632 | + | |
| 528 | 633 | |
| 529 | 634 | (defn open-picker! |
| 530 | 635 | "Choose an emoji for this message. Opening it fresh — no leftover search from |
| @@ -6,6 +6,7 @@ | |||
| 6 | is the only place a wire message turns into UI state." | 6 | is the only place a wire message turns into UI state." |
| 7 | (:require [clojure.string :as str] | 7 | (:require [clojure.string :as str] |
| 8 | [glimmer.ratom :as r :refer [atom]] | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | + [jolt.host :as host] | ||
| 9 | [frq.atproto :as atproto] | 10 | [frq.atproto :as atproto] |
| 10 | [frq.clock :as clock] | 11 | [frq.clock :as clock] |
| 11 | [frq.emoji :as emoji] | 12 | [frq.emoji :as emoji] |
| @@ -13,7 +14,9 @@ | |||
| 13 | [frq.avatars :as avatars] | 14 | [frq.avatars :as avatars] |
| 14 | [frq.media :as media] | 15 | [frq.media :as media] |
| 15 | [frq.oauth :as oauth] | 16 | [frq.oauth :as oauth] |
| 16 | - [frq.store :as store])) | 17 | + [frq.store :as store] |
| 18 | + [frq.upload :as upload] | ||
| 19 | + [glimmer-vidya.core :as vidya])) | ||
| 17 | 20 | ||
| 18 | (def default-host "irc.freeq.at") | 21 | (def default-host "irc.freeq.at") |
| 19 | (def default-port "6697") | 22 | (def default-port "6697") |
| @@ -512,19 +515,121 @@ | |||
| 512 | (when (seq ch) | 515 | (when (seq ch) |
| 513 | (open-channel! ch)))) | 516 | (open-channel! ch)))) |
| 514 | 517 | ||
| 515 | -(defn send-draft! [] | 518 | +;; ------------------------------------------------------------------ pasting |
| 519 | + | ||
| 520 | +;; The picture waiting to go out with the next line, or nil: | ||
| 521 | +;; | ||
| 522 | +;; {:path the copy on disk, which is what the preview paints | ||
| 523 | +;; :url where freeq serves it, once the upload has landed | ||
| 524 | +;; :status :uploading | :ready} | ||
| 525 | +;; | ||
| 526 | +;; Held apart from the draft rather than written into it. A link pasted into | ||
| 527 | +;; the entry is a line of unreadable text in the middle of whatever the reader | ||
| 528 | +;; was typing, and it puts their cursor somewhere they did not put it. The | ||
| 529 | +;; picture is a picture until it is sent; the draft stays theirs. | ||
| 530 | +;; | ||
| 531 | +;; One at a time — a second paste replaces the first, which is what a reader | ||
| 532 | +;; who pasted the wrong thing means by pasting the right one. | ||
| 533 | +(defonce attachment (atom nil)) | ||
| 534 | + | ||
| 535 | +;; Each paste gets a file of its own rather than overwriting the last: the | ||
| 536 | +;; preview is painted from the file, and an upload may still be reading it. | ||
| 537 | +(defonce ^:private paste-count (atom 0)) | ||
| 538 | + | ||
| 539 | +(defn- paste-path [] | ||
| 540 | + (let [n (swap! paste-count inc)] | ||
| 541 | + (str (media/cache-dir) "/outgoing/paste-" n ".png"))) | ||
| 542 | + | ||
| 543 | +(defn- discard-file! | ||
| 544 | + "Drop a paste's copy on disk. Nothing else keeps it: the picture that matters | ||
| 545 | + after sending is the one the server serves back, which the media cache | ||
| 546 | + fetches like any other." | ||
| 547 | + [path] | ||
| 548 | + (when path (try (host/delete-file! path) (catch Exception _ nil)))) | ||
| 549 | + | ||
| 550 | +(defn clear-attachment! | ||
| 551 | + "Drop the pasted picture without sending it." | ||
| 552 | + [] | ||
| 553 | + (when-let [a @attachment] | ||
| 554 | + (reset! attachment nil) | ||
| 555 | + (discard-file! (:path a)))) | ||
| 556 | + | ||
| 557 | +(defn paste-image! | ||
| 558 | + "Take the picture on the clipboard and hold it against the next line. | ||
| 559 | + | ||
| 560 | + IRC has nowhere to put an image, so a link is the whole of what sending one | ||
| 561 | + means — but that is a fact about the wire, not something the reader should | ||
| 562 | + have to type around. The picture is attached: shown under the draft while | ||
| 563 | + they write whatever they are sending it with, and turned into a link only on | ||
| 564 | + the way out. | ||
| 565 | + | ||
| 566 | + The upload runs off the UI thread and starts at once rather than at send, so | ||
| 567 | + by the time a line is written the picture is usually already up. A failure | ||
| 568 | + lands in `error` like any other, and takes the attachment with it — there is | ||
| 569 | + nothing to send and nothing to show." | ||
| 570 | + [] | ||
| 571 | + (let [path (paste-path)] | ||
| 572 | + (host/mkdirs! (str (media/cache-dir) "/outgoing")) | ||
| 573 | + (if-not (vidya/clipboard-image-png! path) | ||
| 574 | + (reset! error "No picture on the clipboard.") | ||
| 575 | + (let [did (:did @session) | ||
| 576 | + host-name @form-host | ||
| 577 | + channel @current] | ||
| 578 | + (reset! error nil) | ||
| 579 | + (clear-attachment!) | ||
| 580 | + (reset! attachment {:path path :status :uploading}) | ||
| 581 | + (future | ||
| 582 | + (try | ||
| 583 | + (let [url (upload/upload! host-name did channel path "paste.png")] | ||
| 584 | + ;; Only if this is still the paste on screen: a reader who | ||
| 585 | + ;; pasted again, or cleared it, has said what they want, and an | ||
| 586 | + ;; upload landing afterwards does not get to undo that. | ||
| 587 | + (swap! attachment #(if (= (:path %) path) | ||
| 588 | + (assoc % :url url :status :ready) | ||
| 589 | + %)) | ||
| 590 | + (when-not (= (:path @attachment) path) (discard-file! path))) | ||
| 591 | + (catch Exception e | ||
| 592 | + (swap! attachment #(if (= (:path %) path) nil %)) | ||
| 593 | + (discard-file! path) | ||
| 594 | + (reset! error (or (ex-message e) (str e)))))))))) | ||
| 595 | + | ||
| 596 | +(defn send-draft! | ||
| 597 | + "Send the draft, with whatever picture is attached to it. | ||
| 598 | + | ||
| 599 | + The picture becomes its link, at the end of the line: what goes on the wire | ||
| 600 | + is the text the reader wrote and a URL after it, which is what every other | ||
| 601 | + client in the channel knows how to show. A line that is only a picture is | ||
| 602 | + only the link. | ||
| 603 | + | ||
| 604 | + A picture still on its way up holds the send rather than losing it: the line | ||
| 605 | + is left in the box, said so, and the reader presses send again a moment | ||
| 606 | + later. Sending the text without its picture would be the one outcome nobody | ||
| 607 | + asked for." | ||
| 608 | + [] | ||
| 516 | (let [text (str/trim @draft) | 609 | (let [text (str/trim @draft) |
| 517 | target @current | 610 | target @current |
| 518 | - reply-to @replying-to] | 611 | + reply-to @replying-to |
| 519 | - (when (and (seq text) target) | 612 | + {:keys [url status path] :as att} @attachment] |
| 520 | - ;; Saying something is a way of asking to see it. | 613 | + (cond |
| 521 | - (jump-to-present!) | 614 | + (not target) nil |
| 522 | - (when-let [c @conn] (irc/privmsg! c target text (:id reply-to))) | 615 | + (= :uploading status) (reset! error "The picture is still uploading.") |
| 523 | - ;; The local echo carries the same link, so the chip is there before the | 616 | + (and (str/blank? text) (not url)) nil |
| 524 | - ;; server's copy of the line comes back — if it ever does. | 617 | + :else |
| 525 | - (push-message! target @form-nick text {:reply-to (:id reply-to)}) | 618 | + (let [line (str/trim (str text (when url (str " " url))))] |
| 526 | - (reset! replying-to nil) | 619 | + ;; Saying something is a way of asking to see it. |
| 527 | - (reset! draft "")))) | 620 | + (jump-to-present!) |
| 621 | + (when-let [c @conn] (irc/privmsg! c target line (:id reply-to))) | ||
| 622 | + ;; The local echo carries the same link, so the chip is there before | ||
| 623 | + ;; the server's copy of the line comes back — if it ever does. | ||
| 624 | + (push-message! target @form-nick line {:reply-to (:id reply-to)}) | ||
| 625 | + (reset! replying-to nil) | ||
| 626 | + (reset! draft "") | ||
| 627 | + (when att | ||
| 628 | + (reset! attachment nil) | ||
| 629 | + ;; The picture on screen from here on is the one fetched back from | ||
| 630 | + ;; the link, like everyone else's. | ||
| 631 | + (discard-file! path)))))) | ||
| 632 | + | ||
| 528 | 633 | ||
| 529 | (defn open-picker! | 634 | (defn open-picker! |
| 530 | "Choose an emoji for this message. Opening it fresh — no leftover search from | 635 | "Choose an emoji for this message. Opening it fresh — no leftover search from |
added
src/frq/upload.jolt +119 -0 | new file mode 100644 | ||
| @@ -0,0 +1,119 @@ | ||
| 1 | +(ns frq.upload | |
| 2 | + "Sending a picture: freeq's media endpoint, over the same hand-rolled TLS the | |
| 3 | + rest of the client speaks. | |
| 4 | + | |
| 5 | + IRC carries text, so a picture is shared the way every other client shares | |
| 6 | + one — it is uploaded, and the link goes in the line. freeq's | |
| 7 | + `POST /api/v1/upload` takes a multipart form and answers with a URL under | |
| 8 | + `/api/v1/media/…`, signed and served back to anyone the link reaches. | |
| 9 | + | |
| 10 | + The upload is authorised by the connection itself: the endpoint accepts a DID | |
| 11 | + that has a live session on the server, which a signed-in client already has. | |
| 12 | + A guest has no DID and cannot upload — the same line TLS draws everywhere | |
| 13 | + else in this client. | |
| 14 | + | |
| 15 | + Nothing here is streamed: an image is at most a few megabytes and the body is | |
| 16 | + built in memory, which is what keeps the request one write." | |
| 17 | + (:require [clojure.string :as str] | |
| 18 | + [jolt.mvn-http :as tls])) | |
| 19 | + | |
| 20 | +;; The endpoint's own cap. Refusing here rather than at the server saves a | |
| 21 | +;; multi-megabyte upload that was always going to be rejected. | |
| 22 | +(def max-bytes (* 10 1024 1024)) | |
| 23 | + | |
| 24 | +(defn- file-bytes [path] | |
| 25 | + (let [in (java.io.FileInputStream. path)] | |
| 26 | + (try (.readAllBytes in) | |
| 27 | + (finally (try (.close in) (catch Exception _ nil)))))) | |
| 28 | + | |
| 29 | +(defn- bytes-of [s] (.getBytes (str s))) | |
| 30 | + | |
| 31 | +(defn- boundary | |
| 32 | + "A delimiter of the form the RFC allows, and one no part of this body has in | |
| 33 | + it: every byte of it is a hyphen, a letter or a digit, and the parts are a | |
| 34 | + PNG and a DID." | |
| 35 | + [size] | |
| 36 | + (str "----frq" (Math/abs (hash (str size "-frq"))))) | |
| 37 | + | |
| 38 | +(defn- multipart | |
| 39 | + "The request body for `fields` (strings) plus the file part, as bytes." | |
| 40 | + [bound fields filename content-type file] | |
| 41 | + (let [out (java.io.ByteArrayOutputStream.) | |
| 42 | + dash (str "--" bound)] | |
| 43 | + (doseq [[k v] fields] | |
| 44 | + (.write out (bytes-of (str dash "\r\n" | |
| 45 | + "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n" | |
| 46 | + v "\r\n")))) | |
| 47 | + (.write out (bytes-of (str dash "\r\n" | |
| 48 | + "Content-Disposition: form-data; name=\"file\";" | |
| 49 | + " filename=\"" filename "\"\r\n" | |
| 50 | + "Content-Type: " content-type "\r\n\r\n"))) | |
| 51 | + (.write out file) | |
| 52 | + (.write out (bytes-of (str "\r\n" dash "--\r\n"))) | |
| 53 | + (.toByteArray out))) | |
| 54 | + | |
| 55 | +(defn- read-all! | |
| 56 | + "Drain a TLS connection into a string. The response is a short JSON body, so | |
| 57 | + it is read whole rather than by Content-Length." | |
| 58 | + [t] | |
| 59 | + (loop [acc ""] | |
| 60 | + (let [b (try (tls/tls-read t) (catch Exception _ nil))] | |
| 61 | + (if (or (nil? b) (zero? (count b))) | |
| 62 | + acc | |
| 63 | + (recur (str acc (String. b))))))) | |
| 64 | + | |
| 65 | +(defn- status-of [resp] | |
| 66 | + (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second)) | |
| 67 | + | |
| 68 | +(defn- error-message | |
| 69 | + "What to show for a response that was not a 2xx. The endpoint answers JSON | |
| 70 | + with a `message` or an `error` for the cases a user can do something about — | |
| 71 | + not signed in, file too large — and plain text for the rest." | |
| 72 | + [status body] | |
| 73 | + (let [field (fn [k] (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body "")))) | |
| 74 | + detail (or (field "message") (field "error") (str/trim (str body)))] | |
| 75 | + (str "Upload failed (" status ")" | |
| 76 | + (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail)))))))) | |
| 77 | + | |
| 78 | +(defn upload! | |
| 79 | + "Upload `path` as `did`'s, returning the URL freeq serves it back at. | |
| 80 | + | |
| 81 | + `channel` is passed along when there is one: the server files an upload under | |
| 82 | + the conversation it was meant for. Nothing is shared to the PDS or posted to | |
| 83 | + Bluesky — those are opt-in fields this client does not send. | |
| 84 | + | |
| 85 | + Throws with a message meant to be shown when the upload is refused." | |
| 86 | + [host did channel path filename] | |
| 87 | + (when (str/blank? (str did)) | |
| 88 | + (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {}))) | |
| 89 | + (let [file (file-bytes path)] | |
| 90 | + (when (> (alength file) max-bytes) | |
| 91 | + (throw (ex-info "That picture is over the 10MB the server takes." {:bytes (alength file)}))) | |
| 92 | + (tls/ensure-native!) | |
| 93 | + (let [bound (boundary (alength file)) | |
| 94 | + body (multipart bound | |
| 95 | + (cond-> [["did" did]] | |
| 96 | + (seq (str channel)) (conj ["channel" channel])) | |
| 97 | + filename "image/png" file) | |
| 98 | + head (bytes-of (str "POST /api/v1/upload HTTP/1.1\r\n" | |
| 99 | + "Host: " host "\r\n" | |
| 100 | + "User-Agent: frq\r\n" | |
| 101 | + "Accept: application/json\r\n" | |
| 102 | + "Content-Type: multipart/form-data; boundary=" bound "\r\n" | |
| 103 | + "Content-Length: " (alength body) "\r\n" | |
| 104 | + "Connection: close\r\n\r\n")) | |
| 105 | + t (tls/tls-connect host 443)] | |
| 106 | + (try | |
| 107 | + ;; Head and body in one write: the server reads a request, not two. | |
| 108 | + (let [req (java.io.ByteArrayOutputStream.)] | |
| 109 | + (.write req head) | |
| 110 | + (.write req body) | |
| 111 | + (tls/tls-write t (.toByteArray req))) | |
| 112 | + (let [resp (read-all! t) | |
| 113 | + status (status-of resp) | |
| 114 | + [_ payload] (str/split resp #"\r\n\r\n" 2)] | |
| 115 | + (if (and status (str/starts-with? status "2")) | |
| 116 | + (or (second (re-find #"\"url\"\s*:\s*\"([^\"]*)\"" (or payload ""))) | |
| 117 | + (throw (ex-info "The server took the picture but named no URL for it." {}))) | |
| 118 | + (throw (ex-info (error-message (or status "no response") payload) {})))) | |
| 119 | + (finally (try (tls/tls-close t) (catch Exception _ nil))))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | +(ns frq.upload | ||
| 2 | + "Sending a picture: freeq's media endpoint, over the same hand-rolled TLS the | ||
| 3 | + rest of the client speaks. | ||
| 4 | + | ||
| 5 | + IRC carries text, so a picture is shared the way every other client shares | ||
| 6 | + one — it is uploaded, and the link goes in the line. freeq's | ||
| 7 | + `POST /api/v1/upload` takes a multipart form and answers with a URL under | ||
| 8 | + `/api/v1/media/…`, signed and served back to anyone the link reaches. | ||
| 9 | + | ||
| 10 | + The upload is authorised by the connection itself: the endpoint accepts a DID | ||
| 11 | + that has a live session on the server, which a signed-in client already has. | ||
| 12 | + A guest has no DID and cannot upload — the same line TLS draws everywhere | ||
| 13 | + else in this client. | ||
| 14 | + | ||
| 15 | + Nothing here is streamed: an image is at most a few megabytes and the body is | ||
| 16 | + built in memory, which is what keeps the request one write." | ||
| 17 | + (:require [clojure.string :as str] | ||
| 18 | + [jolt.mvn-http :as tls])) | ||
| 19 | + | ||
| 20 | +;; The endpoint's own cap. Refusing here rather than at the server saves a | ||
| 21 | +;; multi-megabyte upload that was always going to be rejected. | ||
| 22 | +(def max-bytes (* 10 1024 1024)) | ||
| 23 | + | ||
| 24 | +(defn- file-bytes [path] | ||
| 25 | + (let [in (java.io.FileInputStream. path)] | ||
| 26 | + (try (.readAllBytes in) | ||
| 27 | + (finally (try (.close in) (catch Exception _ nil)))))) | ||
| 28 | + | ||
| 29 | +(defn- bytes-of [s] (.getBytes (str s))) | ||
| 30 | + | ||
| 31 | +(defn- boundary | ||
| 32 | + "A delimiter of the form the RFC allows, and one no part of this body has in | ||
| 33 | + it: every byte of it is a hyphen, a letter or a digit, and the parts are a | ||
| 34 | + PNG and a DID." | ||
| 35 | + [size] | ||
| 36 | + (str "----frq" (Math/abs (hash (str size "-frq"))))) | ||
| 37 | + | ||
| 38 | +(defn- multipart | ||
| 39 | + "The request body for `fields` (strings) plus the file part, as bytes." | ||
| 40 | + [bound fields filename content-type file] | ||
| 41 | + (let [out (java.io.ByteArrayOutputStream.) | ||
| 42 | + dash (str "--" bound)] | ||
| 43 | + (doseq [[k v] fields] | ||
| 44 | + (.write out (bytes-of (str dash "\r\n" | ||
| 45 | + "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n" | ||
| 46 | + v "\r\n")))) | ||
| 47 | + (.write out (bytes-of (str dash "\r\n" | ||
| 48 | + "Content-Disposition: form-data; name=\"file\";" | ||
| 49 | + " filename=\"" filename "\"\r\n" | ||
| 50 | + "Content-Type: " content-type "\r\n\r\n"))) | ||
| 51 | + (.write out file) | ||
| 52 | + (.write out (bytes-of (str "\r\n" dash "--\r\n"))) | ||
| 53 | + (.toByteArray out))) | ||
| 54 | + | ||
| 55 | +(defn- read-all! | ||
| 56 | + "Drain a TLS connection into a string. The response is a short JSON body, so | ||
| 57 | + it is read whole rather than by Content-Length." | ||
| 58 | + [t] | ||
| 59 | + (loop [acc ""] | ||
| 60 | + (let [b (try (tls/tls-read t) (catch Exception _ nil))] | ||
| 61 | + (if (or (nil? b) (zero? (count b))) | ||
| 62 | + acc | ||
| 63 | + (recur (str acc (String. b))))))) | ||
| 64 | + | ||
| 65 | +(defn- status-of [resp] | ||
| 66 | + (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second)) | ||
| 67 | + | ||
| 68 | +(defn- error-message | ||
| 69 | + "What to show for a response that was not a 2xx. The endpoint answers JSON | ||
| 70 | + with a `message` or an `error` for the cases a user can do something about — | ||
| 71 | + not signed in, file too large — and plain text for the rest." | ||
| 72 | + [status body] | ||
| 73 | + (let [field (fn [k] (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body "")))) | ||
| 74 | + detail (or (field "message") (field "error") (str/trim (str body)))] | ||
| 75 | + (str "Upload failed (" status ")" | ||
| 76 | + (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail)))))))) | ||
| 77 | + | ||
| 78 | +(defn upload! | ||
| 79 | + "Upload `path` as `did`'s, returning the URL freeq serves it back at. | ||
| 80 | + | ||
| 81 | + `channel` is passed along when there is one: the server files an upload under | ||
| 82 | + the conversation it was meant for. Nothing is shared to the PDS or posted to | ||
| 83 | + Bluesky — those are opt-in fields this client does not send. | ||
| 84 | + | ||
| 85 | + Throws with a message meant to be shown when the upload is refused." | ||
| 86 | + [host did channel path filename] | ||
| 87 | + (when (str/blank? (str did)) | ||
| 88 | + (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {}))) | ||
| 89 | + (let [file (file-bytes path)] | ||
| 90 | + (when (> (alength file) max-bytes) | ||
| 91 | + (throw (ex-info "That picture is over the 10MB the server takes." {:bytes (alength file)}))) | ||
| 92 | + (tls/ensure-native!) | ||
| 93 | + (let [bound (boundary (alength file)) | ||
| 94 | + body (multipart bound | ||
| 95 | + (cond-> [["did" did]] | ||
| 96 | + (seq (str channel)) (conj ["channel" channel])) | ||
| 97 | + filename "image/png" file) | ||
| 98 | + head (bytes-of (str "POST /api/v1/upload HTTP/1.1\r\n" | ||
| 99 | + "Host: " host "\r\n" | ||
| 100 | + "User-Agent: frq\r\n" | ||
| 101 | + "Accept: application/json\r\n" | ||
| 102 | + "Content-Type: multipart/form-data; boundary=" bound "\r\n" | ||
| 103 | + "Content-Length: " (alength body) "\r\n" | ||
| 104 | + "Connection: close\r\n\r\n")) | ||
| 105 | + t (tls/tls-connect host 443)] | ||
| 106 | + (try | ||
| 107 | + ;; Head and body in one write: the server reads a request, not two. | ||
| 108 | + (let [req (java.io.ByteArrayOutputStream.)] | ||
| 109 | + (.write req head) | ||
| 110 | + (.write req body) | ||
| 111 | + (tls/tls-write t (.toByteArray req))) | ||
| 112 | + (let [resp (read-all! t) | ||
| 113 | + status (status-of resp) | ||
| 114 | + [_ payload] (str/split resp #"\r\n\r\n" 2)] | ||
| 115 | + (if (and status (str/starts-with? status "2")) | ||
| 116 | + (or (second (re-find #"\"url\"\s*:\s*\"([^\"]*)\"" (or payload ""))) | ||
| 117 | + (throw (ex-info "The server took the picture but named no URL for it." {}))) | ||
| 118 | + (throw (ex-info (error-message (or status "no response") payload) {})))) | ||
| 119 | + (finally (try (tls/tls-close t) (catch Exception _ nil))))))) | ||