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

Send a picture by choosing one

Pasting is the only way in at the moment, and Android has neither Ctrl+V nor a
clipboard of pictures to read — so the compose bar gets a picker beside the
entry: a screen that browses directories and paints every PNG in one as its own
button.

The upload half of the paste is now shared: a picked file is copied into
outgoing/ first, so the send's cleanup drops our copy rather than the reader's
own picture. What the picker can see on a phone is whatever the app reads
without a runtime permission this activity has no code to ask for; an
unreadable directory says so instead of throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T16:43:42-07:00 Browse files
cab7487 parent: 3c98eb0
modified src/frq/app.jolt +63 -3
@@ -519,6 +519,61 @@
519519 [:button {:label " Back" :on-click #(reset! s/lightbox nil)}]]
520520 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
521521
522+(defn image-picker-screen
523+ "The pictures on this device, to send one of.
524+
525+ A screen rather than a panel over the compose bar: choosing a file is
526+ browsing, and browsing wants the window the backend paints in one layer
527+ anyway, so there is no overlay to put it in.
528+
529+ A directory at a time, PNG only. Which directories there are to start from is
530+ the platform's answer, not this screen's: a desktop opens on ~/Pictures, and
531+ a phone on what the app can read without a permission it has no code to ask
532+ for, which may be very little. Either way the list says what it found."
533+ []
534+ (let [dir @s/image-picker
535+ {:keys [dirs files]} (s/picker-entries dir)
536+ up (s/parent-dir dir)]
537+ [:vbox {:spacing 8 :margin 12}
538+ [:hbox {:spacing 8}
539+ [:button {:label " Back" :on-click s/close-image-picker!}]
540+ [:title {:label "Send a picture"}]]
541+ [:dim-label {:label (str dir)}]
542+ [error-note]
543+ ;; The places worth starting from, always in reach: browsing into a corner
544+ ;; of the filesystem should not cost the way back to the pictures folder.
545+ [:hbox {:key :roots :spacing 6}
546+ (for [root (s/picker-roots)]
547+ [:button {:key root
548+ :label (or (last (str/split root #"/")) root)
549+ :kind (if (= root dir) :primary :default)
550+ :on-click #(s/browse! root)}])]
551+ [:separator {}]
552+ [:scroll {:scroll-key "image-picker"
553+ :orientation :vertical
554+ :reserve 40}
555+ [:vbox {:spacing 6}
556+ [:vbox {:key :up}
557+ (when up
558+ [:button {:label " Up" :on-click #(s/browse! up)}])]
559+ [:vbox {:key :dirs :spacing 4}
560+ (for [d dirs]
561+ [:button {:key d
562+ :label (str "📁 " (last (str/split d #"/")))
563+ :on-click #(s/browse! d)}])]
564+ ;; The picture itself is the button: a filename is not what anyone is
565+ ;; choosing between, and a thumbnail answers "is this the one" in a way
566+ ;; no name does.
567+ [:vbox {:key :files :spacing 6}
568+ (for [f files]
569+ [:hbox {:key f :spacing 8}
570+ [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}]
571+ [:button {:label (last (str/split f #"/"))
572+ :on-click #(s/pick-image! f)}]])]
573+ [:vbox {:key :empty}
574+ (when (and (empty? dirs) (empty? files))
575+ [:dim-label {:label "No pictures here that this app can read."}])]]]]))
576+
522577 (defn chat-screen []
523578 (let [name @s/current
524579 buffer (get @s/channels name)]
@@ -594,8 +649,11 @@
594649 ;; `:on-paste-empty` a keystroke the field had nothing to put in
595650 ;; itself, which is exactly the one that means "the clipboard has
596651 ;; something else on it".
652+ ;; And the same picture chosen rather than pasted, for a phone which has
653+ ;; no Ctrl+V, and no clipboard of pictures to read if it had.
654+ [:button {:label "🖼" :on-click s/open-image-picker!}]
597655 [:entry {:text @s/draft
598- :width-request 300
656+ :width-request 260
599657 :placeholder "Message"
600658 :on-change #(reset! s/draft %)
601659 :on-paste-empty s/paste-image!
@@ -658,8 +716,10 @@
658716 ;; ---------------------------------------------------------------- shell
659717
660718 (defn app []
661- (if @s/lightbox
662- [lightbox-screen]
719+ (cond
720+ @s/lightbox [lightbox-screen]
721+ @s/image-picker [image-picker-screen]
722+ :else
663723 (case @s/screen
664724 :connect [connect-screen]
665725 :chat [chat-screen]
@@ -519,6 +519,61 @@
519 [:button {:label " Back" :on-click #(reset! s/lightbox nil)}]]519 [:button {:label " Back" :on-click #(reset! s/lightbox nil)}]]
520 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))520 [:image {:src path :fit true :on-click #(reset! s/lightbox nil)}]]))
521 521
522+(defn image-picker-screen
523+ "The pictures on this device, to send one of.
524+
525+ A screen rather than a panel over the compose bar: choosing a file is
526+ browsing, and browsing wants the window the backend paints in one layer
527+ anyway, so there is no overlay to put it in.
528+
529+ A directory at a time, PNG only. Which directories there are to start from is
530+ the platform's answer, not this screen's: a desktop opens on ~/Pictures, and
531+ a phone on what the app can read without a permission it has no code to ask
532+ for, which may be very little. Either way the list says what it found."
533+ []
534+ (let [dir @s/image-picker
535+ {:keys [dirs files]} (s/picker-entries dir)
536+ up (s/parent-dir dir)]
537+ [:vbox {:spacing 8 :margin 12}
538+ [:hbox {:spacing 8}
539+ [:button {:label " Back" :on-click s/close-image-picker!}]
540+ [:title {:label "Send a picture"}]]
541+ [:dim-label {:label (str dir)}]
542+ [error-note]
543+ ;; The places worth starting from, always in reach: browsing into a corner
544+ ;; of the filesystem should not cost the way back to the pictures folder.
545+ [:hbox {:key :roots :spacing 6}
546+ (for [root (s/picker-roots)]
547+ [:button {:key root
548+ :label (or (last (str/split root #"/")) root)
549+ :kind (if (= root dir) :primary :default)
550+ :on-click #(s/browse! root)}])]
551+ [:separator {}]
552+ [:scroll {:scroll-key "image-picker"
553+ :orientation :vertical
554+ :reserve 40}
555+ [:vbox {:spacing 6}
556+ [:vbox {:key :up}
557+ (when up
558+ [:button {:label " Up" :on-click #(s/browse! up)}])]
559+ [:vbox {:key :dirs :spacing 4}
560+ (for [d dirs]
561+ [:button {:key d
562+ :label (str "📁 " (last (str/split d #"/")))
563+ :on-click #(s/browse! d)}])]
564+ ;; The picture itself is the button: a filename is not what anyone is
565+ ;; choosing between, and a thumbnail answers "is this the one" in a way
566+ ;; no name does.
567+ [:vbox {:key :files :spacing 6}
568+ (for [f files]
569+ [:hbox {:key f :spacing 8}
570+ [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}]
571+ [:button {:label (last (str/split f #"/"))
572+ :on-click #(s/pick-image! f)}]])]
573+ [:vbox {:key :empty}
574+ (when (and (empty? dirs) (empty? files))
575+ [:dim-label {:label "No pictures here that this app can read."}])]]]]))
576+
522 (defn chat-screen []577 (defn chat-screen []
523 (let [name @s/current578 (let [name @s/current
524 buffer (get @s/channels name)]579 buffer (get @s/channels name)]
@@ -594,8 +649,11 @@
594 ;; `:on-paste-empty` a keystroke the field had nothing to put in649 ;; `:on-paste-empty` a keystroke the field had nothing to put in
595 ;; itself, which is exactly the one that means "the clipboard has650 ;; itself, which is exactly the one that means "the clipboard has
596 ;; something else on it".651 ;; something else on it".
652+ ;; And the same picture chosen rather than pasted, for a phone which has
653+ ;; no Ctrl+V, and no clipboard of pictures to read if it had.
654+ [:button {:label "🖼" :on-click s/open-image-picker!}]
597 [:entry {:text @s/draft655 [:entry {:text @s/draft
598- :width-request 300656+ :width-request 260
599 :placeholder "Message"657 :placeholder "Message"
600 :on-change #(reset! s/draft %)658 :on-change #(reset! s/draft %)
601 :on-paste-empty s/paste-image!659 :on-paste-empty s/paste-image!
@@ -658,8 +716,10 @@
658 ;; ---------------------------------------------------------------- shell716 ;; ---------------------------------------------------------------- shell
659 717
660 (defn app []718 (defn app []
661- (if @s/lightbox719+ (cond
662- [lightbox-screen]720+ @s/lightbox [lightbox-screen]
721+ @s/image-picker [image-picker-screen]
722+ :else
663 (case @s/screen723 (case @s/screen
664 :connect [connect-screen]724 :connect [connect-screen]
665 :chat [chat-screen]725 :chat [chat-screen]
modified src/frq/state.jolt +129 -26
@@ -559,6 +559,39 @@
559559 (reset! attachment nil)
560560 (discard-file! (:path a))))
561561
562+(defn- attach!
563+ "Hold the picture already written to `path` — a copy of ours under
564+ `outgoing/` against the next line, and start its upload.
565+
566+ The upload runs off the UI thread and starts at once rather than at send, so
567+ by the time a line is written the picture is usually already up. A failure
568+ lands in `error` like any other, and takes the attachment with it there is
569+ nothing to send and nothing to show.
570+
571+ `filename` is what the server files it under; it says which gesture the
572+ picture came in by, and nothing else depends on it."
573+ [path filename]
574+ (let [did (:did @session)
575+ host-name @form-host
576+ channel @current]
577+ (reset! error nil)
578+ (clear-attachment!)
579+ (reset! attachment {:path path :status :uploading})
580+ (future
581+ (try
582+ (let [url (upload/upload! host-name did channel path filename)]
583+ ;; Only if this is still the picture on screen: a reader who attached
584+ ;; another, or cleared it, has said what they want, and an upload
585+ ;; landing afterwards does not get to undo that.
586+ (swap! attachment #(if (= (:path %) path)
587+ (assoc % :url url :status :ready)
588+ %))
589+ (when-not (= (:path @attachment) path) (discard-file! path)))
590+ (catch Exception e
591+ (swap! attachment #(if (= (:path %) path) nil %))
592+ (discard-file! path)
593+ (reset! error (or (ex-message e) (str e))))))))
594+
562595 (defn paste-image!
563596 "Take the picture on the clipboard and hold it against the next line.
564597
@@ -566,37 +599,107 @@
566599 means but that is a fact about the wire, not something the reader should
567600 have to type around. The picture is attached: shown under the draft while
568601 they write whatever they are sending it with, and turned into a link only on
569- the way out.
570-
571- The upload runs off the UI thread and starts at once rather than at send, so
572- by the time a line is written the picture is usually already up. A failure
573- lands in `error` like any other, and takes the attachment with it there is
574- nothing to send and nothing to show."
602+ the way out."
575603 []
576604 (let [path (paste-path)]
577605 (host/mkdirs! (str (media/cache-dir) "/outgoing"))
578606 (if-not (vidya/clipboard-image-png! path)
607+ ;; Android has no clipboard of pictures to read at all, which is the
608+ ;; other half of why the picker below exists.
579609 (reset! error "No picture on the clipboard.")
580- (let [did (:did @session)
581- host-name @form-host
582- channel @current]
583- (reset! error nil)
584- (clear-attachment!)
585- (reset! attachment {:path path :status :uploading})
586- (future
587- (try
588- (let [url (upload/upload! host-name did channel path "paste.png")]
589- ;; Only if this is still the paste on screen: a reader who
590- ;; pasted again, or cleared it, has said what they want, and an
591- ;; upload landing afterwards does not get to undo that.
592- (swap! attachment #(if (= (:path %) path)
593- (assoc % :url url :status :ready)
594- %))
595- (when-not (= (:path @attachment) path) (discard-file! path)))
596- (catch Exception e
597- (swap! attachment #(if (= (:path %) path) nil %))
598- (discard-file! path)
599- (reset! error (or (ex-message e) (str e))))))))))
610+ (attach! path "paste.png"))))
611+
612+;; ------------------------------------------------------------------ picking
613+
614+;; Where the picker is looking, or nil when it is closed. A path, so the
615+;; browsing is just this cell moving.
616+(defonce image-picker (atom nil))
617+
618+(defn- readable-dir? [path]
619+ (try (and (host/file-exists? path) (host/directory? path))
620+ (catch Exception _ false)))
621+
622+(defn picker-roots
623+ "The places worth opening the picker on, on whichever platform this is.
624+
625+ Only the ones that are actually there: a phone has no ~/Pictures and a
626+ desktop no /sdcard, and a list of directories that are not there is a list of
627+ dead ends. On Android everything outside the app's own storage is behind a
628+ runtime permission this activity has no code to ask for, so what survives
629+ this filter there is usually the app's own files — which is the honest
630+ answer, not a bug to paper over."
631+ []
632+ (let [home (or (host/getenv "HOME") "")
633+ under (fn [base] (when (seq base)
634+ (map #(str base "/" %)
635+ ["Pictures" "Downloads" "Download" "DCIM"])))]
636+ (vec (distinct (filter readable-dir?
637+ (concat (under home)
638+ (under "/sdcard")
639+ (under "/storage/emulated/0")
640+ [(media/cache-dir) home]))))))
641+
642+(defn- png? [name]
643+ (str/ends-with? (str/lower-case (str name)) ".png"))
644+
645+(defn picker-entries
646+ "What `dir` holds, as `{:dirs [...] :files [...]}` of full paths.
647+
648+ PNG only, for the same reason the media cache reads PNG only: it is what the
649+ tree backend paints and what the upload sends. An unreadable directory
650+ which on Android is most of them answers empty rather than throwing.
651+
652+ Hidden entries are left out: nothing a reader means to send lives in one, and
653+ a home directory is unusable as a list with them in it."
654+ [dir]
655+ (let [names (try (sort (host/list-dir dir)) (catch Exception _ nil))
656+ keep (remove #(str/starts-with? (str %) ".") names)
657+ path (fn [n] (str dir "/" n))]
658+ {:dirs (vec (filter readable-dir? (map path keep)))
659+ :files (vec (map path (filter png? keep)))}))
660+
661+(defn parent-dir
662+ "The directory above `dir`, or nil at the top."
663+ [dir]
664+ (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
665+ (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
666+
667+(defn open-image-picker!
668+ "Open the picker, on the first place there is to look."
669+ []
670+ (reset! error nil)
671+ (reset! image-picker (or (first (picker-roots)) "/")))
672+
673+(defn close-image-picker! [] (reset! image-picker nil))
674+
675+(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir)))
676+
677+(defn- copy-file!
678+ "Copy `from` to `to`, byte for byte."
679+ [from to]
680+ (let [in (java.io.FileInputStream. from)]
681+ (try
682+ (let [out (java.io.FileOutputStream. to)]
683+ (try (.write out (.readAllBytes in))
684+ (finally (.close out))))
685+ (finally (try (.close in) (catch Exception _ nil))))))
686+
687+(defn pick-image!
688+ "Attach the picture at `path` and close the picker.
689+
690+ Copied into `outgoing/` first rather than attached where it lies: the send
691+ drops the attachment's file when it is done with it, and what it drops has to
692+ be ours not the reader's own picture, sitting in their pictures folder."
693+ [path]
694+ (let [copy (paste-path)]
695+ (try
696+ (host/mkdirs! (str (media/cache-dir) "/outgoing"))
697+ (copy-file! path copy)
698+ (close-image-picker!)
699+ (attach! copy (or (last (str/split (str path) #"/")) "picture.png"))
700+ (catch Exception e
701+ (discard-file! copy)
702+ (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
600703
601704 (defn send-draft!
602705 "Send the draft, with whatever picture is attached to it.
@@ -559,6 +559,39 @@
559 (reset! attachment nil)559 (reset! attachment nil)
560 (discard-file! (:path a))))560 (discard-file! (:path a))))
561 561
562+(defn- attach!
563+ "Hold the picture already written to `path` — a copy of ours under
564+ `outgoing/` against the next line, and start its upload.
565+
566+ The upload runs off the UI thread and starts at once rather than at send, so
567+ by the time a line is written the picture is usually already up. A failure
568+ lands in `error` like any other, and takes the attachment with it there is
569+ nothing to send and nothing to show.
570+
571+ `filename` is what the server files it under; it says which gesture the
572+ picture came in by, and nothing else depends on it."
573+ [path filename]
574+ (let [did (:did @session)
575+ host-name @form-host
576+ channel @current]
577+ (reset! error nil)
578+ (clear-attachment!)
579+ (reset! attachment {:path path :status :uploading})
580+ (future
581+ (try
582+ (let [url (upload/upload! host-name did channel path filename)]
583+ ;; Only if this is still the picture on screen: a reader who attached
584+ ;; another, or cleared it, has said what they want, and an upload
585+ ;; landing afterwards does not get to undo that.
586+ (swap! attachment #(if (= (:path %) path)
587+ (assoc % :url url :status :ready)
588+ %))
589+ (when-not (= (:path @attachment) path) (discard-file! path)))
590+ (catch Exception e
591+ (swap! attachment #(if (= (:path %) path) nil %))
592+ (discard-file! path)
593+ (reset! error (or (ex-message e) (str e))))))))
594+
562 (defn paste-image!595 (defn paste-image!
563 "Take the picture on the clipboard and hold it against the next line.596 "Take the picture on the clipboard and hold it against the next line.
564 597
@@ -566,37 +599,107 @@
566 means but that is a fact about the wire, not something the reader should599 means but that is a fact about the wire, not something the reader should
567 have to type around. The picture is attached: shown under the draft while600 have to type around. The picture is attached: shown under the draft while
568 they write whatever they are sending it with, and turned into a link only on601 they write whatever they are sending it with, and turned into a link only on
569- the way out.602+ the way out."
570-
571- The upload runs off the UI thread and starts at once rather than at send, so
572- by the time a line is written the picture is usually already up. A failure
573- lands in `error` like any other, and takes the attachment with it there is
574- nothing to send and nothing to show."
575 []603 []
576 (let [path (paste-path)]604 (let [path (paste-path)]
577 (host/mkdirs! (str (media/cache-dir) "/outgoing"))605 (host/mkdirs! (str (media/cache-dir) "/outgoing"))
578 (if-not (vidya/clipboard-image-png! path)606 (if-not (vidya/clipboard-image-png! path)
607+ ;; Android has no clipboard of pictures to read at all, which is the
608+ ;; other half of why the picker below exists.
579 (reset! error "No picture on the clipboard.")609 (reset! error "No picture on the clipboard.")
580- (let [did (:did @session)610+ (attach! path "paste.png"))))
581- host-name @form-host611+
582- channel @current]612+;; ------------------------------------------------------------------ picking
583- (reset! error nil)613+
584- (clear-attachment!)614+;; Where the picker is looking, or nil when it is closed. A path, so the
585- (reset! attachment {:path path :status :uploading})615+;; browsing is just this cell moving.
586- (future616+(defonce image-picker (atom nil))
587- (try617+
588- (let [url (upload/upload! host-name did channel path "paste.png")]618+(defn- readable-dir? [path]
589- ;; Only if this is still the paste on screen: a reader who619+ (try (and (host/file-exists? path) (host/directory? path))
590- ;; pasted again, or cleared it, has said what they want, and an620+ (catch Exception _ false)))
591- ;; upload landing afterwards does not get to undo that.621+
592- (swap! attachment #(if (= (:path %) path)622+(defn picker-roots
593- (assoc % :url url :status :ready)623+ "The places worth opening the picker on, on whichever platform this is.
594- %))624+
595- (when-not (= (:path @attachment) path) (discard-file! path)))625+ Only the ones that are actually there: a phone has no ~/Pictures and a
596- (catch Exception e626+ desktop no /sdcard, and a list of directories that are not there is a list of
597- (swap! attachment #(if (= (:path %) path) nil %))627+ dead ends. On Android everything outside the app's own storage is behind a
598- (discard-file! path)628+ runtime permission this activity has no code to ask for, so what survives
599- (reset! error (or (ex-message e) (str e))))))))))629+ this filter there is usually the app's own files — which is the honest
630+ answer, not a bug to paper over."
631+ []
632+ (let [home (or (host/getenv "HOME") "")
633+ under (fn [base] (when (seq base)
634+ (map #(str base "/" %)
635+ ["Pictures" "Downloads" "Download" "DCIM"])))]
636+ (vec (distinct (filter readable-dir?
637+ (concat (under home)
638+ (under "/sdcard")
639+ (under "/storage/emulated/0")
640+ [(media/cache-dir) home]))))))
641+
642+(defn- png? [name]
643+ (str/ends-with? (str/lower-case (str name)) ".png"))
644+
645+(defn picker-entries
646+ "What `dir` holds, as `{:dirs [...] :files [...]}` of full paths.
647+
648+ PNG only, for the same reason the media cache reads PNG only: it is what the
649+ tree backend paints and what the upload sends. An unreadable directory
650+ which on Android is most of them answers empty rather than throwing.
651+
652+ Hidden entries are left out: nothing a reader means to send lives in one, and
653+ a home directory is unusable as a list with them in it."
654+ [dir]
655+ (let [names (try (sort (host/list-dir dir)) (catch Exception _ nil))
656+ keep (remove #(str/starts-with? (str %) ".") names)
657+ path (fn [n] (str dir "/" n))]
658+ {:dirs (vec (filter readable-dir? (map path keep)))
659+ :files (vec (map path (filter png? keep)))}))
660+
661+(defn parent-dir
662+ "The directory above `dir`, or nil at the top."
663+ [dir]
664+ (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
665+ (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
666+
667+(defn open-image-picker!
668+ "Open the picker, on the first place there is to look."
669+ []
670+ (reset! error nil)
671+ (reset! image-picker (or (first (picker-roots)) "/")))
672+
673+(defn close-image-picker! [] (reset! image-picker nil))
674+
675+(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir)))
676+
677+(defn- copy-file!
678+ "Copy `from` to `to`, byte for byte."
679+ [from to]
680+ (let [in (java.io.FileInputStream. from)]
681+ (try
682+ (let [out (java.io.FileOutputStream. to)]
683+ (try (.write out (.readAllBytes in))
684+ (finally (.close out))))
685+ (finally (try (.close in) (catch Exception _ nil))))))
686+
687+(defn pick-image!
688+ "Attach the picture at `path` and close the picker.
689+
690+ Copied into `outgoing/` first rather than attached where it lies: the send
691+ drops the attachment's file when it is done with it, and what it drops has to
692+ be ours not the reader's own picture, sitting in their pictures folder."
693+ [path]
694+ (let [copy (paste-path)]
695+ (try
696+ (host/mkdirs! (str (media/cache-dir) "/outgoing"))
697+ (copy-file! path copy)
698+ (close-image-picker!)
699+ (attach! copy (or (last (str/split (str path) #"/")) "picture.png"))
700+ (catch Exception e
701+ (discard-file! copy)
702+ (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
600 703
601 (defn send-draft!704 (defn send-draft!
602 "Send the draft, with whatever picture is attached to it.705 "Send the draft, with whatever picture is attached to it.