Send a picture from the phone
`frq.upload.core` is what freeq's media endpoint is asked and what its answer means: the boundary, the parts, the 10MB the server takes, and reading a URL or a reason out of what came back. All of it is arithmetic over bytes and strings. The sending is not — the desktop writes it down its own TLS and the phone hands it to dart:io — and that is the whole of the difference. The chooser is the platform's, which is what `frq.state/open-image-picker!` already reached for first and fell back from: what a chooser hands back is a grant for the one picture chosen, so the app needs no permission over the reader's pictures at all. Without such a permission browsing finds almost nothing, so the browse screen's actions are installed as the honest empty answer rather than as a screen with nothing on it. Android has no clipboard of pictures either, and paste says so. Two things came out of testing rather than reading. The multipart body is a PersistentVector and `HttpClientRequest.add` wants a List<int>, which ClojureDart does not bridge — the same generic edge `frq.net.dart` splits its own lines to avoid. And the boundary was built out of `hash`, which is the host's and the two hosts do not agree on it; it is arithmetic now, since all it has to do is not collide with the bytes beside it. Verified on a Pixel 6a: the system chooser opened, the picture uploaded while the preview said so, and Send put the media link in #test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6665ec6 parent: 40f8817 added
common/frq/upload/core.cljc +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +(ns frq.upload.core | |
| 2 | + "Sending a picture: what freeq's media endpoint is asked, and what its answer | |
| 3 | + means. | |
| 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. `POST /api/v1/upload` | |
| 7 | + takes a multipart form and answers with a URL under `/api/v1/media/…`, served | |
| 8 | + 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. | |
| 13 | + | |
| 14 | + Everything here is arithmetic over bytes and strings — the boundary, the | |
| 15 | + parts, the cap, and reading a URL or a reason out of what came back. The | |
| 16 | + sending is not: the desktop writes it down its own TLS and the phone hands it | |
| 17 | + to `dart:io`, and neither is this namespace's business. | |
| 18 | + | |
| 19 | + Nothing is streamed. An image is at most a few megabytes and the body is | |
| 20 | + built in memory, which is what keeps the request one write." | |
| 21 | + (:require [clojure.string :as str] | |
| 22 | + [frq.io :as io])) | |
| 23 | + | |
| 24 | +;; The endpoint's own cap. Refusing here rather than at the server saves a | |
| 25 | +;; multi-megabyte upload that was always going to be rejected. | |
| 26 | +(def max-bytes (* 10 1024 1024)) | |
| 27 | + | |
| 28 | +(defn boundary | |
| 29 | + "A delimiter of the form the RFC allows, and one no part of this body has in | |
| 30 | + it: every byte of it is a hyphen, a letter or a digit, and the parts are a | |
| 31 | + PNG and a DID. | |
| 32 | + | |
| 33 | + Written out of `size` rather than taken from a hash: `hash` is the host's and | |
| 34 | + the two hosts do not agree on it, and what this needs is only that it does | |
| 35 | + not collide with the bytes beside it." | |
| 36 | + [size] | |
| 37 | + (str "----frq" size "x" (mod (* 31 (+ size 7)) 100000))) | |
| 38 | + | |
| 39 | +(defn multipart | |
| 40 | + "The request body for `fields` (strings) plus the file part, as a vector of | |
| 41 | + bytes." | |
| 42 | + [bound fields filename content-type file] | |
| 43 | + (let [dash (str "--" bound) | |
| 44 | + text (fn [s] (io/utf8-bytes s))] | |
| 45 | + (-> (reduce (fn [acc [k v]] | |
| 46 | + (into acc (text (str dash "\r\n" | |
| 47 | + "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n" | |
| 48 | + v "\r\n")))) | |
| 49 | + [] | |
| 50 | + fields) | |
| 51 | + (into (text (str dash "\r\n" | |
| 52 | + "Content-Disposition: form-data; name=\"file\";" | |
| 53 | + " filename=\"" filename "\"\r\n" | |
| 54 | + "Content-Type: " content-type "\r\n\r\n"))) | |
| 55 | + (into file) | |
| 56 | + (into (text (str "\r\n" dash "--\r\n")))))) | |
| 57 | + | |
| 58 | +(defn request | |
| 59 | + "What to send for this upload: `{:path :content-type :body}`, body in bytes. | |
| 60 | + | |
| 61 | + Throws with a message meant to be shown when there is no point sending it — | |
| 62 | + a guest has no account to file an upload under, and the endpoint's cap is | |
| 63 | + worth refusing on this side of a few megabytes of wire." | |
| 64 | + [did channel filename file] | |
| 65 | + (when (str/blank? (str did)) | |
| 66 | + (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {}))) | |
| 67 | + (when (> (count file) max-bytes) | |
| 68 | + (throw (ex-info "That picture is over the 10MB the server takes." | |
| 69 | + {:bytes (count file)}))) | |
| 70 | + (let [bound (boundary (count file))] | |
| 71 | + {:path "/api/v1/upload" | |
| 72 | + :content-type (str "multipart/form-data; boundary=" bound) | |
| 73 | + :body (multipart bound | |
| 74 | + (cond-> [["did" did]] | |
| 75 | + (seq (str channel)) (conj ["channel" channel])) | |
| 76 | + filename "image/png" file)})) | |
| 77 | + | |
| 78 | +(defn- field [body k] | |
| 79 | + (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body "")))) | |
| 80 | + | |
| 81 | +(defn error-message | |
| 82 | + "What to show for a response that was not a 2xx. The endpoint answers JSON | |
| 83 | + with a `message` or an `error` for the cases a user can do something about — | |
| 84 | + not signed in, file too large — and plain text for the rest." | |
| 85 | + [status body] | |
| 86 | + (let [detail (or (field body "message") (field body "error") (str/trim (str body)))] | |
| 87 | + (str "Upload failed (" status ")" | |
| 88 | + (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail)))))))) | |
| 89 | + | |
| 90 | +(defn url-of | |
| 91 | + "The URL freeq serves the picture back at, out of a 2xx body. Throws when it | |
| 92 | + took the picture and named no URL for it, which leaves nothing to send." | |
| 93 | + [body] | |
| 94 | + (or (field body "url") | |
| 95 | + (throw (ex-info "The server took the picture but named no URL for it." {})))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +(ns frq.upload.core | ||
| 2 | + "Sending a picture: what freeq's media endpoint is asked, and what its answer | ||
| 3 | + means. | ||
| 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. `POST /api/v1/upload` | ||
| 7 | + takes a multipart form and answers with a URL under `/api/v1/media/…`, served | ||
| 8 | + 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. | ||
| 13 | + | ||
| 14 | + Everything here is arithmetic over bytes and strings — the boundary, the | ||
| 15 | + parts, the cap, and reading a URL or a reason out of what came back. The | ||
| 16 | + sending is not: the desktop writes it down its own TLS and the phone hands it | ||
| 17 | + to `dart:io`, and neither is this namespace's business. | ||
| 18 | + | ||
| 19 | + Nothing is streamed. An image is at most a few megabytes and the body is | ||
| 20 | + built in memory, which is what keeps the request one write." | ||
| 21 | + (:require [clojure.string :as str] | ||
| 22 | + [frq.io :as io])) | ||
| 23 | + | ||
| 24 | +;; The endpoint's own cap. Refusing here rather than at the server saves a | ||
| 25 | +;; multi-megabyte upload that was always going to be rejected. | ||
| 26 | +(def max-bytes (* 10 1024 1024)) | ||
| 27 | + | ||
| 28 | +(defn boundary | ||
| 29 | + "A delimiter of the form the RFC allows, and one no part of this body has in | ||
| 30 | + it: every byte of it is a hyphen, a letter or a digit, and the parts are a | ||
| 31 | + PNG and a DID. | ||
| 32 | + | ||
| 33 | + Written out of `size` rather than taken from a hash: `hash` is the host's and | ||
| 34 | + the two hosts do not agree on it, and what this needs is only that it does | ||
| 35 | + not collide with the bytes beside it." | ||
| 36 | + [size] | ||
| 37 | + (str "----frq" size "x" (mod (* 31 (+ size 7)) 100000))) | ||
| 38 | + | ||
| 39 | +(defn multipart | ||
| 40 | + "The request body for `fields` (strings) plus the file part, as a vector of | ||
| 41 | + bytes." | ||
| 42 | + [bound fields filename content-type file] | ||
| 43 | + (let [dash (str "--" bound) | ||
| 44 | + text (fn [s] (io/utf8-bytes s))] | ||
| 45 | + (-> (reduce (fn [acc [k v]] | ||
| 46 | + (into acc (text (str dash "\r\n" | ||
| 47 | + "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n" | ||
| 48 | + v "\r\n")))) | ||
| 49 | + [] | ||
| 50 | + fields) | ||
| 51 | + (into (text (str dash "\r\n" | ||
| 52 | + "Content-Disposition: form-data; name=\"file\";" | ||
| 53 | + " filename=\"" filename "\"\r\n" | ||
| 54 | + "Content-Type: " content-type "\r\n\r\n"))) | ||
| 55 | + (into file) | ||
| 56 | + (into (text (str "\r\n" dash "--\r\n")))))) | ||
| 57 | + | ||
| 58 | +(defn request | ||
| 59 | + "What to send for this upload: `{:path :content-type :body}`, body in bytes. | ||
| 60 | + | ||
| 61 | + Throws with a message meant to be shown when there is no point sending it — | ||
| 62 | + a guest has no account to file an upload under, and the endpoint's cap is | ||
| 63 | + worth refusing on this side of a few megabytes of wire." | ||
| 64 | + [did channel filename file] | ||
| 65 | + (when (str/blank? (str did)) | ||
| 66 | + (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {}))) | ||
| 67 | + (when (> (count file) max-bytes) | ||
| 68 | + (throw (ex-info "That picture is over the 10MB the server takes." | ||
| 69 | + {:bytes (count file)}))) | ||
| 70 | + (let [bound (boundary (count file))] | ||
| 71 | + {:path "/api/v1/upload" | ||
| 72 | + :content-type (str "multipart/form-data; boundary=" bound) | ||
| 73 | + :body (multipart bound | ||
| 74 | + (cond-> [["did" did]] | ||
| 75 | + (seq (str channel)) (conj ["channel" channel])) | ||
| 76 | + filename "image/png" file)})) | ||
| 77 | + | ||
| 78 | +(defn- field [body k] | ||
| 79 | + (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body "")))) | ||
| 80 | + | ||
| 81 | +(defn error-message | ||
| 82 | + "What to show for a response that was not a 2xx. The endpoint answers JSON | ||
| 83 | + with a `message` or an `error` for the cases a user can do something about — | ||
| 84 | + not signed in, file too large — and plain text for the rest." | ||
| 85 | + [status body] | ||
| 86 | + (let [detail (or (field body "message") (field body "error") (str/trim (str body)))] | ||
| 87 | + (str "Upload failed (" status ")" | ||
| 88 | + (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail)))))))) | ||
| 89 | + | ||
| 90 | +(defn url-of | ||
| 91 | + "The URL freeq serves the picture back at, out of a 2xx body. Throws when it | ||
| 92 | + took the picture and named no URL for it, which leaves nothing to send." | ||
| 93 | + [body] | ||
| 94 | + (or (field body "url") | ||
| 95 | + (throw (ex-info "The server took the picture but named no URL for it." {})))) | ||
modified
flutter/linux/flutter/generated_plugin_registrant.cc +4 -0 | @@ -6,9 +6,13 @@ | ||
| 6 | 6 | |
| 7 | 7 | #include "generated_plugin_registrant.h" |
| 8 | 8 | |
| 9 | +#include <file_selector_linux/file_selector_plugin.h> | |
| 9 | 10 | #include <url_launcher_linux/url_launcher_plugin.h> |
| 10 | 11 | |
| 11 | 12 | void fl_register_plugins(FlPluginRegistry* registry) { |
| 13 | + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = | |
| 14 | + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); | |
| 15 | + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); | |
| 12 | 16 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = |
| 13 | 17 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); |
| 14 | 18 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); |
| @@ -6,9 +6,13 @@ | |||
| 6 | 6 | ||
| 7 | #include "generated_plugin_registrant.h" | 7 | #include "generated_plugin_registrant.h" |
| 8 | 8 | ||
| 9 | +#include <file_selector_linux/file_selector_plugin.h> | ||
| 9 | #include <url_launcher_linux/url_launcher_plugin.h> | 10 | #include <url_launcher_linux/url_launcher_plugin.h> |
| 10 | 11 | ||
| 11 | void fl_register_plugins(FlPluginRegistry* registry) { | 12 | void fl_register_plugins(FlPluginRegistry* registry) { |
| 13 | + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = | ||
| 14 | + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); | ||
| 15 | + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); | ||
| 12 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = | 16 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = |
| 13 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); | 17 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); |
| 14 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); | 18 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); |
modified
flutter/linux/flutter/generated_plugins.cmake +1 -0 | @@ -3,6 +3,7 @@ | ||
| 3 | 3 | # |
| 4 | 4 | |
| 5 | 5 | list(APPEND FLUTTER_PLUGIN_LIST |
| 6 | + file_selector_linux | |
| 6 | 7 | url_launcher_linux |
| 7 | 8 | ) |
| 8 | 9 | |
| @@ -3,6 +3,7 @@ | |||
| 3 | # | 3 | # |
| 4 | 4 | ||
| 5 | list(APPEND FLUTTER_PLUGIN_LIST | 5 | list(APPEND FLUTTER_PLUGIN_LIST |
| 6 | + file_selector_linux | ||
| 6 | url_launcher_linux | 7 | url_launcher_linux |
| 7 | ) | 8 | ) |
| 8 | 9 | ||
modified
flutter/pubspec.lock +136 -0 | @@ -73,6 +73,14 @@ packages: | ||
| 73 | 73 | url: "https://pub.dev" |
| 74 | 74 | source: hosted |
| 75 | 75 | version: "3.1.2" |
| 76 | + cross_file: | |
| 77 | + dependency: transitive | |
| 78 | + description: | |
| 79 | + name: cross_file | |
| 80 | + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 | |
| 81 | + url: "https://pub.dev" | |
| 82 | + source: hosted | |
| 83 | + version: "0.3.5+5" | |
| 76 | 84 | crypto: |
| 77 | 85 | dependency: "direct main" |
| 78 | 86 | description: |
| @@ -113,6 +121,38 @@ packages: | ||
| 113 | 121 | url: "https://pub.dev" |
| 114 | 122 | source: hosted |
| 115 | 123 | version: "2.2.0" |
| 124 | + file_selector_linux: | |
| 125 | + dependency: transitive | |
| 126 | + description: | |
| 127 | + name: file_selector_linux | |
| 128 | + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab | |
| 129 | + url: "https://pub.dev" | |
| 130 | + source: hosted | |
| 131 | + version: "0.9.4+1" | |
| 132 | + file_selector_macos: | |
| 133 | + dependency: transitive | |
| 134 | + description: | |
| 135 | + name: file_selector_macos | |
| 136 | + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 | |
| 137 | + url: "https://pub.dev" | |
| 138 | + source: hosted | |
| 139 | + version: "0.9.5+1" | |
| 140 | + file_selector_platform_interface: | |
| 141 | + dependency: transitive | |
| 142 | + description: | |
| 143 | + name: file_selector_platform_interface | |
| 144 | + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" | |
| 145 | + url: "https://pub.dev" | |
| 146 | + source: hosted | |
| 147 | + version: "2.7.0" | |
| 148 | + file_selector_windows: | |
| 149 | + dependency: transitive | |
| 150 | + description: | |
| 151 | + name: file_selector_windows | |
| 152 | + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec | |
| 153 | + url: "https://pub.dev" | |
| 154 | + source: hosted | |
| 155 | + version: "0.9.3+6" | |
| 116 | 156 | fixnum: |
| 117 | 157 | dependency: transitive |
| 118 | 158 | description: |
| @@ -134,6 +174,14 @@ packages: | ||
| 134 | 174 | url: "https://pub.dev" |
| 135 | 175 | source: hosted |
| 136 | 176 | version: "6.0.0" |
| 177 | + flutter_plugin_android_lifecycle: | |
| 178 | + dependency: transitive | |
| 179 | + description: | |
| 180 | + name: flutter_plugin_android_lifecycle | |
| 181 | + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" | |
| 182 | + url: "https://pub.dev" | |
| 183 | + source: hosted | |
| 184 | + version: "2.0.35" | |
| 137 | 185 | flutter_test: |
| 138 | 186 | dependency: "direct dev" |
| 139 | 187 | description: flutter |
| @@ -152,6 +200,86 @@ packages: | ||
| 152 | 200 | url: "https://pub.dev" |
| 153 | 201 | source: hosted |
| 154 | 202 | version: "2.0.2" |
| 203 | + http: | |
| 204 | + dependency: transitive | |
| 205 | + description: | |
| 206 | + name: http | |
| 207 | + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" | |
| 208 | + url: "https://pub.dev" | |
| 209 | + source: hosted | |
| 210 | + version: "1.6.0" | |
| 211 | + http_parser: | |
| 212 | + dependency: transitive | |
| 213 | + description: | |
| 214 | + name: http_parser | |
| 215 | + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" | |
| 216 | + url: "https://pub.dev" | |
| 217 | + source: hosted | |
| 218 | + version: "4.1.2" | |
| 219 | + image_picker: | |
| 220 | + dependency: "direct main" | |
| 221 | + description: | |
| 222 | + name: image_picker | |
| 223 | + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 | |
| 224 | + url: "https://pub.dev" | |
| 225 | + source: hosted | |
| 226 | + version: "1.2.3" | |
| 227 | + image_picker_android: | |
| 228 | + dependency: transitive | |
| 229 | + description: | |
| 230 | + name: image_picker_android | |
| 231 | + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" | |
| 232 | + url: "https://pub.dev" | |
| 233 | + source: hosted | |
| 234 | + version: "0.8.13+23" | |
| 235 | + image_picker_for_web: | |
| 236 | + dependency: transitive | |
| 237 | + description: | |
| 238 | + name: image_picker_for_web | |
| 239 | + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" | |
| 240 | + url: "https://pub.dev" | |
| 241 | + source: hosted | |
| 242 | + version: "3.1.1" | |
| 243 | + image_picker_ios: | |
| 244 | + dependency: transitive | |
| 245 | + description: | |
| 246 | + name: image_picker_ios | |
| 247 | + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae | |
| 248 | + url: "https://pub.dev" | |
| 249 | + source: hosted | |
| 250 | + version: "0.8.13+7" | |
| 251 | + image_picker_linux: | |
| 252 | + dependency: transitive | |
| 253 | + description: | |
| 254 | + name: image_picker_linux | |
| 255 | + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" | |
| 256 | + url: "https://pub.dev" | |
| 257 | + source: hosted | |
| 258 | + version: "0.2.2" | |
| 259 | + image_picker_macos: | |
| 260 | + dependency: transitive | |
| 261 | + description: | |
| 262 | + name: image_picker_macos | |
| 263 | + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" | |
| 264 | + url: "https://pub.dev" | |
| 265 | + source: hosted | |
| 266 | + version: "0.2.2+1" | |
| 267 | + image_picker_platform_interface: | |
| 268 | + dependency: transitive | |
| 269 | + description: | |
| 270 | + name: image_picker_platform_interface | |
| 271 | + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" | |
| 272 | + url: "https://pub.dev" | |
| 273 | + source: hosted | |
| 274 | + version: "2.11.1" | |
| 275 | + image_picker_windows: | |
| 276 | + dependency: transitive | |
| 277 | + description: | |
| 278 | + name: image_picker_windows | |
| 279 | + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae | |
| 280 | + url: "https://pub.dev" | |
| 281 | + source: hosted | |
| 282 | + version: "0.2.2" | |
| 155 | 283 | jni: |
| 156 | 284 | dependency: transitive |
| 157 | 285 | description: |
| @@ -240,6 +368,14 @@ packages: | ||
| 240 | 368 | url: "https://pub.dev" |
| 241 | 369 | source: hosted |
| 242 | 370 | version: "1.18.3" |
| 371 | + mime: | |
| 372 | + dependency: transitive | |
| 373 | + description: | |
| 374 | + name: mime | |
| 375 | + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 | |
| 376 | + url: "https://pub.dev" | |
| 377 | + source: hosted | |
| 378 | + version: "2.1.0" | |
| 243 | 379 | objective_c: |
| 244 | 380 | dependency: transitive |
| 245 | 381 | description: |
| @@ -73,6 +73,14 @@ packages: | |||
| 73 | url: "https://pub.dev" | 73 | url: "https://pub.dev" |
| 74 | source: hosted | 74 | source: hosted |
| 75 | version: "3.1.2" | 75 | version: "3.1.2" |
| 76 | + cross_file: | ||
| 77 | + dependency: transitive | ||
| 78 | + description: | ||
| 79 | + name: cross_file | ||
| 80 | + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 | ||
| 81 | + url: "https://pub.dev" | ||
| 82 | + source: hosted | ||
| 83 | + version: "0.3.5+5" | ||
| 76 | crypto: | 84 | crypto: |
| 77 | dependency: "direct main" | 85 | dependency: "direct main" |
| 78 | description: | 86 | description: |
| @@ -113,6 +121,38 @@ packages: | |||
| 113 | url: "https://pub.dev" | 121 | url: "https://pub.dev" |
| 114 | source: hosted | 122 | source: hosted |
| 115 | version: "2.2.0" | 123 | version: "2.2.0" |
| 124 | + file_selector_linux: | ||
| 125 | + dependency: transitive | ||
| 126 | + description: | ||
| 127 | + name: file_selector_linux | ||
| 128 | + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab | ||
| 129 | + url: "https://pub.dev" | ||
| 130 | + source: hosted | ||
| 131 | + version: "0.9.4+1" | ||
| 132 | + file_selector_macos: | ||
| 133 | + dependency: transitive | ||
| 134 | + description: | ||
| 135 | + name: file_selector_macos | ||
| 136 | + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 | ||
| 137 | + url: "https://pub.dev" | ||
| 138 | + source: hosted | ||
| 139 | + version: "0.9.5+1" | ||
| 140 | + file_selector_platform_interface: | ||
| 141 | + dependency: transitive | ||
| 142 | + description: | ||
| 143 | + name: file_selector_platform_interface | ||
| 144 | + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" | ||
| 145 | + url: "https://pub.dev" | ||
| 146 | + source: hosted | ||
| 147 | + version: "2.7.0" | ||
| 148 | + file_selector_windows: | ||
| 149 | + dependency: transitive | ||
| 150 | + description: | ||
| 151 | + name: file_selector_windows | ||
| 152 | + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec | ||
| 153 | + url: "https://pub.dev" | ||
| 154 | + source: hosted | ||
| 155 | + version: "0.9.3+6" | ||
| 116 | fixnum: | 156 | fixnum: |
| 117 | dependency: transitive | 157 | dependency: transitive |
| 118 | description: | 158 | description: |
| @@ -134,6 +174,14 @@ packages: | |||
| 134 | url: "https://pub.dev" | 174 | url: "https://pub.dev" |
| 135 | source: hosted | 175 | source: hosted |
| 136 | version: "6.0.0" | 176 | version: "6.0.0" |
| 177 | + flutter_plugin_android_lifecycle: | ||
| 178 | + dependency: transitive | ||
| 179 | + description: | ||
| 180 | + name: flutter_plugin_android_lifecycle | ||
| 181 | + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" | ||
| 182 | + url: "https://pub.dev" | ||
| 183 | + source: hosted | ||
| 184 | + version: "2.0.35" | ||
| 137 | flutter_test: | 185 | flutter_test: |
| 138 | dependency: "direct dev" | 186 | dependency: "direct dev" |
| 139 | description: flutter | 187 | description: flutter |
| @@ -152,6 +200,86 @@ packages: | |||
| 152 | url: "https://pub.dev" | 200 | url: "https://pub.dev" |
| 153 | source: hosted | 201 | source: hosted |
| 154 | version: "2.0.2" | 202 | version: "2.0.2" |
| 203 | + http: | ||
| 204 | + dependency: transitive | ||
| 205 | + description: | ||
| 206 | + name: http | ||
| 207 | + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" | ||
| 208 | + url: "https://pub.dev" | ||
| 209 | + source: hosted | ||
| 210 | + version: "1.6.0" | ||
| 211 | + http_parser: | ||
| 212 | + dependency: transitive | ||
| 213 | + description: | ||
| 214 | + name: http_parser | ||
| 215 | + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" | ||
| 216 | + url: "https://pub.dev" | ||
| 217 | + source: hosted | ||
| 218 | + version: "4.1.2" | ||
| 219 | + image_picker: | ||
| 220 | + dependency: "direct main" | ||
| 221 | + description: | ||
| 222 | + name: image_picker | ||
| 223 | + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 | ||
| 224 | + url: "https://pub.dev" | ||
| 225 | + source: hosted | ||
| 226 | + version: "1.2.3" | ||
| 227 | + image_picker_android: | ||
| 228 | + dependency: transitive | ||
| 229 | + description: | ||
| 230 | + name: image_picker_android | ||
| 231 | + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" | ||
| 232 | + url: "https://pub.dev" | ||
| 233 | + source: hosted | ||
| 234 | + version: "0.8.13+23" | ||
| 235 | + image_picker_for_web: | ||
| 236 | + dependency: transitive | ||
| 237 | + description: | ||
| 238 | + name: image_picker_for_web | ||
| 239 | + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" | ||
| 240 | + url: "https://pub.dev" | ||
| 241 | + source: hosted | ||
| 242 | + version: "3.1.1" | ||
| 243 | + image_picker_ios: | ||
| 244 | + dependency: transitive | ||
| 245 | + description: | ||
| 246 | + name: image_picker_ios | ||
| 247 | + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae | ||
| 248 | + url: "https://pub.dev" | ||
| 249 | + source: hosted | ||
| 250 | + version: "0.8.13+7" | ||
| 251 | + image_picker_linux: | ||
| 252 | + dependency: transitive | ||
| 253 | + description: | ||
| 254 | + name: image_picker_linux | ||
| 255 | + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" | ||
| 256 | + url: "https://pub.dev" | ||
| 257 | + source: hosted | ||
| 258 | + version: "0.2.2" | ||
| 259 | + image_picker_macos: | ||
| 260 | + dependency: transitive | ||
| 261 | + description: | ||
| 262 | + name: image_picker_macos | ||
| 263 | + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" | ||
| 264 | + url: "https://pub.dev" | ||
| 265 | + source: hosted | ||
| 266 | + version: "0.2.2+1" | ||
| 267 | + image_picker_platform_interface: | ||
| 268 | + dependency: transitive | ||
| 269 | + description: | ||
| 270 | + name: image_picker_platform_interface | ||
| 271 | + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" | ||
| 272 | + url: "https://pub.dev" | ||
| 273 | + source: hosted | ||
| 274 | + version: "2.11.1" | ||
| 275 | + image_picker_windows: | ||
| 276 | + dependency: transitive | ||
| 277 | + description: | ||
| 278 | + name: image_picker_windows | ||
| 279 | + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae | ||
| 280 | + url: "https://pub.dev" | ||
| 281 | + source: hosted | ||
| 282 | + version: "0.2.2" | ||
| 155 | jni: | 283 | jni: |
| 156 | dependency: transitive | 284 | dependency: transitive |
| 157 | description: | 285 | description: |
| @@ -240,6 +368,14 @@ packages: | |||
| 240 | url: "https://pub.dev" | 368 | url: "https://pub.dev" |
| 241 | source: hosted | 369 | source: hosted |
| 242 | version: "1.18.3" | 370 | version: "1.18.3" |
| 371 | + mime: | ||
| 372 | + dependency: transitive | ||
| 373 | + description: | ||
| 374 | + name: mime | ||
| 375 | + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 | ||
| 376 | + url: "https://pub.dev" | ||
| 377 | + source: hosted | ||
| 378 | + version: "2.1.0" | ||
| 243 | objective_c: | 379 | objective_c: |
| 244 | dependency: transitive | 380 | dependency: transitive |
| 245 | description: | 381 | description: |
modified
flutter/pubspec.yaml +4 -0 | @@ -41,6 +41,10 @@ dependencies: | ||
| 41 | 41 | # dart:io cannot do on its own — the loopback listener that catches the |
| 42 | 42 | # answer is plain dart:io. |
| 43 | 43 | url_launcher: ^6.3.1 |
| 44 | + # The system picture chooser. What it hands back is a grant for the one | |
| 45 | + # picture chosen, so the app needs no permission over the reader's pictures | |
| 46 | + # at all — which is also why browsing for one finds almost nothing here. | |
| 47 | + image_picker: ^1.1.2 | |
| 44 | 48 | dev_dependencies: |
| 45 | 49 | flutter_test: |
| 46 | 50 | sdk: flutter |
| @@ -41,6 +41,10 @@ dependencies: | |||
| 41 | # dart:io cannot do on its own — the loopback listener that catches the | 41 | # dart:io cannot do on its own — the loopback listener that catches the |
| 42 | # answer is plain dart:io. | 42 | # answer is plain dart:io. |
| 43 | url_launcher: ^6.3.1 | 43 | url_launcher: ^6.3.1 |
| 44 | + # The system picture chooser. What it hands back is a grant for the one | ||
| 45 | + # picture chosen, so the app needs no permission over the reader's pictures | ||
| 46 | + # at all — which is also why browsing for one finds almost nothing here. | ||
| 47 | + image_picker: ^1.1.2 | ||
| 44 | dev_dependencies: | 48 | dev_dependencies: |
| 45 | flutter_test: | 49 | flutter_test: |
| 46 | sdk: flutter | 50 | sdk: flutter |
modified
flutter/src/frq/main.cljd +109 -3 | @@ -17,6 +17,7 @@ | ||
| 17 | 17 | compiler: the clock and the saved session, read through exactly the |
| 18 | 18 | namespaces the desktop reads them through." |
| 19 | 19 | (:require ["dart:async" :as async] |
| 20 | + ["dart:io" :as dio] | |
| 20 | 21 | ["package:flutter/material.dart" :as m] |
| 21 | 22 | ["package:path_provider/path_provider.dart" :as pp] |
| 22 | 23 | [cljd.flutter :as f] |
| @@ -46,6 +47,8 @@ | ||
| 46 | 47 | [frq.edits :as edits] |
| 47 | 48 | [frq.profile :as profile] |
| 48 | 49 | [frq.avatars.dart :as avatars] |
| 50 | + ["package:image_picker/image_picker.dart" :as picker] | |
| 51 | + [frq.upload.dart :as upload] | |
| 49 | 52 | [frq.irc.mutate :as mutate] |
| 50 | 53 | [frq.oauth.core :as oauth] |
| 51 | 54 | [frq.oauth.dart :as oauth-dart] |
| @@ -559,6 +562,75 @@ | ||
| 559 | 562 | [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]] |
| 560 | 563 | [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]]) |
| 561 | 564 | |
| 565 | +(defn- outgoing-dir [] | |
| 566 | + (let [d (str (fio/config-dir) "/outgoing")] | |
| 567 | + (fio/mkdirs! d) | |
| 568 | + d)) | |
| 569 | + | |
| 570 | +(defn- discard! [path] | |
| 571 | + (try (fio/delete-file! path) (catch Object _ nil))) | |
| 572 | + | |
| 573 | +(defn- clear-attachment! | |
| 574 | + "Drop the picture without sending it, and the copy of it with it." | |
| 575 | + [] | |
| 576 | + (when-let [a @cells/attachment] | |
| 577 | + (reset! cells/attachment nil) | |
| 578 | + (discard! (:path a)))) | |
| 579 | + | |
| 580 | +(defn ^:async attach! | |
| 581 | + "Hold the picture already copied to `path` against the next line, and start | |
| 582 | + its upload. | |
| 583 | + | |
| 584 | + The upload starts at once rather than at send, so by the time a line is | |
| 585 | + written the picture is usually already up. A failure lands in `error` like | |
| 586 | + any other and takes the attachment with it — there is nothing to send and | |
| 587 | + nothing to show." | |
| 588 | + [path filename] | |
| 589 | + (let [did (:did @cells/session) | |
| 590 | + host-name (str @cells/form-host) | |
| 591 | + channel (str @cells/current)] | |
| 592 | + (reset! cells/error nil) | |
| 593 | + (clear-attachment!) | |
| 594 | + (reset! cells/attachment {:path path :status :uploading}) | |
| 595 | + (try | |
| 596 | + (let [url (await (upload/upload! host-name did channel path filename))] | |
| 597 | + ;; Only if this is still the picture on screen: a reader who attached | |
| 598 | + ;; another, or cleared it, has said what they want, and an upload | |
| 599 | + ;; landing afterwards does not get to undo that. | |
| 600 | + (swap! cells/attachment #(if (= (:path %) path) | |
| 601 | + (assoc % :url url :status :ready) | |
| 602 | + %)) | |
| 603 | + (when-not (= (:path @cells/attachment) path) (discard! path))) | |
| 604 | + (catch Object e | |
| 605 | + (swap! cells/attachment #(if (= (:path %) path) nil %)) | |
| 606 | + (discard! path) | |
| 607 | + (reset! cells/error (str (or (ex-message e) e))))))) | |
| 608 | + | |
| 609 | +(defn ^:async open-image-picker! | |
| 610 | + "Ask for a picture, the way a phone asks. | |
| 611 | + | |
| 612 | + The system chooser, not this app's browsing screen: what the chooser hands | |
| 613 | + back is a grant for the one picture chosen, so the app needs no permission | |
| 614 | + over the reader's pictures at all — and without such a permission, browsing | |
| 615 | + finds almost nothing to show. `frq.state/open-image-picker!` says the same | |
| 616 | + thing from the other side and falls back to browsing where there is no | |
| 617 | + chooser, which is every desktop. | |
| 618 | + | |
| 619 | + Copied into our own storage rather than attached where it lies: the send | |
| 620 | + drops the attachment's file when it is done with it, and what it drops has | |
| 621 | + to be ours — not the reader's own picture, sitting in their gallery." | |
| 622 | + [] | |
| 623 | + (try | |
| 624 | + (let [chosen (await (.pickImage (picker/ImagePicker) | |
| 625 | + .source picker/ImageSource.gallery))] | |
| 626 | + (when chosen | |
| 627 | + (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now)) | |
| 628 | + ".png")] | |
| 629 | + (await (.copy (dio/File. (.-path chosen)) copy)) | |
| 630 | + (await (attach! copy "picture.png"))))) | |
| 631 | + (catch Object e | |
| 632 | + (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e)))))) | |
| 633 | + | |
| 562 | 634 | (defn- send-draft! [] |
| 563 | 635 | (let [text (str @cells/draft) |
| 564 | 636 | room (str @cells/current) |
| @@ -576,8 +648,21 @@ | ||
| 576 | 648 | (reset! cells/editing nil) |
| 577 | 649 | (reset! cells/draft "")) |
| 578 | 650 | |
| 579 | - (and (seq text) (seq room) @conn) | |
| 580 | - (do | |
| 651 | + ;; A picture still on its way up holds the send rather than losing it: | |
| 652 | + ;; the line stays in the box, said so, and the reader presses send again | |
| 653 | + ;; a moment later. Sending the text without its picture would be the one | |
| 654 | + ;; outcome nobody asked for. | |
| 655 | + (= :uploading (:status @cells/attachment)) | |
| 656 | + (reset! cells/error "The picture is still uploading.") | |
| 657 | + | |
| 658 | + (and (or (seq text) (:url @cells/attachment)) (seq room) @conn) | |
| 659 | + (let [url (:url @cells/attachment) | |
| 660 | + ;; The picture becomes its link, at the end of the line: what goes | |
| 661 | + ;; on the wire is the text the reader wrote and a URL after it, | |
| 662 | + ;; which is what every other client in the channel knows how to | |
| 663 | + ;; show. A line that is only a picture is only the link. | |
| 664 | + text (.trim (str text (when url (str " " url))))] | |
| 665 | + (do | |
| 581 | 666 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| 582 | 667 | ;; Echoed locally only when the server will not echo it back. With |
| 583 | 668 | ;; `echo-message` negotiated it does — that is what the cap is for, and |
| @@ -586,7 +671,11 @@ | ||
| 586 | 671 | (when-not (handshake/acked? @caps "echo-message") |
| 587 | 672 | (swap! cells/channels update room |
| 588 | 673 | #(update % :messages conj {:from @cells/form-nick :text text}))) |
| 589 | - (reset! cells/draft ""))))) | |
| 674 | + ;; The attachment has done its job the moment the link is on the wire. | |
| 675 | + (when-let [a @cells/attachment] | |
| 676 | + (reset! cells/attachment nil) | |
| 677 | + (discard! (:path a))) | |
| 678 | + (reset! cells/draft "")))))) | |
| 590 | 679 | |
| 591 | 680 | (defn- message-by-id [room id] |
| 592 | 681 | (when id |
| @@ -678,6 +767,23 @@ | ||
| 678 | 767 | (swap! cells/channels dissoc name)) |
| 679 | 768 | :send-draft! send-draft! |
| 680 | 769 | |
| 770 | + ;; Pictures. The chooser is the platform's, so the browsing screen the | |
| 771 | + ;; desktop falls back to is never opened here — and its actions would | |
| 772 | + ;; have nothing to list anyway, since everything outside this app's own | |
| 773 | + ;; storage is behind a permission it does not ask for. | |
| 774 | + :open-image-picker! open-image-picker! | |
| 775 | + :clear-attachment! clear-attachment! | |
| 776 | + :close-image-picker! (fn [] (reset! cells/image-picker nil)) | |
| 777 | + :browse! (fn [_] nil) | |
| 778 | + :picker-roots (fn [] []) | |
| 779 | + :picker-entries (fn [_] {:dirs [] :files []}) | |
| 780 | + :parent-dir (fn [_] nil) | |
| 781 | + :pick-image! (fn [_] nil) | |
| 782 | + ;; Android has no clipboard of pictures to read, which is the other half | |
| 783 | + ;; of why the chooser above exists. | |
| 784 | + :paste-image! (fn [] | |
| 785 | + (reset! cells/error "No picture on the clipboard.")) | |
| 786 | + | |
| 681 | 787 | ;; Answering. Both are a cell and nothing else — what the composer does |
| 682 | 788 | ;; with `replying-to` is the shared screen's business. |
| 683 | 789 | :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text]))) |
| @@ -17,6 +17,7 @@ | |||
| 17 | compiler: the clock and the saved session, read through exactly the | 17 | compiler: the clock and the saved session, read through exactly the |
| 18 | namespaces the desktop reads them through." | 18 | namespaces the desktop reads them through." |
| 19 | (:require ["dart:async" :as async] | 19 | (:require ["dart:async" :as async] |
| 20 | + ["dart:io" :as dio] | ||
| 20 | ["package:flutter/material.dart" :as m] | 21 | ["package:flutter/material.dart" :as m] |
| 21 | ["package:path_provider/path_provider.dart" :as pp] | 22 | ["package:path_provider/path_provider.dart" :as pp] |
| 22 | [cljd.flutter :as f] | 23 | [cljd.flutter :as f] |
| @@ -46,6 +47,8 @@ | |||
| 46 | [frq.edits :as edits] | 47 | [frq.edits :as edits] |
| 47 | [frq.profile :as profile] | 48 | [frq.profile :as profile] |
| 48 | [frq.avatars.dart :as avatars] | 49 | [frq.avatars.dart :as avatars] |
| 50 | + ["package:image_picker/image_picker.dart" :as picker] | ||
| 51 | + [frq.upload.dart :as upload] | ||
| 49 | [frq.irc.mutate :as mutate] | 52 | [frq.irc.mutate :as mutate] |
| 50 | [frq.oauth.core :as oauth] | 53 | [frq.oauth.core :as oauth] |
| 51 | [frq.oauth.dart :as oauth-dart] | 54 | [frq.oauth.dart :as oauth-dart] |
| @@ -559,6 +562,75 @@ | |||
| 559 | [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]] | 562 | [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]] |
| 560 | [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]]) | 563 | [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]]) |
| 561 | 564 | ||
| 565 | +(defn- outgoing-dir [] | ||
| 566 | + (let [d (str (fio/config-dir) "/outgoing")] | ||
| 567 | + (fio/mkdirs! d) | ||
| 568 | + d)) | ||
| 569 | + | ||
| 570 | +(defn- discard! [path] | ||
| 571 | + (try (fio/delete-file! path) (catch Object _ nil))) | ||
| 572 | + | ||
| 573 | +(defn- clear-attachment! | ||
| 574 | + "Drop the picture without sending it, and the copy of it with it." | ||
| 575 | + [] | ||
| 576 | + (when-let [a @cells/attachment] | ||
| 577 | + (reset! cells/attachment nil) | ||
| 578 | + (discard! (:path a)))) | ||
| 579 | + | ||
| 580 | +(defn ^:async attach! | ||
| 581 | + "Hold the picture already copied to `path` against the next line, and start | ||
| 582 | + its upload. | ||
| 583 | + | ||
| 584 | + The upload starts at once rather than at send, so by the time a line is | ||
| 585 | + written the picture is usually already up. A failure lands in `error` like | ||
| 586 | + any other and takes the attachment with it — there is nothing to send and | ||
| 587 | + nothing to show." | ||
| 588 | + [path filename] | ||
| 589 | + (let [did (:did @cells/session) | ||
| 590 | + host-name (str @cells/form-host) | ||
| 591 | + channel (str @cells/current)] | ||
| 592 | + (reset! cells/error nil) | ||
| 593 | + (clear-attachment!) | ||
| 594 | + (reset! cells/attachment {:path path :status :uploading}) | ||
| 595 | + (try | ||
| 596 | + (let [url (await (upload/upload! host-name did channel path filename))] | ||
| 597 | + ;; Only if this is still the picture on screen: a reader who attached | ||
| 598 | + ;; another, or cleared it, has said what they want, and an upload | ||
| 599 | + ;; landing afterwards does not get to undo that. | ||
| 600 | + (swap! cells/attachment #(if (= (:path %) path) | ||
| 601 | + (assoc % :url url :status :ready) | ||
| 602 | + %)) | ||
| 603 | + (when-not (= (:path @cells/attachment) path) (discard! path))) | ||
| 604 | + (catch Object e | ||
| 605 | + (swap! cells/attachment #(if (= (:path %) path) nil %)) | ||
| 606 | + (discard! path) | ||
| 607 | + (reset! cells/error (str (or (ex-message e) e))))))) | ||
| 608 | + | ||
| 609 | +(defn ^:async open-image-picker! | ||
| 610 | + "Ask for a picture, the way a phone asks. | ||
| 611 | + | ||
| 612 | + The system chooser, not this app's browsing screen: what the chooser hands | ||
| 613 | + back is a grant for the one picture chosen, so the app needs no permission | ||
| 614 | + over the reader's pictures at all — and without such a permission, browsing | ||
| 615 | + finds almost nothing to show. `frq.state/open-image-picker!` says the same | ||
| 616 | + thing from the other side and falls back to browsing where there is no | ||
| 617 | + chooser, which is every desktop. | ||
| 618 | + | ||
| 619 | + Copied into our own storage rather than attached where it lies: the send | ||
| 620 | + drops the attachment's file when it is done with it, and what it drops has | ||
| 621 | + to be ours — not the reader's own picture, sitting in their gallery." | ||
| 622 | + [] | ||
| 623 | + (try | ||
| 624 | + (let [chosen (await (.pickImage (picker/ImagePicker) | ||
| 625 | + .source picker/ImageSource.gallery))] | ||
| 626 | + (when chosen | ||
| 627 | + (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now)) | ||
| 628 | + ".png")] | ||
| 629 | + (await (.copy (dio/File. (.-path chosen)) copy)) | ||
| 630 | + (await (attach! copy "picture.png"))))) | ||
| 631 | + (catch Object e | ||
| 632 | + (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e)))))) | ||
| 633 | + | ||
| 562 | (defn- send-draft! [] | 634 | (defn- send-draft! [] |
| 563 | (let [text (str @cells/draft) | 635 | (let [text (str @cells/draft) |
| 564 | room (str @cells/current) | 636 | room (str @cells/current) |
| @@ -576,8 +648,21 @@ | |||
| 576 | (reset! cells/editing nil) | 648 | (reset! cells/editing nil) |
| 577 | (reset! cells/draft "")) | 649 | (reset! cells/draft "")) |
| 578 | 650 | ||
| 579 | - (and (seq text) (seq room) @conn) | 651 | + ;; A picture still on its way up holds the send rather than losing it: |
| 580 | - (do | 652 | + ;; the line stays in the box, said so, and the reader presses send again |
| 653 | + ;; a moment later. Sending the text without its picture would be the one | ||
| 654 | + ;; outcome nobody asked for. | ||
| 655 | + (= :uploading (:status @cells/attachment)) | ||
| 656 | + (reset! cells/error "The picture is still uploading.") | ||
| 657 | + | ||
| 658 | + (and (or (seq text) (:url @cells/attachment)) (seq room) @conn) | ||
| 659 | + (let [url (:url @cells/attachment) | ||
| 660 | + ;; The picture becomes its link, at the end of the line: what goes | ||
| 661 | + ;; on the wire is the text the reader wrote and a URL after it, | ||
| 662 | + ;; which is what every other client in the channel knows how to | ||
| 663 | + ;; show. A line that is only a picture is only the link. | ||
| 664 | + text (.trim (str text (when url (str " " url))))] | ||
| 665 | + (do | ||
| 581 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) | 666 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| 582 | ;; Echoed locally only when the server will not echo it back. With | 667 | ;; Echoed locally only when the server will not echo it back. With |
| 583 | ;; `echo-message` negotiated it does — that is what the cap is for, and | 668 | ;; `echo-message` negotiated it does — that is what the cap is for, and |
| @@ -586,7 +671,11 @@ | |||
| 586 | (when-not (handshake/acked? @caps "echo-message") | 671 | (when-not (handshake/acked? @caps "echo-message") |
| 587 | (swap! cells/channels update room | 672 | (swap! cells/channels update room |
| 588 | #(update % :messages conj {:from @cells/form-nick :text text}))) | 673 | #(update % :messages conj {:from @cells/form-nick :text text}))) |
| 589 | - (reset! cells/draft ""))))) | 674 | + ;; The attachment has done its job the moment the link is on the wire. |
| 675 | + (when-let [a @cells/attachment] | ||
| 676 | + (reset! cells/attachment nil) | ||
| 677 | + (discard! (:path a))) | ||
| 678 | + (reset! cells/draft "")))))) | ||
| 590 | 679 | ||
| 591 | (defn- message-by-id [room id] | 680 | (defn- message-by-id [room id] |
| 592 | (when id | 681 | (when id |
| @@ -678,6 +767,23 @@ | |||
| 678 | (swap! cells/channels dissoc name)) | 767 | (swap! cells/channels dissoc name)) |
| 679 | :send-draft! send-draft! | 768 | :send-draft! send-draft! |
| 680 | 769 | ||
| 770 | + ;; Pictures. The chooser is the platform's, so the browsing screen the | ||
| 771 | + ;; desktop falls back to is never opened here — and its actions would | ||
| 772 | + ;; have nothing to list anyway, since everything outside this app's own | ||
| 773 | + ;; storage is behind a permission it does not ask for. | ||
| 774 | + :open-image-picker! open-image-picker! | ||
| 775 | + :clear-attachment! clear-attachment! | ||
| 776 | + :close-image-picker! (fn [] (reset! cells/image-picker nil)) | ||
| 777 | + :browse! (fn [_] nil) | ||
| 778 | + :picker-roots (fn [] []) | ||
| 779 | + :picker-entries (fn [_] {:dirs [] :files []}) | ||
| 780 | + :parent-dir (fn [_] nil) | ||
| 781 | + :pick-image! (fn [_] nil) | ||
| 782 | + ;; Android has no clipboard of pictures to read, which is the other half | ||
| 783 | + ;; of why the chooser above exists. | ||
| 784 | + :paste-image! (fn [] | ||
| 785 | + (reset! cells/error "No picture on the clipboard.")) | ||
| 786 | + | ||
| 681 | ;; Answering. Both are a cell and nothing else — what the composer does | 787 | ;; Answering. Both are a cell and nothing else — what the composer does |
| 682 | ;; with `replying-to` is the shared screen's business. | 788 | ;; with `replying-to` is the shared screen's business. |
| 683 | :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text]))) | 789 | :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text]))) |
added
flutter/src/frq/upload/dart.cljd +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +(ns frq.upload.dart | |
| 2 | + "Sending the picture, over dart:io. | |
| 3 | + | |
| 4 | + `frq.upload.core` builds the multipart body and reads freeq's answer; this is | |
| 5 | + the one write and the one read. The desktop's half of this is jolt's own TLS | |
| 6 | + and a hand-built request line — here `HttpClient` is the runtime's, which is | |
| 7 | + the same reason `frq.net.dart` has no OpenSSL in it." | |
| 8 | + (:require ["dart:io" :as io] | |
| 9 | + ["dart:convert" :as conv] | |
| 10 | + [frq.upload.core :as core])) | |
| 11 | + | |
| 12 | +(defonce ^:private client (io/HttpClient)) | |
| 13 | + | |
| 14 | +(defn ^:async upload! | |
| 15 | + "Upload the file at `path` as `did`'s, answering the URL freeq serves it back | |
| 16 | + at. Throws with a message meant to be shown." | |
| 17 | + [host did channel path filename] | |
| 18 | + (let [file (io/File. path) | |
| 19 | + bytes (await (.readAsBytes file)) | |
| 20 | + {p :path ct :content-type body :body} (core/request did channel filename | |
| 21 | + (vec bytes)) | |
| 22 | + uri (Uri.https host p) | |
| 23 | + req (await (.postUrl client uri))] | |
| 24 | + (.set (.-headers req) "user-agent" "frq") | |
| 25 | + (.set (.-headers req) "accept" "application/json") | |
| 26 | + (.set (.-headers req) "content-type" ct) | |
| 27 | + ;; Byte by byte into a builder rather than handing the vector over: what | |
| 28 | + ;; `frq.upload.core` answers with is a PersistentVector, `add` wants a | |
| 29 | + ;; List<int>, and ClojureDart does not bridge the one to the other — the | |
| 30 | + ;; same generic edge `frq.net.dart` splits its own lines to avoid. The | |
| 31 | + ;; first attempt put "PersistentVector<dynamic> is not a subtype of | |
| 32 | + ;; List<int>" on screen, which at least the error banner showed. | |
| 33 | + (let [out (io/BytesBuilder)] | |
| 34 | + (doseq [b body] (.addByte out b)) | |
| 35 | + (.add req (.takeBytes out))) | |
| 36 | + (let [resp (await (.close req)) | |
| 37 | + payload (await (.join (.transform resp (.-decoder conv/utf8)))) | |
| 38 | + status (.-statusCode resp)] | |
| 39 | + (if (and (>= status 200) (< status 300)) | |
| 40 | + (core/url-of payload) | |
| 41 | + (throw (ex-info (core/error-message (str status) payload) {})))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +(ns frq.upload.dart | ||
| 2 | + "Sending the picture, over dart:io. | ||
| 3 | + | ||
| 4 | + `frq.upload.core` builds the multipart body and reads freeq's answer; this is | ||
| 5 | + the one write and the one read. The desktop's half of this is jolt's own TLS | ||
| 6 | + and a hand-built request line — here `HttpClient` is the runtime's, which is | ||
| 7 | + the same reason `frq.net.dart` has no OpenSSL in it." | ||
| 8 | + (:require ["dart:io" :as io] | ||
| 9 | + ["dart:convert" :as conv] | ||
| 10 | + [frq.upload.core :as core])) | ||
| 11 | + | ||
| 12 | +(defonce ^:private client (io/HttpClient)) | ||
| 13 | + | ||
| 14 | +(defn ^:async upload! | ||
| 15 | + "Upload the file at `path` as `did`'s, answering the URL freeq serves it back | ||
| 16 | + at. Throws with a message meant to be shown." | ||
| 17 | + [host did channel path filename] | ||
| 18 | + (let [file (io/File. path) | ||
| 19 | + bytes (await (.readAsBytes file)) | ||
| 20 | + {p :path ct :content-type body :body} (core/request did channel filename | ||
| 21 | + (vec bytes)) | ||
| 22 | + uri (Uri.https host p) | ||
| 23 | + req (await (.postUrl client uri))] | ||
| 24 | + (.set (.-headers req) "user-agent" "frq") | ||
| 25 | + (.set (.-headers req) "accept" "application/json") | ||
| 26 | + (.set (.-headers req) "content-type" ct) | ||
| 27 | + ;; Byte by byte into a builder rather than handing the vector over: what | ||
| 28 | + ;; `frq.upload.core` answers with is a PersistentVector, `add` wants a | ||
| 29 | + ;; List<int>, and ClojureDart does not bridge the one to the other — the | ||
| 30 | + ;; same generic edge `frq.net.dart` splits its own lines to avoid. The | ||
| 31 | + ;; first attempt put "PersistentVector<dynamic> is not a subtype of | ||
| 32 | + ;; List<int>" on screen, which at least the error banner showed. | ||
| 33 | + (let [out (io/BytesBuilder)] | ||
| 34 | + (doseq [b body] (.addByte out b)) | ||
| 35 | + (.add req (.takeBytes out))) | ||
| 36 | + (let [resp (await (.close req)) | ||
| 37 | + payload (await (.join (.transform resp (.-decoder conv/utf8)))) | ||
| 38 | + status (.-statusCode resp)] | ||
| 39 | + (if (and (>= status 200) (< status 300)) | ||
| 40 | + (core/url-of payload) | ||
| 41 | + (throw (ex-info (core/error-message (str status) payload) {})))))) | ||
modified
src/frq/upload.clj +13 -53 | @@ -15,11 +15,12 @@ | ||
| 15 | 15 | Nothing here is streamed: an image is at most a few megabytes and the body is |
| 16 | 16 | built in memory, which is what keeps the request one write." |
| 17 | 17 | (:require [clojure.string :as str] |
| 18 | + [frq.upload.core :as core] | |
| 18 | 19 | [jolt.mvn-http :as tls])) |
| 19 | 20 | |
| 20 | 21 | ;; The endpoint's own cap. Refusing here rather than at the server saves a |
| 21 | 22 | ;; multi-megabyte upload that was always going to be rejected. |
| 22 | -(def max-bytes (* 10 1024 1024)) | |
| 23 | +(def max-bytes core/max-bytes) | |
| 23 | 24 | |
| 24 | 25 | (defn- file-bytes [path] |
| 25 | 26 | (let [in (java.io.FileInputStream. path)] |
| @@ -28,30 +29,6 @@ | ||
| 28 | 29 | |
| 29 | 30 | (defn- bytes-of [s] (.getBytes (str s))) |
| 30 | 31 | |
| 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 | 32 | (defn- read-all! |
| 56 | 33 | "Drain a TLS connection into a string. The response is a short JSON body, so |
| 57 | 34 | it is read whole rather than by Content-Length." |
| @@ -65,41 +42,25 @@ | ||
| 65 | 42 | (defn- status-of [resp] |
| 66 | 43 | (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second)) |
| 67 | 44 | |
| 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 | 45 | (defn upload! |
| 79 | 46 | "Upload `path` as `did`'s, returning the URL freeq serves it back at. |
| 80 | 47 | |
| 48 | + `frq.upload.core` builds the request and reads the answer; what is left here | |
| 49 | + is this backend's way of sending one — jolt's own TLS, in a single write. | |
| 50 | + | |
| 81 | 51 | `channel` is passed along when there is one: the server files an upload under |
| 82 | 52 | 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." | |
| 53 | + Bluesky — those are opt-in fields this client does not send." | |
| 86 | 54 | [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)}))) | |
| 55 | + (let [{:keys [path content-type body]} | |
| 56 | + (core/request did channel filename (vec (file-bytes path))) | |
| 57 | + body (byte-array body)] | |
| 92 | 58 | (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" | |
| 59 | + (let [head (bytes-of (str "POST " path " HTTP/1.1\r\n" | |
| 99 | 60 | "Host: " host "\r\n" |
| 100 | 61 | "User-Agent: frq\r\n" |
| 101 | 62 | "Accept: application/json\r\n" |
| 102 | - "Content-Type: multipart/form-data; boundary=" bound "\r\n" | |
| 63 | + "Content-Type: " content-type "\r\n" | |
| 103 | 64 | "Content-Length: " (alength body) "\r\n" |
| 104 | 65 | "Connection: close\r\n\r\n")) |
| 105 | 66 | t (tls/tls-connect host 443)] |
| @@ -113,7 +74,6 @@ | ||
| 113 | 74 | status (status-of resp) |
| 114 | 75 | [_ payload] (str/split resp #"\r\n\r\n" 2)] |
| 115 | 76 | (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) {})))) | |
| 77 | + (core/url-of payload) | |
| 78 | + (throw (ex-info (core/error-message (or status "no response") payload) {})))) | |
| 119 | 79 | (finally (try (tls/tls-close t) (catch Exception _ nil))))))) |
| @@ -15,11 +15,12 @@ | |||
| 15 | Nothing here is streamed: an image is at most a few megabytes and the body is | 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." | 16 | built in memory, which is what keeps the request one write." |
| 17 | (:require [clojure.string :as str] | 17 | (:require [clojure.string :as str] |
| 18 | + [frq.upload.core :as core] | ||
| 18 | [jolt.mvn-http :as tls])) | 19 | [jolt.mvn-http :as tls])) |
| 19 | 20 | ||
| 20 | ;; The endpoint's own cap. Refusing here rather than at the server saves a | 21 | ;; 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 | ;; multi-megabyte upload that was always going to be rejected. |
| 22 | -(def max-bytes (* 10 1024 1024)) | 23 | +(def max-bytes core/max-bytes) |
| 23 | 24 | ||
| 24 | (defn- file-bytes [path] | 25 | (defn- file-bytes [path] |
| 25 | (let [in (java.io.FileInputStream. path)] | 26 | (let [in (java.io.FileInputStream. path)] |
| @@ -28,30 +29,6 @@ | |||
| 28 | 29 | ||
| 29 | (defn- bytes-of [s] (.getBytes (str s))) | 30 | (defn- bytes-of [s] (.getBytes (str s))) |
| 30 | 31 | ||
| 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! | 32 | (defn- read-all! |
| 56 | "Drain a TLS connection into a string. The response is a short JSON body, so | 33 | "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." | 34 | it is read whole rather than by Content-Length." |
| @@ -65,41 +42,25 @@ | |||
| 65 | (defn- status-of [resp] | 42 | (defn- status-of [resp] |
| 66 | (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second)) | 43 | (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second)) |
| 67 | 44 | ||
| 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! | 45 | (defn upload! |
| 79 | "Upload `path` as `did`'s, returning the URL freeq serves it back at. | 46 | "Upload `path` as `did`'s, returning the URL freeq serves it back at. |
| 80 | 47 | ||
| 48 | + `frq.upload.core` builds the request and reads the answer; what is left here | ||
| 49 | + is this backend's way of sending one — jolt's own TLS, in a single write. | ||
| 50 | + | ||
| 81 | `channel` is passed along when there is one: the server files an upload under | 51 | `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 | 52 | 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. | 53 | + 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] | 54 | [host did channel path filename] |
| 87 | - (when (str/blank? (str did)) | 55 | + (let [{:keys [path content-type body]} |
| 88 | - (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {}))) | 56 | + (core/request did channel filename (vec (file-bytes path))) |
| 89 | - (let [file (file-bytes path)] | 57 | + body (byte-array body)] |
| 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!) | 58 | (tls/ensure-native!) |
| 93 | - (let [bound (boundary (alength file)) | 59 | + (let [head (bytes-of (str "POST " path " HTTP/1.1\r\n" |
| 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" | 60 | "Host: " host "\r\n" |
| 100 | "User-Agent: frq\r\n" | 61 | "User-Agent: frq\r\n" |
| 101 | "Accept: application/json\r\n" | 62 | "Accept: application/json\r\n" |
| 102 | - "Content-Type: multipart/form-data; boundary=" bound "\r\n" | 63 | + "Content-Type: " content-type "\r\n" |
| 103 | "Content-Length: " (alength body) "\r\n" | 64 | "Content-Length: " (alength body) "\r\n" |
| 104 | "Connection: close\r\n\r\n")) | 65 | "Connection: close\r\n\r\n")) |
| 105 | t (tls/tls-connect host 443)] | 66 | t (tls/tls-connect host 443)] |
| @@ -113,7 +74,6 @@ | |||
| 113 | status (status-of resp) | 74 | status (status-of resp) |
| 114 | [_ payload] (str/split resp #"\r\n\r\n" 2)] | 75 | [_ payload] (str/split resp #"\r\n\r\n" 2)] |
| 115 | (if (and status (str/starts-with? status "2")) | 76 | (if (and status (str/starts-with? status "2")) |
| 116 | - (or (second (re-find #"\"url\"\s*:\s*\"([^\"]*)\"" (or payload ""))) | 77 | + (core/url-of payload) |
| 117 | - (throw (ex-info "The server took the picture but named no URL for it." {}))) | 78 | + (throw (ex-info (core/error-message (or status "no response") payload) {})))) |
| 118 | - (throw (ex-info (error-message (or status "no response") payload) {})))) | ||
| 119 | (finally (try (tls/tls-close t) (catch Exception _ nil))))))) | 79 | (finally (try (tls/tls-close t) (catch Exception _ nil))))))) |