nandi/frqpublic Fork
⑂ f4d18a5
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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>
nandi committed 2026-09-17T18:47:49-07:00 Browse files
f4d18a5 parent: c9d7b92
modified flutter/src/frq/main.cljd +21 -15
@@ -58,8 +58,10 @@
5858 [frq.replies :as replies]
5959 [frq.profile :as profile]
6060 [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]
6365 [frq.irc.mutate :as mutate]
6466 [frq.oauth.core :as oauth]
6567 [frq.oauth.handoff :as handoff]
@@ -993,22 +995,27 @@
993995 (discard! (:path a))))
994996
995997 (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.
9981004
9991005 The upload starts at once rather than at send, so by the time a line is
10001006 written the picture is usually already up. A failure lands in `error` like
10011007 any other and takes the attachment with it — there is nothing to send and
10021008 nothing to show."
1003- [path filename]
1009+ [picture]
10041010 (let [did (:did @cells/session)
10051011 host-name (str @cells/form-host)
1006- channel (str @cells/current)]
1012+ channel (str @cells/current)
1013+ path (:path picture)]
10071014 (reset! cells/error nil)
10081015 (clear-attachment!)
10091016 (reset! cells/attachment {:path path :status :uploading})
10101017 (try
1011- (let [url (await (upload/upload! host-name did channel path filename))]
1018+ (let [url (await (pictures/upload! host-name did channel picture))]
10121019 ;; Only if this is still the picture on screen: a reader who attached
10131020 ;; another, or cleared it, has said what they want, and an upload
10141021 ;; landing afterwards does not get to undo that.
@@ -1036,13 +1043,8 @@
10361043 to be ours — not the reader's own picture, sitting in their gallery."
10371044 []
10381045 (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)))
10461048 (catch Object e
10471049 (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e))))))
10481050
@@ -1359,7 +1361,10 @@
13591361 ;; difference is what is behind them: a face is a CDN URL the network
13601362 ;; will serve again, a picture in a message is whatever host a stranger
13611363 ;; 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))
13631368 :wide? wide?
13641369 ;; True where there is a pointer to hover with. Three Flutter targets
13651370 ;; compile this file now, so it is asked rather than assumed: a window on
@@ -1481,4 +1486,5 @@
14811486 (net-dart/install!)
14821487 (oauth-dart/install!)
14831488 (atproto-dart/install!)
1489+ (pictures-dart/install!)
14841490 (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 start998+ "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 is1005 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` like1006 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 and1007 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 (try1017 (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 attached1019 ;; 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 upload1020 ;; 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 (try1045 (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 e1048 (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 network1361 ;; 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 stranger1362 ;; 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 targets1369 ;; 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 on1370 ;; 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 @@
2929 [frq.oauth.web :as oauth-web]
3030 [frq.atproto.web :as atproto-web]
3131 ["dart:html" :as html]
32- [frq.hiccup :as hiccup]))
32+ [frq.hiccup :as hiccup]
33+ [frq.pictures.web :as pictures-web]))
3334
3435 (defn ^:async main []
3536 (app/bind!)
@@ -63,6 +64,11 @@
6364 ;; authorization server, and failing that was there a sign-in before today.
6465 ;; Either way what lands is a session in `frq.cells`, which is what
6566 ;; `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))
6672 (let [signed-in? (or (await (oauth-web/resume!)) (oauth-web/restore!))]
6773 (await (app/start!))
6874 ;; 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 what65 ;; 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 to74 ;; 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}))