nandi/frqpublic Fork 0
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.

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}))