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>
cab7487 parent: 3c98eb0 modified
src/frq/app.jolt +63 -3 | @@ -519,6 +519,61 @@ | ||
| 519 | 519 | [:button {:label "← Back" :on-click #(reset! s/lightbox nil)}]] |
| 520 | 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 | 577 | (defn chat-screen [] |
| 523 | 578 | (let [name @s/current |
| 524 | 579 | buffer (get @s/channels name)] |
| @@ -594,8 +649,11 @@ | ||
| 594 | 649 | ;; `:on-paste-empty` — a keystroke the field had nothing to put in |
| 595 | 650 | ;; itself, which is exactly the one that means "the clipboard has |
| 596 | 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 | 655 | [:entry {:text @s/draft |
| 598 | - :width-request 300 | |
| 656 | + :width-request 260 | |
| 599 | 657 | :placeholder "Message" |
| 600 | 658 | :on-change #(reset! s/draft %) |
| 601 | 659 | :on-paste-empty s/paste-image! |
| @@ -658,8 +716,10 @@ | ||
| 658 | 716 | ;; ---------------------------------------------------------------- shell |
| 659 | 717 | |
| 660 | 718 | (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 | |
| 663 | 723 | (case @s/screen |
| 664 | 724 | :connect [connect-screen] |
| 665 | 725 | :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/current | 578 | (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 in | 649 | ;; `:on-paste-empty` — a keystroke the field had nothing to put in |
| 595 | ;; itself, which is exactly the one that means "the clipboard has | 650 | ;; 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/draft | 655 | [:entry {:text @s/draft |
| 598 | - :width-request 300 | 656 | + :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 | ;; ---------------------------------------------------------------- shell | 716 | ;; ---------------------------------------------------------------- shell |
| 659 | 717 | ||
| 660 | (defn app [] | 718 | (defn app [] |
| 661 | - (if @s/lightbox | 719 | + (cond |
| 662 | - [lightbox-screen] | 720 | + @s/lightbox [lightbox-screen] |
| 721 | + @s/image-picker [image-picker-screen] | ||
| 722 | + :else | ||
| 663 | (case @s/screen | 723 | (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 @@ | ||
| 559 | 559 | (reset! attachment nil) |
| 560 | 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 | 595 | (defn paste-image! |
| 563 | 596 | "Take the picture on the clipboard and hold it against the next line. |
| 564 | 597 | |
| @@ -566,37 +599,107 @@ | ||
| 566 | 599 | means — but that is a fact about the wire, not something the reader should |
| 567 | 600 | have to type around. The picture is attached: shown under the draft while |
| 568 | 601 | 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." | |
| 575 | 603 | [] |
| 576 | 604 | (let [path (paste-path)] |
| 577 | 605 | (host/mkdirs! (str (media/cache-dir) "/outgoing")) |
| 578 | 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 | 609 | (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))))))) | |
| 600 | 703 | |
| 601 | 704 | (defn send-draft! |
| 602 | 705 | "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 should | 599 | 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 while | 600 | 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 on | 601 | 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-host | 611 | + |
| 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 | - (future | 616 | +(defonce image-picker (atom nil)) |
| 587 | - (try | 617 | + |
| 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 who | 619 | + (try (and (host/file-exists? path) (host/directory? path)) |
| 590 | - ;; pasted again, or cleared it, has said what they want, and an | 620 | + (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 e | 626 | + 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. |