A picture is bytes where there is no file, and the clipboard is one way in
`Could not read that picture: _Namespace` was dart:io's filesystem saying it is not there: the chooser handed back an XFile and `File.copy` tried to put it in the app's storage, which a browser does not have. So `frq.pictures` is the fifth seam, and what crosses it is a map with `:path` where there is a filesystem and `:bytes` where there is not. `frq.upload.core` already built the multipart body out of bytes, so only the transport and the chooser ever differed — the browser posts that same body over XMLHttpRequest, and reads `XFile.readAsBytes` instead of copying. The `mem://` key it invents is not a place: `frq.main` tracks the attachment by `:path` to know whether an upload that just landed is still the picture on screen, and `discard!` hands it to a delete that finds nothing. Both wanted a name, not a location. Ctrl-V falls out of the same shape. A browser fires `paste` with the picture in it, so listening costs nothing and lands in `attach!` by the same road the chooser takes. A paste carrying no image is left entirely alone — no preventDefault — because that is somebody pasting text into the composer. And `preview-url`, which is where the pictures in the backlog come from: a path on the targets that cache to disk, and the URL itself in a browser, which has an image cache already and gets the bytes through the proxy the avatars go through. A link in a message is a picture again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f4d18a5 parent: c9d7b92 modified
flutter/src/frq/main.cljd +21 -15 | @@ -58,8 +58,10 @@ | ||
| 58 | 58 | [frq.replies :as replies] |
| 59 | 59 | [frq.profile :as profile] |
| 60 | 60 | [frq.avatars.dart :as avatars] |
| 61 | - ["package:image_picker/image_picker.dart" :as picker] | |
| 62 | - [frq.upload.dart :as upload] | |
| 61 | + [frq.pictures :as pictures] | |
| 62 | + ;; The dart:io chooser and upload this entry point installs; | |
| 63 | + ;; `frq.main-web` installs the browser's. | |
| 64 | + [frq.pictures.dart :as pictures-dart] | |
| 63 | 65 | [frq.irc.mutate :as mutate] |
| 64 | 66 | [frq.oauth.core :as oauth] |
| 65 | 67 | [frq.oauth.handoff :as handoff] |
| @@ -993,22 +995,27 @@ | ||
| 993 | 995 | (discard! (:path a)))) |
| 994 | 996 | |
| 995 | 997 | (defn ^:async attach! |
| 996 | - "Hold the picture already copied to `path` against the next line, and start | |
| 997 | - its upload. | |
| 998 | + "Hold `picture` against the next line, and start its upload. | |
| 999 | + | |
| 1000 | + What `frq.pictures/pick!` answers with, or what a paste handed over: a map | |
| 1001 | + carrying `:path` where there is a filesystem, `:bytes` where there is not, | |
| 1002 | + and a name either way. `:path` is the key the attachment is tracked by even | |
| 1003 | + when it names nowhere. | |
| 998 | 1004 | |
| 999 | 1005 | The upload starts at once rather than at send, so by the time a line is |
| 1000 | 1006 | written the picture is usually already up. A failure lands in `error` like |
| 1001 | 1007 | any other and takes the attachment with it — there is nothing to send and |
| 1002 | 1008 | nothing to show." |
| 1003 | - [path filename] | |
| 1009 | + [picture] | |
| 1004 | 1010 | (let [did (:did @cells/session) |
| 1005 | 1011 | host-name (str @cells/form-host) |
| 1006 | - channel (str @cells/current)] | |
| 1012 | + channel (str @cells/current) | |
| 1013 | + path (:path picture)] | |
| 1007 | 1014 | (reset! cells/error nil) |
| 1008 | 1015 | (clear-attachment!) |
| 1009 | 1016 | (reset! cells/attachment {:path path :status :uploading}) |
| 1010 | 1017 | (try |
| 1011 | - (let [url (await (upload/upload! host-name did channel path filename))] | |
| 1018 | + (let [url (await (pictures/upload! host-name did channel picture))] | |
| 1012 | 1019 | ;; Only if this is still the picture on screen: a reader who attached |
| 1013 | 1020 | ;; another, or cleared it, has said what they want, and an upload |
| 1014 | 1021 | ;; landing afterwards does not get to undo that. |
| @@ -1036,13 +1043,8 @@ | ||
| 1036 | 1043 | to be ours — not the reader's own picture, sitting in their gallery." |
| 1037 | 1044 | [] |
| 1038 | 1045 | (try |
| 1039 | - (let [chosen (await (.pickImage (picker/ImagePicker) | |
| 1040 | - .source picker/ImageSource.gallery))] | |
| 1041 | - (when chosen | |
| 1042 | - (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now)) | |
| 1043 | - ".png")] | |
| 1044 | - (await (.copy (dio/File. (.-path chosen)) copy)) | |
| 1045 | - (await (attach! copy "picture.png"))))) | |
| 1046 | + (when-let [picture (await (pictures/pick! (outgoing-dir)))] | |
| 1047 | + (await (attach! picture))) | |
| 1046 | 1048 | (catch Object e |
| 1047 | 1049 | (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e)))))) |
| 1048 | 1050 | |
| @@ -1359,7 +1361,10 @@ | ||
| 1359 | 1361 | ;; difference is what is behind them: a face is a CDN URL the network |
| 1360 | 1362 | ;; will serve again, a picture in a message is whatever host a stranger |
| 1361 | 1363 | ;; put a link to. |
| 1362 | - :image-path (fn [url] (media/path-when-ready url)) | |
| 1364 | + ;; Through the seam: a path on the targets that cache to disk, and the | |
| 1365 | + ;; URL itself in a browser, which has its own image cache and nowhere to | |
| 1366 | + ;; put a file. See `frq.pictures/preview-url`. | |
| 1367 | + :image-path (fn [url] (pictures/preview-url url)) | |
| 1363 | 1368 | :wide? wide? |
| 1364 | 1369 | ;; True where there is a pointer to hover with. Three Flutter targets |
| 1365 | 1370 | ;; compile this file now, so it is asked rather than assumed: a window on |
| @@ -1481,4 +1486,5 @@ | ||
| 1481 | 1486 | (net-dart/install!) |
| 1482 | 1487 | (oauth-dart/install!) |
| 1483 | 1488 | (atproto-dart/install!) |
| 1489 | + (pictures-dart/install!) | |
| 1484 | 1490 | (await (start!))) |
| @@ -58,8 +58,10 @@ | |||
| 58 | [frq.replies :as replies] | 58 | [frq.replies :as replies] |
| 59 | [frq.profile :as profile] | 59 | [frq.profile :as profile] |
| 60 | [frq.avatars.dart :as avatars] | 60 | [frq.avatars.dart :as avatars] |
| 61 | - ["package:image_picker/image_picker.dart" :as picker] | 61 | + [frq.pictures :as pictures] |
| 62 | - [frq.upload.dart :as upload] | 62 | + ;; The dart:io chooser and upload this entry point installs; |
| 63 | + ;; `frq.main-web` installs the browser's. | ||
| 64 | + [frq.pictures.dart :as pictures-dart] | ||
| 63 | [frq.irc.mutate :as mutate] | 65 | [frq.irc.mutate :as mutate] |
| 64 | [frq.oauth.core :as oauth] | 66 | [frq.oauth.core :as oauth] |
| 65 | [frq.oauth.handoff :as handoff] | 67 | [frq.oauth.handoff :as handoff] |
| @@ -993,22 +995,27 @@ | |||
| 993 | (discard! (:path a)))) | 995 | (discard! (:path a)))) |
| 994 | 996 | ||
| 995 | (defn ^:async attach! | 997 | (defn ^:async attach! |
| 996 | - "Hold the picture already copied to `path` against the next line, and start | 998 | + "Hold `picture` against the next line, and start its upload. |
| 997 | - its upload. | 999 | + |
| 1000 | + What `frq.pictures/pick!` answers with, or what a paste handed over: a map | ||
| 1001 | + carrying `:path` where there is a filesystem, `:bytes` where there is not, | ||
| 1002 | + and a name either way. `:path` is the key the attachment is tracked by even | ||
| 1003 | + when it names nowhere. | ||
| 998 | 1004 | ||
| 999 | The upload starts at once rather than at send, so by the time a line is | 1005 | The upload starts at once rather than at send, so by the time a line is |
| 1000 | written the picture is usually already up. A failure lands in `error` like | 1006 | written the picture is usually already up. A failure lands in `error` like |
| 1001 | any other and takes the attachment with it — there is nothing to send and | 1007 | any other and takes the attachment with it — there is nothing to send and |
| 1002 | nothing to show." | 1008 | nothing to show." |
| 1003 | - [path filename] | 1009 | + [picture] |
| 1004 | (let [did (:did @cells/session) | 1010 | (let [did (:did @cells/session) |
| 1005 | host-name (str @cells/form-host) | 1011 | host-name (str @cells/form-host) |
| 1006 | - channel (str @cells/current)] | 1012 | + channel (str @cells/current) |
| 1013 | + path (:path picture)] | ||
| 1007 | (reset! cells/error nil) | 1014 | (reset! cells/error nil) |
| 1008 | (clear-attachment!) | 1015 | (clear-attachment!) |
| 1009 | (reset! cells/attachment {:path path :status :uploading}) | 1016 | (reset! cells/attachment {:path path :status :uploading}) |
| 1010 | (try | 1017 | (try |
| 1011 | - (let [url (await (upload/upload! host-name did channel path filename))] | 1018 | + (let [url (await (pictures/upload! host-name did channel picture))] |
| 1012 | ;; Only if this is still the picture on screen: a reader who attached | 1019 | ;; Only if this is still the picture on screen: a reader who attached |
| 1013 | ;; another, or cleared it, has said what they want, and an upload | 1020 | ;; another, or cleared it, has said what they want, and an upload |
| 1014 | ;; landing afterwards does not get to undo that. | 1021 | ;; landing afterwards does not get to undo that. |
| @@ -1036,13 +1043,8 @@ | |||
| 1036 | to be ours — not the reader's own picture, sitting in their gallery." | 1043 | to be ours — not the reader's own picture, sitting in their gallery." |
| 1037 | [] | 1044 | [] |
| 1038 | (try | 1045 | (try |
| 1039 | - (let [chosen (await (.pickImage (picker/ImagePicker) | 1046 | + (when-let [picture (await (pictures/pick! (outgoing-dir)))] |
| 1040 | - .source picker/ImageSource.gallery))] | 1047 | + (await (attach! picture))) |
| 1041 | - (when chosen | ||
| 1042 | - (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now)) | ||
| 1043 | - ".png")] | ||
| 1044 | - (await (.copy (dio/File. (.-path chosen)) copy)) | ||
| 1045 | - (await (attach! copy "picture.png"))))) | ||
| 1046 | (catch Object e | 1048 | (catch Object e |
| 1047 | (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e)))))) | 1049 | (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e)))))) |
| 1048 | 1050 | ||
| @@ -1359,7 +1361,10 @@ | |||
| 1359 | ;; difference is what is behind them: a face is a CDN URL the network | 1361 | ;; difference is what is behind them: a face is a CDN URL the network |
| 1360 | ;; will serve again, a picture in a message is whatever host a stranger | 1362 | ;; will serve again, a picture in a message is whatever host a stranger |
| 1361 | ;; put a link to. | 1363 | ;; put a link to. |
| 1362 | - :image-path (fn [url] (media/path-when-ready url)) | 1364 | + ;; Through the seam: a path on the targets that cache to disk, and the |
| 1365 | + ;; URL itself in a browser, which has its own image cache and nowhere to | ||
| 1366 | + ;; put a file. See `frq.pictures/preview-url`. | ||
| 1367 | + :image-path (fn [url] (pictures/preview-url url)) | ||
| 1363 | :wide? wide? | 1368 | :wide? wide? |
| 1364 | ;; True where there is a pointer to hover with. Three Flutter targets | 1369 | ;; True where there is a pointer to hover with. Three Flutter targets |
| 1365 | ;; compile this file now, so it is asked rather than assumed: a window on | 1370 | ;; compile this file now, so it is asked rather than assumed: a window on |
| @@ -1481,4 +1486,5 @@ | |||
| 1481 | (net-dart/install!) | 1486 | (net-dart/install!) |
| 1482 | (oauth-dart/install!) | 1487 | (oauth-dart/install!) |
| 1483 | (atproto-dart/install!) | 1488 | (atproto-dart/install!) |
| 1489 | + (pictures-dart/install!) | ||
| 1484 | (await (start!))) | 1490 | (await (start!))) |
modified
flutter/src/frq/main_web.cljd +7 -1 | @@ -29,7 +29,8 @@ | ||
| 29 | 29 | [frq.oauth.web :as oauth-web] |
| 30 | 30 | [frq.atproto.web :as atproto-web] |
| 31 | 31 | ["dart:html" :as html] |
| 32 | - [frq.hiccup :as hiccup])) | |
| 32 | + [frq.hiccup :as hiccup] | |
| 33 | + [frq.pictures.web :as pictures-web])) | |
| 33 | 34 | |
| 34 | 35 | (defn ^:async main [] |
| 35 | 36 | (app/bind!) |
| @@ -63,6 +64,11 @@ | ||
| 63 | 64 | ;; authorization server, and failing that was there a sign-in before today. |
| 64 | 65 | ;; Either way what lands is a session in `frq.cells`, which is what |
| 65 | 66 | ;; `frq.main/sign-in!` finds instead of starting the browser leg again. |
| 67 | + (pictures-web/install!) | |
| 68 | + ;; Ctrl-V. The other two targets have no clipboard to listen to, and this is | |
| 69 | + ;; the same attachment the chooser makes — `attach!` cannot tell which it | |
| 70 | + ;; was handed. | |
| 71 | + (pictures-web/listen-for-paste! (fn [picture] (app/attach! picture) nil)) | |
| 66 | 72 | (let [signed-in? (or (await (oauth-web/resume!)) (oauth-web/restore!))] |
| 67 | 73 | (await (app/start!)) |
| 68 | 74 | ;; A sign-in that has just completed should not then wait to be told to |
| @@ -29,7 +29,8 @@ | |||
| 29 | [frq.oauth.web :as oauth-web] | 29 | [frq.oauth.web :as oauth-web] |
| 30 | [frq.atproto.web :as atproto-web] | 30 | [frq.atproto.web :as atproto-web] |
| 31 | ["dart:html" :as html] | 31 | ["dart:html" :as html] |
| 32 | - [frq.hiccup :as hiccup])) | 32 | + [frq.hiccup :as hiccup] |
| 33 | + [frq.pictures.web :as pictures-web])) | ||
| 33 | 34 | ||
| 34 | (defn ^:async main [] | 35 | (defn ^:async main [] |
| 35 | (app/bind!) | 36 | (app/bind!) |
| @@ -63,6 +64,11 @@ | |||
| 63 | ;; authorization server, and failing that was there a sign-in before today. | 64 | ;; authorization server, and failing that was there a sign-in before today. |
| 64 | ;; Either way what lands is a session in `frq.cells`, which is what | 65 | ;; Either way what lands is a session in `frq.cells`, which is what |
| 65 | ;; `frq.main/sign-in!` finds instead of starting the browser leg again. | 66 | ;; `frq.main/sign-in!` finds instead of starting the browser leg again. |
| 67 | + (pictures-web/install!) | ||
| 68 | + ;; Ctrl-V. The other two targets have no clipboard to listen to, and this is | ||
| 69 | + ;; the same attachment the chooser makes — `attach!` cannot tell which it | ||
| 70 | + ;; was handed. | ||
| 71 | + (pictures-web/listen-for-paste! (fn [picture] (app/attach! picture) nil)) | ||
| 66 | (let [signed-in? (or (await (oauth-web/resume!)) (oauth-web/restore!))] | 72 | (let [signed-in? (or (await (oauth-web/resume!)) (oauth-web/restore!))] |
| 67 | (await (app/start!)) | 73 | (await (app/start!)) |
| 68 | ;; A sign-in that has just completed should not then wait to be told to | 74 | ;; A sign-in that has just completed should not then wait to be told to |
added
flutter/src/frq/pictures.cljd +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +(ns frq.pictures | |
| 2 | + "Choosing a picture and sending it, named once for both Flutter targets. | |
| 3 | + | |
| 4 | + The fifth seam, and it exists because a browser has no file. dart:io picks | |
| 5 | + one with the system chooser, copies it into the app's own storage and | |
| 6 | + uploads it by path; a browser is handed BYTES — from the chooser, or from | |
| 7 | + the clipboard — and there is no path to copy to. `File.copy` on the web | |
| 8 | + throws `Unsupported operation: _Namespace`, which is dart:io's filesystem | |
| 9 | + saying it is not there, and what the reader saw was `Could not read that | |
| 10 | + picture: _Namespace`. | |
| 11 | + | |
| 12 | + So a picture here is a map, and either half of it may be missing: `:path` | |
| 13 | + where there is a filesystem, `:bytes` where there is not, `:filename` | |
| 14 | + always. `frq.upload.core` already builds the multipart body out of bytes, so | |
| 15 | + only the transport and the chooser differ." | |
| 16 | + (:refer-clojure :exclude [name])) | |
| 17 | + | |
| 18 | +(defonce ^:private impl (atom {})) | |
| 19 | + | |
| 20 | +(defn install! [m] (swap! impl merge m) nil) | |
| 21 | + | |
| 22 | +(defn ^:async pick! | |
| 23 | + "Ask the reader for a picture. Nil when they chose none. | |
| 24 | + | |
| 25 | + `dir` is where a copy belongs on the targets that have one — it is the | |
| 26 | + caller's, because it comes from `frq.io/config-dir`, and the web backend | |
| 27 | + ignores it." | |
| 28 | + [dir] | |
| 29 | + (if-let [f (:pick! @impl)] | |
| 30 | + (await (f dir)) | |
| 31 | + (throw (ex-info "frq.pictures: no backend installed" {:op :pick!})))) | |
| 32 | + | |
| 33 | +(defn ^:async upload! | |
| 34 | + "Send `picture` and answer the URL freeq serves it back at." | |
| 35 | + [host did channel picture] | |
| 36 | + (if-let [f (:upload! @impl)] | |
| 37 | + (await (f host did channel picture)) | |
| 38 | + (throw (ex-info "frq.pictures: no backend installed" {:op :upload!})))) | |
| 39 | + | |
| 40 | +(defn preview-url | |
| 41 | + "What the renderer should draw for the picture at `url`, or nil while there | |
| 42 | + is nothing to draw yet. | |
| 43 | + | |
| 44 | + Two different answers, and the browser's is the simpler one. dart:io | |
| 45 | + downloads the picture into a cache and hands back a path, because a phone | |
| 46 | + wants the bytes on disk and `frq.media` is the thing that puts them there. | |
| 47 | + A browser already has an image loader with a cache in front of it, and no | |
| 48 | + disk to put anything on — so the answer is the URL, which | |
| 49 | + `frq.hiccup/src-url` then sends through the same proxy the avatars use. | |
| 50 | + | |
| 51 | + Synchronous, because the screens call it while they build a row and read nil | |
| 52 | + as \"not here yet\" — see `frq.media/path-when-ready`, which is the answer on | |
| 53 | + the other two targets." | |
| 54 | + [url] | |
| 55 | + (if-let [f (:preview-url @impl)] | |
| 56 | + (f url) | |
| 57 | + nil)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +(ns frq.pictures | ||
| 2 | + "Choosing a picture and sending it, named once for both Flutter targets. | ||
| 3 | + | ||
| 4 | + The fifth seam, and it exists because a browser has no file. dart:io picks | ||
| 5 | + one with the system chooser, copies it into the app's own storage and | ||
| 6 | + uploads it by path; a browser is handed BYTES — from the chooser, or from | ||
| 7 | + the clipboard — and there is no path to copy to. `File.copy` on the web | ||
| 8 | + throws `Unsupported operation: _Namespace`, which is dart:io's filesystem | ||
| 9 | + saying it is not there, and what the reader saw was `Could not read that | ||
| 10 | + picture: _Namespace`. | ||
| 11 | + | ||
| 12 | + So a picture here is a map, and either half of it may be missing: `:path` | ||
| 13 | + where there is a filesystem, `:bytes` where there is not, `:filename` | ||
| 14 | + always. `frq.upload.core` already builds the multipart body out of bytes, so | ||
| 15 | + only the transport and the chooser differ." | ||
| 16 | + (:refer-clojure :exclude [name])) | ||
| 17 | + | ||
| 18 | +(defonce ^:private impl (atom {})) | ||
| 19 | + | ||
| 20 | +(defn install! [m] (swap! impl merge m) nil) | ||
| 21 | + | ||
| 22 | +(defn ^:async pick! | ||
| 23 | + "Ask the reader for a picture. Nil when they chose none. | ||
| 24 | + | ||
| 25 | + `dir` is where a copy belongs on the targets that have one — it is the | ||
| 26 | + caller's, because it comes from `frq.io/config-dir`, and the web backend | ||
| 27 | + ignores it." | ||
| 28 | + [dir] | ||
| 29 | + (if-let [f (:pick! @impl)] | ||
| 30 | + (await (f dir)) | ||
| 31 | + (throw (ex-info "frq.pictures: no backend installed" {:op :pick!})))) | ||
| 32 | + | ||
| 33 | +(defn ^:async upload! | ||
| 34 | + "Send `picture` and answer the URL freeq serves it back at." | ||
| 35 | + [host did channel picture] | ||
| 36 | + (if-let [f (:upload! @impl)] | ||
| 37 | + (await (f host did channel picture)) | ||
| 38 | + (throw (ex-info "frq.pictures: no backend installed" {:op :upload!})))) | ||
| 39 | + | ||
| 40 | +(defn preview-url | ||
| 41 | + "What the renderer should draw for the picture at `url`, or nil while there | ||
| 42 | + is nothing to draw yet. | ||
| 43 | + | ||
| 44 | + Two different answers, and the browser's is the simpler one. dart:io | ||
| 45 | + downloads the picture into a cache and hands back a path, because a phone | ||
| 46 | + wants the bytes on disk and `frq.media` is the thing that puts them there. | ||
| 47 | + A browser already has an image loader with a cache in front of it, and no | ||
| 48 | + disk to put anything on — so the answer is the URL, which | ||
| 49 | + `frq.hiccup/src-url` then sends through the same proxy the avatars use. | ||
| 50 | + | ||
| 51 | + Synchronous, because the screens call it while they build a row and read nil | ||
| 52 | + as \"not here yet\" — see `frq.media/path-when-ready`, which is the answer on | ||
| 53 | + the other two targets." | ||
| 54 | + [url] | ||
| 55 | + (if-let [f (:preview-url @impl)] | ||
| 56 | + (f url) | ||
| 57 | + nil)) | ||
added
flutter/src/frq/pictures/dart.cljd +31 -0 | new file mode 100644 | ||
| @@ -0,0 +1,31 @@ | ||
| 1 | +(ns frq.pictures.dart | |
| 2 | + "Pictures over dart:io: the system chooser, a copy, and `frq.upload.dart`. | |
| 3 | + | |
| 4 | + What was in `frq.main` until the web needed the other half. Unchanged in | |
| 5 | + substance — the chooser hands back a grant for the one picture chosen, and | |
| 6 | + it is copied into our own storage because the send drops the file when it is | |
| 7 | + done with it, and what it drops has to be ours." | |
| 8 | + (:require ["dart:io" :as dio] | |
| 9 | + ["package:image_picker/image_picker.dart" :as picker] | |
| 10 | + [frq.media.dart :as media] | |
| 11 | + [frq.pictures :as pictures] | |
| 12 | + [frq.upload.dart :as upload])) | |
| 13 | + | |
| 14 | +(defn ^:async ^:private pick! [dir] | |
| 15 | + (let [chosen (await (.pickImage (picker/ImagePicker) | |
| 16 | + .source picker/ImageSource.gallery))] | |
| 17 | + (when chosen | |
| 18 | + (let [copy (str dir "/" (.-millisecondsSinceEpoch (DateTime/now)) ".png")] | |
| 19 | + (await (.copy (dio/File. (.-path chosen)) copy)) | |
| 20 | + {:path copy :filename "picture.png"})))) | |
| 21 | + | |
| 22 | +(defn ^:async ^:private upload! [host did channel picture] | |
| 23 | + (await (upload/upload! host did channel (:path picture) | |
| 24 | + (or (:filename picture) "picture.png")))) | |
| 25 | + | |
| 26 | +(defn install! [] | |
| 27 | + (pictures/install! {:pick! pick! | |
| 28 | + :upload! upload! | |
| 29 | + ;; The cache does the work; this is only where the | |
| 30 | + ;; screens ask whether it has finished. | |
| 31 | + :preview-url media/path-when-ready})) | |
| new file mode 100644 | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | +(ns frq.pictures.dart | ||
| 2 | + "Pictures over dart:io: the system chooser, a copy, and `frq.upload.dart`. | ||
| 3 | + | ||
| 4 | + What was in `frq.main` until the web needed the other half. Unchanged in | ||
| 5 | + substance — the chooser hands back a grant for the one picture chosen, and | ||
| 6 | + it is copied into our own storage because the send drops the file when it is | ||
| 7 | + done with it, and what it drops has to be ours." | ||
| 8 | + (:require ["dart:io" :as dio] | ||
| 9 | + ["package:image_picker/image_picker.dart" :as picker] | ||
| 10 | + [frq.media.dart :as media] | ||
| 11 | + [frq.pictures :as pictures] | ||
| 12 | + [frq.upload.dart :as upload])) | ||
| 13 | + | ||
| 14 | +(defn ^:async ^:private pick! [dir] | ||
| 15 | + (let [chosen (await (.pickImage (picker/ImagePicker) | ||
| 16 | + .source picker/ImageSource.gallery))] | ||
| 17 | + (when chosen | ||
| 18 | + (let [copy (str dir "/" (.-millisecondsSinceEpoch (DateTime/now)) ".png")] | ||
| 19 | + (await (.copy (dio/File. (.-path chosen)) copy)) | ||
| 20 | + {:path copy :filename "picture.png"})))) | ||
| 21 | + | ||
| 22 | +(defn ^:async ^:private upload! [host did channel picture] | ||
| 23 | + (await (upload/upload! host did channel (:path picture) | ||
| 24 | + (or (:filename picture) "picture.png")))) | ||
| 25 | + | ||
| 26 | +(defn install! [] | ||
| 27 | + (pictures/install! {:pick! pick! | ||
| 28 | + :upload! upload! | ||
| 29 | + ;; The cache does the work; this is only where the | ||
| 30 | + ;; screens ask whether it has finished. | ||
| 31 | + :preview-url media/path-when-ready})) | ||
added
flutter/src/frq/pictures/web.cljd +133 -0 | new file mode 100644 | ||
| @@ -0,0 +1,133 @@ | ||
| 1 | +(ns frq.pictures.web | |
| 2 | + "Pictures in a browser: bytes from the chooser or the clipboard, and an | |
| 3 | + XMLHttpRequest to carry them. | |
| 4 | + | |
| 5 | + No copy and no path, because there is no filesystem to copy to — that is the | |
| 6 | + whole of the difference, and `File.copy` saying `_Namespace` is dart:io | |
| 7 | + telling us so. The chooser hands back an `XFile` over a blob URL; what is | |
| 8 | + useful about it is `readAsBytes`. | |
| 9 | + | |
| 10 | + And the clipboard, which the other targets have no equivalent for: a browser | |
| 11 | + fires `paste` with the picture in it, so Ctrl-V is a way to attach one that | |
| 12 | + costs nothing but listening." | |
| 13 | + (:require ["dart:async" :as async] | |
| 14 | + ["dart:html" :as html] | |
| 15 | + ["dart:typed_data" :as td] | |
| 16 | + ["package:image_picker/image_picker.dart" :as picker] | |
| 17 | + [frq.pictures :as pictures] | |
| 18 | + [frq.upload.core :as core])) | |
| 19 | + | |
| 20 | +(defn- ^td/Uint8List byte-list | |
| 21 | + "A cljd vector of byte values as the typed list Dart wants. | |
| 22 | + | |
| 23 | + Built element by element rather than handed over: what | |
| 24 | + `frq.upload.core/request` answers with is a PersistentVector, and cljd does | |
| 25 | + not bridge one to a `List<int>` — `frq.upload.dart` walks into a BytesBuilder | |
| 26 | + for the same reason, and got \"PersistentVector<dynamic> is not a subtype of | |
| 27 | + List<int>\" on screen before it did." | |
| 28 | + [bs] | |
| 29 | + (let [n (count bs) | |
| 30 | + out (td/Uint8List. n)] | |
| 31 | + (loop [s (seq bs) i 0] | |
| 32 | + (if (nil? s) | |
| 33 | + out | |
| 34 | + (do (aset out i (first s)) | |
| 35 | + (recur (next s) (inc i))))))) | |
| 36 | + | |
| 37 | +(defn ^:async ^:private upload! [host did channel picture] | |
| 38 | + (let [{p :path ct :content-type body :body} | |
| 39 | + (core/request did channel (or (:filename picture) "picture.png") | |
| 40 | + (vec (:bytes picture))) | |
| 41 | + url (str "https://" host p) | |
| 42 | + req (html/HttpRequest.) | |
| 43 | + done (async/Completer.)] | |
| 44 | + (.open req "POST" url) | |
| 45 | + (.setRequestHeader req "accept" "application/json") | |
| 46 | + (.setRequestHeader req "content-type" ct) | |
| 47 | + (.addEventListener req "load" | |
| 48 | + (fn [_] | |
| 49 | + (when-not (.-isCompleted done) | |
| 50 | + (let [status (.-status req) | |
| 51 | + payload (str (.-responseText req))] | |
| 52 | + (if (and (>= status 200) (< status 300)) | |
| 53 | + (.complete done (core/url-of payload)) | |
| 54 | + (.completeError | |
| 55 | + done | |
| 56 | + (Exception. (core/error-message (str status) | |
| 57 | + payload)))))) | |
| 58 | + nil)) | |
| 59 | + (.addEventListener req "error" | |
| 60 | + (fn [_] | |
| 61 | + (when-not (.-isCompleted done) | |
| 62 | + (.completeError | |
| 63 | + done (Exception. (str "Could not reach " host)))) | |
| 64 | + nil)) | |
| 65 | + (.send req (byte-list body)) | |
| 66 | + (await (.-future done)))) | |
| 67 | + | |
| 68 | +(defn ^:async ^:private pick! [_dir] | |
| 69 | + (let [chosen (await (.pickImage (picker/ImagePicker) | |
| 70 | + .source picker/ImageSource.gallery))] | |
| 71 | + (when chosen | |
| 72 | + (let [bytes (await (.readAsBytes chosen))] | |
| 73 | + ;; `:path` is a name and not a place: `frq.main` keys the attachment on | |
| 74 | + ;; it — to know whether the upload that just landed is still the | |
| 75 | + ;; picture on screen — and `discard!` hands it to `frq.io/delete-file!`, | |
| 76 | + ;; which on the web is a localStorage key that was never written. So it | |
| 77 | + ;; has to be unique and it has to be harmless, and this is both. | |
| 78 | + {:path (str "mem://" (.-millisecondsSinceEpoch (DateTime/now)) ".png") | |
| 79 | + :bytes (vec bytes) | |
| 80 | + :filename (or (.-name chosen) "picture.png")})))) | |
| 81 | + | |
| 82 | +(defn ^:async ^:private blob-bytes | |
| 83 | + "One clipboard file's bytes." | |
| 84 | + [blob] | |
| 85 | + (let [reader (html/FileReader.) | |
| 86 | + done (async/Completer.)] | |
| 87 | + (.addEventListener reader "loadend" | |
| 88 | + (fn [_] | |
| 89 | + (when-not (.-isCompleted done) | |
| 90 | + (.complete done (.-result reader))) | |
| 91 | + nil)) | |
| 92 | + (.readAsArrayBuffer reader blob) | |
| 93 | + (await (.-future done)))) | |
| 94 | + | |
| 95 | +(defn listen-for-paste! | |
| 96 | + "Ctrl-V, as a way to attach a picture. | |
| 97 | + | |
| 98 | + `on-picture` is called with the same map `pick!` answers with, so the paste | |
| 99 | + and the chooser land in exactly the same place. | |
| 100 | + | |
| 101 | + A paste carrying no picture is left entirely alone — no preventDefault, no | |
| 102 | + error — because it is somebody pasting text into the composer, which is the | |
| 103 | + overwhelmingly common case and must keep working." | |
| 104 | + [on-picture] | |
| 105 | + (.addEventListener | |
| 106 | + html/document "paste" | |
| 107 | + (fn [e] | |
| 108 | + (let [data (.-clipboardData e) | |
| 109 | + files (when data (.-files data))] | |
| 110 | + (when (and files (pos? (.-length files))) | |
| 111 | + (let [f (.item files 0)] | |
| 112 | + (when (.startsWith (str (.-type f)) "image/") | |
| 113 | + (.preventDefault e) | |
| 114 | + (.then ^async/Future (blob-bytes f) | |
| 115 | + (fn [buf] | |
| 116 | + (on-picture | |
| 117 | + {:path (str "mem://" (.-millisecondsSinceEpoch (DateTime/now)) | |
| 118 | + ".png") | |
| 119 | + :bytes (vec (td/Uint8List.view buf)) | |
| 120 | + :filename (or (.-name f) "paste.png")}) | |
| 121 | + nil)))))) | |
| 122 | + nil)) | |
| 123 | + nil) | |
| 124 | + | |
| 125 | +(defn install! [] | |
| 126 | + (pictures/install! {:pick! pick! | |
| 127 | + :upload! upload! | |
| 128 | + ;; No download and no cache: the browser has both, and | |
| 129 | + ;; `frq.hiccup/src-url` puts the URL through the proxy so | |
| 130 | + ;; the bytes are allowed in. A picture is shown the | |
| 131 | + ;; moment its link is on screen rather than after a | |
| 132 | + ;; round trip this target never needed to make. | |
| 133 | + :preview-url identity})) | |
| new file mode 100644 | |||
| @@ -0,0 +1,133 @@ | |||
| 1 | +(ns frq.pictures.web | ||
| 2 | + "Pictures in a browser: bytes from the chooser or the clipboard, and an | ||
| 3 | + XMLHttpRequest to carry them. | ||
| 4 | + | ||
| 5 | + No copy and no path, because there is no filesystem to copy to — that is the | ||
| 6 | + whole of the difference, and `File.copy` saying `_Namespace` is dart:io | ||
| 7 | + telling us so. The chooser hands back an `XFile` over a blob URL; what is | ||
| 8 | + useful about it is `readAsBytes`. | ||
| 9 | + | ||
| 10 | + And the clipboard, which the other targets have no equivalent for: a browser | ||
| 11 | + fires `paste` with the picture in it, so Ctrl-V is a way to attach one that | ||
| 12 | + costs nothing but listening." | ||
| 13 | + (:require ["dart:async" :as async] | ||
| 14 | + ["dart:html" :as html] | ||
| 15 | + ["dart:typed_data" :as td] | ||
| 16 | + ["package:image_picker/image_picker.dart" :as picker] | ||
| 17 | + [frq.pictures :as pictures] | ||
| 18 | + [frq.upload.core :as core])) | ||
| 19 | + | ||
| 20 | +(defn- ^td/Uint8List byte-list | ||
| 21 | + "A cljd vector of byte values as the typed list Dart wants. | ||
| 22 | + | ||
| 23 | + Built element by element rather than handed over: what | ||
| 24 | + `frq.upload.core/request` answers with is a PersistentVector, and cljd does | ||
| 25 | + not bridge one to a `List<int>` — `frq.upload.dart` walks into a BytesBuilder | ||
| 26 | + for the same reason, and got \"PersistentVector<dynamic> is not a subtype of | ||
| 27 | + List<int>\" on screen before it did." | ||
| 28 | + [bs] | ||
| 29 | + (let [n (count bs) | ||
| 30 | + out (td/Uint8List. n)] | ||
| 31 | + (loop [s (seq bs) i 0] | ||
| 32 | + (if (nil? s) | ||
| 33 | + out | ||
| 34 | + (do (aset out i (first s)) | ||
| 35 | + (recur (next s) (inc i))))))) | ||
| 36 | + | ||
| 37 | +(defn ^:async ^:private upload! [host did channel picture] | ||
| 38 | + (let [{p :path ct :content-type body :body} | ||
| 39 | + (core/request did channel (or (:filename picture) "picture.png") | ||
| 40 | + (vec (:bytes picture))) | ||
| 41 | + url (str "https://" host p) | ||
| 42 | + req (html/HttpRequest.) | ||
| 43 | + done (async/Completer.)] | ||
| 44 | + (.open req "POST" url) | ||
| 45 | + (.setRequestHeader req "accept" "application/json") | ||
| 46 | + (.setRequestHeader req "content-type" ct) | ||
| 47 | + (.addEventListener req "load" | ||
| 48 | + (fn [_] | ||
| 49 | + (when-not (.-isCompleted done) | ||
| 50 | + (let [status (.-status req) | ||
| 51 | + payload (str (.-responseText req))] | ||
| 52 | + (if (and (>= status 200) (< status 300)) | ||
| 53 | + (.complete done (core/url-of payload)) | ||
| 54 | + (.completeError | ||
| 55 | + done | ||
| 56 | + (Exception. (core/error-message (str status) | ||
| 57 | + payload)))))) | ||
| 58 | + nil)) | ||
| 59 | + (.addEventListener req "error" | ||
| 60 | + (fn [_] | ||
| 61 | + (when-not (.-isCompleted done) | ||
| 62 | + (.completeError | ||
| 63 | + done (Exception. (str "Could not reach " host)))) | ||
| 64 | + nil)) | ||
| 65 | + (.send req (byte-list body)) | ||
| 66 | + (await (.-future done)))) | ||
| 67 | + | ||
| 68 | +(defn ^:async ^:private pick! [_dir] | ||
| 69 | + (let [chosen (await (.pickImage (picker/ImagePicker) | ||
| 70 | + .source picker/ImageSource.gallery))] | ||
| 71 | + (when chosen | ||
| 72 | + (let [bytes (await (.readAsBytes chosen))] | ||
| 73 | + ;; `:path` is a name and not a place: `frq.main` keys the attachment on | ||
| 74 | + ;; it — to know whether the upload that just landed is still the | ||
| 75 | + ;; picture on screen — and `discard!` hands it to `frq.io/delete-file!`, | ||
| 76 | + ;; which on the web is a localStorage key that was never written. So it | ||
| 77 | + ;; has to be unique and it has to be harmless, and this is both. | ||
| 78 | + {:path (str "mem://" (.-millisecondsSinceEpoch (DateTime/now)) ".png") | ||
| 79 | + :bytes (vec bytes) | ||
| 80 | + :filename (or (.-name chosen) "picture.png")})))) | ||
| 81 | + | ||
| 82 | +(defn ^:async ^:private blob-bytes | ||
| 83 | + "One clipboard file's bytes." | ||
| 84 | + [blob] | ||
| 85 | + (let [reader (html/FileReader.) | ||
| 86 | + done (async/Completer.)] | ||
| 87 | + (.addEventListener reader "loadend" | ||
| 88 | + (fn [_] | ||
| 89 | + (when-not (.-isCompleted done) | ||
| 90 | + (.complete done (.-result reader))) | ||
| 91 | + nil)) | ||
| 92 | + (.readAsArrayBuffer reader blob) | ||
| 93 | + (await (.-future done)))) | ||
| 94 | + | ||
| 95 | +(defn listen-for-paste! | ||
| 96 | + "Ctrl-V, as a way to attach a picture. | ||
| 97 | + | ||
| 98 | + `on-picture` is called with the same map `pick!` answers with, so the paste | ||
| 99 | + and the chooser land in exactly the same place. | ||
| 100 | + | ||
| 101 | + A paste carrying no picture is left entirely alone — no preventDefault, no | ||
| 102 | + error — because it is somebody pasting text into the composer, which is the | ||
| 103 | + overwhelmingly common case and must keep working." | ||
| 104 | + [on-picture] | ||
| 105 | + (.addEventListener | ||
| 106 | + html/document "paste" | ||
| 107 | + (fn [e] | ||
| 108 | + (let [data (.-clipboardData e) | ||
| 109 | + files (when data (.-files data))] | ||
| 110 | + (when (and files (pos? (.-length files))) | ||
| 111 | + (let [f (.item files 0)] | ||
| 112 | + (when (.startsWith (str (.-type f)) "image/") | ||
| 113 | + (.preventDefault e) | ||
| 114 | + (.then ^async/Future (blob-bytes f) | ||
| 115 | + (fn [buf] | ||
| 116 | + (on-picture | ||
| 117 | + {:path (str "mem://" (.-millisecondsSinceEpoch (DateTime/now)) | ||
| 118 | + ".png") | ||
| 119 | + :bytes (vec (td/Uint8List.view buf)) | ||
| 120 | + :filename (or (.-name f) "paste.png")}) | ||
| 121 | + nil)))))) | ||
| 122 | + nil)) | ||
| 123 | + nil) | ||
| 124 | + | ||
| 125 | +(defn install! [] | ||
| 126 | + (pictures/install! {:pick! pick! | ||
| 127 | + :upload! upload! | ||
| 128 | + ;; No download and no cache: the browser has both, and | ||
| 129 | + ;; `frq.hiccup/src-url` puts the URL through the proxy so | ||
| 130 | + ;; the bytes are allowed in. A picture is shown the | ||
| 131 | + ;; moment its link is on screen rather than after a | ||
| 132 | + ;; round trip this target never needed to make. | ||
| 133 | + :preview-url identity})) | ||