Merge main into the call work
Three conflicts, none of them a disagreement. deps.edn: main bumped glimmer's sha while this branch repointed glimmer-vidya at the jolt-native checkout. Both wanted. app.jolt: the call UI and the image-picker screen were added at the same place and have nothing to do with each other. The chat header did overlap — main takes the back button away on a window wide enough to show the list beside the conversation, and this branch adds a Call button — so the Call button goes in a keyed wrapper of its own for the same reason main's does: a child that comes and goes renumbers the row for the reconciler. Both now poll the window's size, by different routes: main reads a `window-width` prop the backend writes onto the root node, and the call wall asks vidya_screen_width/height over the FFI because it needs the height too. Left as they are — a merge is not the place — but they are one mechanism too many and should become one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
modified
deps.edn +6 -2 | @@ -2,12 +2,16 @@ | ||
| 2 | 2 | |
| 3 | 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); |
| 4 | 4 | ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. |
| 5 | - ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a | |
| 5 | + ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A | |
| 6 | 6 | ;; component unmounted between a cell firing and the queued render running |
| 7 | 7 | ;; used to render anyway, into widgets the backend had already freed and |
| 8 | 8 | ;; handed out again — which took the message list apart in a busy channel. |
| 9 | + ;; And replacing a native node with one of another tag left every watcher | |
| 10 | + ;; under it subscribed, so swapping the phone layout for the split one and | |
| 11 | + ;; back shredded the chat: one component's props painted onto another's | |
| 12 | + ;; widget. | |
| 9 | 13 | :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer" |
| 10 | - :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"} | |
| 14 | + :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"} | |
| 11 | 15 | nandi/glimmer-vidya {:local/root "../jolt-native/jolt/glimmer-vidya"}} |
| 12 | 16 | |
| 13 | 17 | ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each, |
| @@ -2,12 +2,16 @@ | |||
| 2 | 2 | ||
| 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); | 3 | ;; glimmer owns the reactive half (ratom, components, reconciler); |
| 4 | ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. | 4 | ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. |
| 5 | - ;; Our fork of glimmer, until the reconciler fix in it lands upstream: a | 5 | + ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A |
| 6 | ;; component unmounted between a cell firing and the queued render running | 6 | ;; component unmounted between a cell firing and the queued render running |
| 7 | ;; used to render anyway, into widgets the backend had already freed and | 7 | ;; used to render anyway, into widgets the backend had already freed and |
| 8 | ;; handed out again — which took the message list apart in a busy channel. | 8 | ;; handed out again — which took the message list apart in a busy channel. |
| 9 | + ;; And replacing a native node with one of another tag left every watcher | ||
| 10 | + ;; under it subscribed, so swapping the phone layout for the split one and | ||
| 11 | + ;; back shredded the chat: one component's props painted onto another's | ||
| 12 | + ;; widget. | ||
| 9 | :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer" | 13 | :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer" |
| 10 | - :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"} | 14 | + :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"} |
| 11 | nandi/glimmer-vidya {:local/root "../jolt-native/jolt/glimmer-vidya"}} | 15 | nandi/glimmer-vidya {:local/root "../jolt-native/jolt/glimmer-vidya"}} |
| 12 | 16 | ||
| 13 | ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each, | 17 | ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each, |
modified
src/frq/app.jolt +136 -18 | @@ -632,6 +632,61 @@ | ||
| 632 | 632 | |
| 633 | 633 | :else nil)]) |
| 634 | 634 | |
| 635 | +(defn image-picker-screen | |
| 636 | + "The pictures on this device, to send one of. | |
| 637 | + | |
| 638 | + A screen rather than a panel over the compose bar: choosing a file is | |
| 639 | + browsing, and browsing wants the window — the backend paints in one layer | |
| 640 | + anyway, so there is no overlay to put it in. | |
| 641 | + | |
| 642 | + A directory at a time, PNG only. Which directories there are to start from is | |
| 643 | + the platform's answer, not this screen's: a desktop opens on ~/Pictures, and | |
| 644 | + a phone on what the app can read without a permission it has no code to ask | |
| 645 | + for, which may be very little. Either way the list says what it found." | |
| 646 | + [] | |
| 647 | + (let [dir @s/image-picker | |
| 648 | + {:keys [dirs files]} (s/picker-entries dir) | |
| 649 | + up (s/parent-dir dir)] | |
| 650 | + [:vbox {:spacing 8 :margin 12} | |
| 651 | + [:hbox {:spacing 8} | |
| 652 | + [:button {:label "← Back" :on-click s/close-image-picker!}] | |
| 653 | + [:title {:label "Send a picture"}]] | |
| 654 | + [:dim-label {:label (str dir)}] | |
| 655 | + [error-note] | |
| 656 | + ;; The places worth starting from, always in reach: browsing into a corner | |
| 657 | + ;; of the filesystem should not cost the way back to the pictures folder. | |
| 658 | + [:hbox {:key :roots :spacing 6} | |
| 659 | + (for [root (s/picker-roots)] | |
| 660 | + [:button {:key root | |
| 661 | + :label (or (last (str/split root #"/")) root) | |
| 662 | + :kind (if (= root dir) :primary :default) | |
| 663 | + :on-click #(s/browse! root)}])] | |
| 664 | + [:separator {}] | |
| 665 | + [:scroll {:scroll-key "image-picker" | |
| 666 | + :orientation :vertical | |
| 667 | + :reserve 40} | |
| 668 | + [:vbox {:spacing 6} | |
| 669 | + [:vbox {:key :up} | |
| 670 | + (when up | |
| 671 | + [:button {:label "⬆ Up" :on-click #(s/browse! up)}])] | |
| 672 | + [:vbox {:key :dirs :spacing 4} | |
| 673 | + (for [d dirs] | |
| 674 | + [:button {:key d | |
| 675 | + :label (str "📁 " (last (str/split d #"/"))) | |
| 676 | + :on-click #(s/browse! d)}])] | |
| 677 | + ;; The picture itself is the button: a filename is not what anyone is | |
| 678 | + ;; choosing between, and a thumbnail answers "is this the one" in a way | |
| 679 | + ;; no name does. | |
| 680 | + [:vbox {:key :files :spacing 6} | |
| 681 | + (for [f files] | |
| 682 | + [:hbox {:key f :spacing 8} | |
| 683 | + [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}] | |
| 684 | + [:button {:label (last (str/split f #"/")) | |
| 685 | + :on-click #(s/pick-image! f)}]])] | |
| 686 | + [:vbox {:key :empty} | |
| 687 | + (when (and (empty? dirs) (empty? files)) | |
| 688 | + [:dim-label {:label "No pictures here that this app can read."}])]]]])) | |
| 689 | + | |
| 635 | 690 | (defn chat-screen [] |
| 636 | 691 | (let [name @s/current |
| 637 | 692 | buffer (get @s/channels name)] |
| @@ -642,16 +697,23 @@ | ||
| 642 | 697 | ;; bottom of a screen wants is above it, not beneath it. |
| 643 | 698 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} |
| 644 | 699 | [:hbox {:spacing 8} |
| 645 | - [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}] | |
| 700 | + ;; The way back to the list, on a window with room for one thing at a | |
| 701 | + ;; time. Beside the list there is nothing to go back to, so the button | |
| 702 | + ;; goes — in a wrapper of its own, since a child that comes and goes | |
| 703 | + ;; would otherwise renumber the row for the reconciler. | |
| 704 | + [:vbox {:key :back} | |
| 705 | + (when-not (s/wide?) | |
| 706 | + [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] | |
| 646 | 707 | [:title {:label (or name "Chat")}] |
| 647 | - ;; Only in a channel, and only when there is no call to join already — | |
| 648 | - ;; the bar below offers Join in that case, and two ways to end up in the | |
| 649 | - ;; same call is one more than anybody needs. | |
| 650 | - (when (and name | |
| 651 | - (str/starts-with? name "#") | |
| 652 | - (not (av/call-in name)) | |
| 653 | - (not (av/in-call?))) | |
| 654 | - [:button {:label "Call" :on-click #(s/start-call! name)}])] | |
| 708 | + ;; Same wrapper trick: only in a channel, and only when there is no call | |
| 709 | + ;; to join already — the bar below offers Join in that case, and two ways | |
| 710 | + ;; into the same call is one more than anybody needs. | |
| 711 | + [:vbox {:key :call} | |
| 712 | + (when (and name | |
| 713 | + (str/starts-with? name "#") | |
| 714 | + (not (av/call-in name)) | |
| 715 | + (not (av/in-call?))) | |
| 716 | + [:button {:label "Call" :on-click #(s/start-call! name)}])]] | |
| 655 | 717 | [error-note] |
| 656 | 718 | [call-bar name] |
| 657 | 719 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -716,14 +778,57 @@ | ||
| 716 | 778 | ;; `:on-paste-empty` — a keystroke the field had nothing to put in |
| 717 | 779 | ;; itself, which is exactly the one that means "the clipboard has |
| 718 | 780 | ;; something else on it". |
| 781 | + ;; And the same picture chosen rather than pasted, for a phone — which has | |
| 782 | + ;; no Ctrl+V, and no clipboard of pictures to read if it had. | |
| 783 | + [:button {:label "🖼" :on-click s/open-image-picker!}] | |
| 719 | 784 | [:entry {:text @s/draft |
| 720 | - :width-request 300 | |
| 785 | + :width-request 260 | |
| 721 | 786 | :placeholder "Message" |
| 722 | 787 | :on-change #(reset! s/draft %) |
| 723 | 788 | :on-paste-empty s/paste-image! |
| 724 | 789 | :on-activate s/send-draft!}] |
| 725 | 790 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) |
| 726 | 791 | |
| 792 | +;; ---------------------------------------------------------------- split | |
| 793 | + | |
| 794 | +(def ^:private sidebar-width 320) | |
| 795 | + | |
| 796 | +(defn- no-chat-pane | |
| 797 | + "What fills the second pane before a conversation has been picked. The pane | |
| 798 | + is there either way — a list that widened into two columns and back again as | |
| 799 | + channels were opened would be a worse answer than an empty half." | |
| 800 | + [] | |
| 801 | + [:vbox {:spacing 8 :margin 12} | |
| 802 | + [:title {:label "frq"}] | |
| 803 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]]]) | |
| 804 | + | |
| 805 | +(defn split-screen | |
| 806 | + "The chats list and the conversation side by side, for a window wide enough | |
| 807 | + to hold both. | |
| 808 | + | |
| 809 | + Same components as the narrow layout, in a row instead of one at a time: the | |
| 810 | + list keeps its own scroll and its own tab bar, and the conversation keeps the | |
| 811 | + compose bar pinned under a backlog that scrolls on its own. | |
| 812 | + | |
| 813 | + Both panes take `:fill-height`: a column in a row is otherwise as tall as the | |
| 814 | + row, which at the moment it is placed is one button — and the list and the | |
| 815 | + message backlog both size themselves against the height they are handed. | |
| 816 | + | |
| 817 | + Only the list is given a width. The conversation takes what is left, rather | |
| 818 | + than the window's width minus the list's: the list costs a little more than | |
| 819 | + its 320 (its page has padding of its own), and a second column asking for | |
| 820 | + more than remains is wrapped onto a row below — painting the whole | |
| 821 | + conversation off the bottom of the window, which reads exactly like a | |
| 822 | + conversation that has gone missing." | |
| 823 | + [] | |
| 824 | + [:hbox {:spacing 0 :wrap false} | |
| 825 | + [:vbox {:key :list :width-request sidebar-width :fill-height true} | |
| 826 | + [chats-screen]] | |
| 827 | + [:vbox {:key :chat :fill-height true} | |
| 828 | + (if @s/current | |
| 829 | + [chat-screen] | |
| 830 | + [no-chat-pane])]]) | |
| 831 | + | |
| 727 | 832 | ;; ---------------------------------------------------------------- discover |
| 728 | 833 | |
| 729 | 834 | (defn discover-screen [] |
| @@ -780,14 +885,20 @@ | ||
| 780 | 885 | ;; ---------------------------------------------------------------- shell |
| 781 | 886 | |
| 782 | 887 | (defn app [] |
| 783 | - (if @s/lightbox | |
| 784 | - [lightbox-screen] | |
| 785 | - (case @s/screen | |
| 786 | - :connect [connect-screen] | |
| 787 | - :chat [chat-screen] | |
| 788 | - :discover [discover-screen] | |
| 789 | - :settings [settings-screen] | |
| 790 | - [chats-screen]))) | |
| 888 | + (cond | |
| 889 | + @s/lightbox [lightbox-screen] | |
| 890 | + @s/image-picker [image-picker-screen] | |
| 891 | + ;; Wide enough for both, and on one of the two screens that are halves of | |
| 892 | + ;; the same thing: the list and the conversation it opens. Discover and | |
| 893 | + ;; settings stay whole screens — they are somewhere else, not the other | |
| 894 | + ;; half of here. | |
| 895 | + (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen] | |
| 896 | + :else (case @s/screen | |
| 897 | + :connect [connect-screen] | |
| 898 | + :chat [chat-screen] | |
| 899 | + :discover [discover-screen] | |
| 900 | + :settings [settings-screen] | |
| 901 | + [chats-screen]))) | |
| 791 | 902 | |
| 792 | 903 | (defn -main [& _] |
| 793 | 904 | ;; Before the window: the rooms this client has been in, and a saved sign-in |
| @@ -812,4 +923,11 @@ | ||
| 812 | 923 | ;; that belongs to the state layer. |
| 813 | 924 | (reset! av/on-dropped s/announce-leave!) |
| 814 | 925 | (vidya/after! 0 av/install-pump!) |
| 926 | + ;; The window's width, into a ratom, a few times a second. Polled rather than | |
| 927 | + ;; delivered: the backend reports a size by writing it onto the window node, | |
| 928 | + ;; and only what a component derefs re-renders — so the layout follows a drag | |
| 929 | + ;; of the window's edge without every frame touching the tree. | |
| 930 | + (vidya/every! 200 #(let [w (vidya/window-width)] | |
| 931 | + (when (not= w @s/window-width) | |
| 932 | + (reset! s/window-width w)))) | |
| 815 | 933 | (ui/run app :title "frq" :width 520 :height 860)) |
| @@ -632,6 +632,61 @@ | |||
| 632 | 632 | ||
| 633 | :else nil)]) | 633 | :else nil)]) |
| 634 | 634 | ||
| 635 | +(defn image-picker-screen | ||
| 636 | + "The pictures on this device, to send one of. | ||
| 637 | + | ||
| 638 | + A screen rather than a panel over the compose bar: choosing a file is | ||
| 639 | + browsing, and browsing wants the window — the backend paints in one layer | ||
| 640 | + anyway, so there is no overlay to put it in. | ||
| 641 | + | ||
| 642 | + A directory at a time, PNG only. Which directories there are to start from is | ||
| 643 | + the platform's answer, not this screen's: a desktop opens on ~/Pictures, and | ||
| 644 | + a phone on what the app can read without a permission it has no code to ask | ||
| 645 | + for, which may be very little. Either way the list says what it found." | ||
| 646 | + [] | ||
| 647 | + (let [dir @s/image-picker | ||
| 648 | + {:keys [dirs files]} (s/picker-entries dir) | ||
| 649 | + up (s/parent-dir dir)] | ||
| 650 | + [:vbox {:spacing 8 :margin 12} | ||
| 651 | + [:hbox {:spacing 8} | ||
| 652 | + [:button {:label "← Back" :on-click s/close-image-picker!}] | ||
| 653 | + [:title {:label "Send a picture"}]] | ||
| 654 | + [:dim-label {:label (str dir)}] | ||
| 655 | + [error-note] | ||
| 656 | + ;; The places worth starting from, always in reach: browsing into a corner | ||
| 657 | + ;; of the filesystem should not cost the way back to the pictures folder. | ||
| 658 | + [:hbox {:key :roots :spacing 6} | ||
| 659 | + (for [root (s/picker-roots)] | ||
| 660 | + [:button {:key root | ||
| 661 | + :label (or (last (str/split root #"/")) root) | ||
| 662 | + :kind (if (= root dir) :primary :default) | ||
| 663 | + :on-click #(s/browse! root)}])] | ||
| 664 | + [:separator {}] | ||
| 665 | + [:scroll {:scroll-key "image-picker" | ||
| 666 | + :orientation :vertical | ||
| 667 | + :reserve 40} | ||
| 668 | + [:vbox {:spacing 6} | ||
| 669 | + [:vbox {:key :up} | ||
| 670 | + (when up | ||
| 671 | + [:button {:label "⬆ Up" :on-click #(s/browse! up)}])] | ||
| 672 | + [:vbox {:key :dirs :spacing 4} | ||
| 673 | + (for [d dirs] | ||
| 674 | + [:button {:key d | ||
| 675 | + :label (str "📁 " (last (str/split d #"/"))) | ||
| 676 | + :on-click #(s/browse! d)}])] | ||
| 677 | + ;; The picture itself is the button: a filename is not what anyone is | ||
| 678 | + ;; choosing between, and a thumbnail answers "is this the one" in a way | ||
| 679 | + ;; no name does. | ||
| 680 | + [:vbox {:key :files :spacing 6} | ||
| 681 | + (for [f files] | ||
| 682 | + [:hbox {:key f :spacing 8} | ||
| 683 | + [:image {:src f :max-height 72 :on-click #(s/pick-image! f)}] | ||
| 684 | + [:button {:label (last (str/split f #"/")) | ||
| 685 | + :on-click #(s/pick-image! f)}]])] | ||
| 686 | + [:vbox {:key :empty} | ||
| 687 | + (when (and (empty? dirs) (empty? files)) | ||
| 688 | + [:dim-label {:label "No pictures here that this app can read."}])]]]])) | ||
| 689 | + | ||
| 635 | (defn chat-screen [] | 690 | (defn chat-screen [] |
| 636 | (let [name @s/current | 691 | (let [name @s/current |
| 637 | buffer (get @s/channels name)] | 692 | buffer (get @s/channels name)] |
| @@ -642,16 +697,23 @@ | |||
| 642 | ;; bottom of a screen wants is above it, not beneath it. | 697 | ;; bottom of a screen wants is above it, not beneath it. |
| 643 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} | 698 | [:vbox {:spacing 8 :margin 12 :margin-bottom 4} |
| 644 | [:hbox {:spacing 8} | 699 | [:hbox {:spacing 8} |
| 645 | - [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}] | 700 | + ;; The way back to the list, on a window with room for one thing at a |
| 701 | + ;; time. Beside the list there is nothing to go back to, so the button | ||
| 702 | + ;; goes — in a wrapper of its own, since a child that comes and goes | ||
| 703 | + ;; would otherwise renumber the row for the reconciler. | ||
| 704 | + [:vbox {:key :back} | ||
| 705 | + (when-not (s/wide?) | ||
| 706 | + [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}])] | ||
| 646 | [:title {:label (or name "Chat")}] | 707 | [:title {:label (or name "Chat")}] |
| 647 | - ;; Only in a channel, and only when there is no call to join already — | 708 | + ;; Same wrapper trick: only in a channel, and only when there is no call |
| 648 | - ;; the bar below offers Join in that case, and two ways to end up in the | 709 | + ;; to join already — the bar below offers Join in that case, and two ways |
| 649 | - ;; same call is one more than anybody needs. | 710 | + ;; into the same call is one more than anybody needs. |
| 650 | - (when (and name | 711 | + [:vbox {:key :call} |
| 651 | - (str/starts-with? name "#") | 712 | + (when (and name |
| 652 | - (not (av/call-in name)) | 713 | + (str/starts-with? name "#") |
| 653 | - (not (av/in-call?))) | 714 | + (not (av/call-in name)) |
| 654 | - [:button {:label "Call" :on-click #(s/start-call! name)}])] | 715 | + (not (av/in-call?))) |
| 716 | + [:button {:label "Call" :on-click #(s/start-call! name)}])]] | ||
| 655 | [error-note] | 717 | [error-note] |
| 656 | [call-bar name] | 718 | [call-bar name] |
| 657 | ;; :reserve leaves room for everything below: the jump button's row, the | 719 | ;; :reserve leaves room for everything below: the jump button's row, the |
| @@ -716,14 +778,57 @@ | |||
| 716 | ;; `:on-paste-empty` — a keystroke the field had nothing to put in | 778 | ;; `:on-paste-empty` — a keystroke the field had nothing to put in |
| 717 | ;; itself, which is exactly the one that means "the clipboard has | 779 | ;; itself, which is exactly the one that means "the clipboard has |
| 718 | ;; something else on it". | 780 | ;; something else on it". |
| 781 | + ;; And the same picture chosen rather than pasted, for a phone — which has | ||
| 782 | + ;; no Ctrl+V, and no clipboard of pictures to read if it had. | ||
| 783 | + [:button {:label "🖼" :on-click s/open-image-picker!}] | ||
| 719 | [:entry {:text @s/draft | 784 | [:entry {:text @s/draft |
| 720 | - :width-request 300 | 785 | + :width-request 260 |
| 721 | :placeholder "Message" | 786 | :placeholder "Message" |
| 722 | :on-change #(reset! s/draft %) | 787 | :on-change #(reset! s/draft %) |
| 723 | :on-paste-empty s/paste-image! | 788 | :on-paste-empty s/paste-image! |
| 724 | :on-activate s/send-draft!}] | 789 | :on-activate s/send-draft!}] |
| 725 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) | 790 | [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) |
| 726 | 791 | ||
| 792 | +;; ---------------------------------------------------------------- split | ||
| 793 | + | ||
| 794 | +(def ^:private sidebar-width 320) | ||
| 795 | + | ||
| 796 | +(defn- no-chat-pane | ||
| 797 | + "What fills the second pane before a conversation has been picked. The pane | ||
| 798 | + is there either way — a list that widened into two columns and back again as | ||
| 799 | + channels were opened would be a worse answer than an empty half." | ||
| 800 | + [] | ||
| 801 | + [:vbox {:spacing 8 :margin 12} | ||
| 802 | + [:title {:label "frq"}] | ||
| 803 | + [:card {} [:dim-label {:label "Pick a conversation on the left."}]]]) | ||
| 804 | + | ||
| 805 | +(defn split-screen | ||
| 806 | + "The chats list and the conversation side by side, for a window wide enough | ||
| 807 | + to hold both. | ||
| 808 | + | ||
| 809 | + Same components as the narrow layout, in a row instead of one at a time: the | ||
| 810 | + list keeps its own scroll and its own tab bar, and the conversation keeps the | ||
| 811 | + compose bar pinned under a backlog that scrolls on its own. | ||
| 812 | + | ||
| 813 | + Both panes take `:fill-height`: a column in a row is otherwise as tall as the | ||
| 814 | + row, which at the moment it is placed is one button — and the list and the | ||
| 815 | + message backlog both size themselves against the height they are handed. | ||
| 816 | + | ||
| 817 | + Only the list is given a width. The conversation takes what is left, rather | ||
| 818 | + than the window's width minus the list's: the list costs a little more than | ||
| 819 | + its 320 (its page has padding of its own), and a second column asking for | ||
| 820 | + more than remains is wrapped onto a row below — painting the whole | ||
| 821 | + conversation off the bottom of the window, which reads exactly like a | ||
| 822 | + conversation that has gone missing." | ||
| 823 | + [] | ||
| 824 | + [:hbox {:spacing 0 :wrap false} | ||
| 825 | + [:vbox {:key :list :width-request sidebar-width :fill-height true} | ||
| 826 | + [chats-screen]] | ||
| 827 | + [:vbox {:key :chat :fill-height true} | ||
| 828 | + (if @s/current | ||
| 829 | + [chat-screen] | ||
| 830 | + [no-chat-pane])]]) | ||
| 831 | + | ||
| 727 | ;; ---------------------------------------------------------------- discover | 832 | ;; ---------------------------------------------------------------- discover |
| 728 | 833 | ||
| 729 | (defn discover-screen [] | 834 | (defn discover-screen [] |
| @@ -780,14 +885,20 @@ | |||
| 780 | ;; ---------------------------------------------------------------- shell | 885 | ;; ---------------------------------------------------------------- shell |
| 781 | 886 | ||
| 782 | (defn app [] | 887 | (defn app [] |
| 783 | - (if @s/lightbox | 888 | + (cond |
| 784 | - [lightbox-screen] | 889 | + @s/lightbox [lightbox-screen] |
| 785 | - (case @s/screen | 890 | + @s/image-picker [image-picker-screen] |
| 786 | - :connect [connect-screen] | 891 | + ;; Wide enough for both, and on one of the two screens that are halves of |
| 787 | - :chat [chat-screen] | 892 | + ;; the same thing: the list and the conversation it opens. Discover and |
| 788 | - :discover [discover-screen] | 893 | + ;; settings stay whole screens — they are somewhere else, not the other |
| 789 | - :settings [settings-screen] | 894 | + ;; half of here. |
| 790 | - [chats-screen]))) | 895 | + (and (s/wide?) (contains? #{:chats :chat} @s/screen)) [split-screen] |
| 896 | + :else (case @s/screen | ||
| 897 | + :connect [connect-screen] | ||
| 898 | + :chat [chat-screen] | ||
| 899 | + :discover [discover-screen] | ||
| 900 | + :settings [settings-screen] | ||
| 901 | + [chats-screen]))) | ||
| 791 | 902 | ||
| 792 | (defn -main [& _] | 903 | (defn -main [& _] |
| 793 | ;; Before the window: the rooms this client has been in, and a saved sign-in | 904 | ;; Before the window: the rooms this client has been in, and a saved sign-in |
| @@ -812,4 +923,11 @@ | |||
| 812 | ;; that belongs to the state layer. | 923 | ;; that belongs to the state layer. |
| 813 | (reset! av/on-dropped s/announce-leave!) | 924 | (reset! av/on-dropped s/announce-leave!) |
| 814 | (vidya/after! 0 av/install-pump!) | 925 | (vidya/after! 0 av/install-pump!) |
| 926 | + ;; The window's width, into a ratom, a few times a second. Polled rather than | ||
| 927 | + ;; delivered: the backend reports a size by writing it onto the window node, | ||
| 928 | + ;; and only what a component derefs re-renders — so the layout follows a drag | ||
| 929 | + ;; of the window's edge without every frame touching the tree. | ||
| 930 | + (vidya/every! 200 #(let [w (vidya/window-width)] | ||
| 931 | + (when (not= w @s/window-width) | ||
| 932 | + (reset! s/window-width w)))) | ||
| 815 | (ui/run app :title "frq" :width 520 :height 860)) | 933 | (ui/run app :title "frq" :width 520 :height 860)) |
modified
src/frq/state.jolt +160 -28 | @@ -89,6 +89,32 @@ | ||
| 89 | 89 | (defonce lightbox (atom nil)) ; {:path :url} |
| 90 | 90 | (defonce join-input (atom "")) |
| 91 | 91 | |
| 92 | +;; The window's content width in points, polled from the backend a few times a | |
| 93 | +;; second. The app is laid out for a phone-width window, and this is what lets | |
| 94 | +;; a wide one be more than a phone with margins: past `wide-width` the channel | |
| 95 | +;; list and the conversation are both on screen instead of taking turns. | |
| 96 | +(defonce window-width (atom 0)) | |
| 97 | + | |
| 98 | +;; Where the second pane starts paying for itself. Below this a 300pt list | |
| 99 | +;; beside a conversation leaves the messages narrower than the phone layout | |
| 100 | +;; they were written for. | |
| 101 | +(def wide-width 900) | |
| 102 | + | |
| 103 | +(defn wide? | |
| 104 | + "True while the window has room for the list and a conversation at once." | |
| 105 | + [] | |
| 106 | + (>= @window-width wide-width)) | |
| 107 | + | |
| 108 | +(defn chat-visible? | |
| 109 | + "Whether the conversation in `current` is on screen. | |
| 110 | + | |
| 111 | + On a narrow window that is the chat screen alone. On a wide one the chats | |
| 112 | + screen shows it too, in the pane beside the list — so this, and not the | |
| 113 | + screen, is what decides whether an arriving line counts as unread." | |
| 114 | + [] | |
| 115 | + (or (= :chat @screen) | |
| 116 | + (and (wide?) (= :chats @screen)))) | |
| 117 | + | |
| 92 | 118 | ;; Whether the chat view is showing the newest line, and a counter the view |
| 93 | 119 | ;; watches to be told to go back to it. A counter rather than a flag: a flag |
| 94 | 120 | ;; would need clearing, and there is no frame in which to clear it. |
| @@ -151,7 +177,7 @@ | ||
| 151 | 177 | (swap! channels |
| 152 | 178 | (fn [m] |
| 153 | 179 | (let [m (ensure-channel m channel) |
| 154 | - viewing? (and (= :chat @screen) (= channel @current))] | |
| 180 | + viewing? (and (chat-visible?) (= channel @current))] | |
| 155 | 181 | (-> m |
| 156 | 182 | (update-in [channel :messages] conj |
| 157 | 183 | {:from from :text text :system? (= "*" from) |
| @@ -171,7 +197,10 @@ | ||
| 171 | 197 | buffer stays), and opening one is a request to be in it." |
| 172 | 198 | [name] |
| 173 | 199 | (reset! current name) |
| 174 | - (reset! screen :chat) | |
| 200 | + ;; On a wide window the conversation lives in the chats screen's second | |
| 201 | + ;; pane, beside the list; :chat is the narrow window's way of showing it | |
| 202 | + ;; instead of the list, and there is nothing there to trade it for. | |
| 203 | + (reset! screen (if (wide?) :chats :chat)) | |
| 175 | 204 | ;; A picker belongs to the message it was opened on; carrying it into another |
| 176 | 205 | ;; buffer would offer to react to something that is no longer on screen. |
| 177 | 206 | (reset! reacting nil) |
| @@ -618,6 +647,39 @@ | ||
| 618 | 647 | (reset! attachment nil) |
| 619 | 648 | (discard-file! (:path a)))) |
| 620 | 649 | |
| 650 | +(defn- attach! | |
| 651 | + "Hold the picture already written to `path` — a copy of ours under | |
| 652 | + `outgoing/` — against the next line, and start its upload. | |
| 653 | + | |
| 654 | + The upload runs off the UI thread and starts at once rather than at send, so | |
| 655 | + by the time a line is written the picture is usually already up. A failure | |
| 656 | + lands in `error` like any other, and takes the attachment with it — there is | |
| 657 | + nothing to send and nothing to show. | |
| 658 | + | |
| 659 | + `filename` is what the server files it under; it says which gesture the | |
| 660 | + picture came in by, and nothing else depends on it." | |
| 661 | + [path filename] | |
| 662 | + (let [did (:did @session) | |
| 663 | + host-name @form-host | |
| 664 | + channel @current] | |
| 665 | + (reset! error nil) | |
| 666 | + (clear-attachment!) | |
| 667 | + (reset! attachment {:path path :status :uploading}) | |
| 668 | + (future | |
| 669 | + (try | |
| 670 | + (let [url (upload/upload! host-name did channel path filename)] | |
| 671 | + ;; Only if this is still the picture on screen: a reader who attached | |
| 672 | + ;; another, or cleared it, has said what they want, and an upload | |
| 673 | + ;; landing afterwards does not get to undo that. | |
| 674 | + (swap! attachment #(if (= (:path %) path) | |
| 675 | + (assoc % :url url :status :ready) | |
| 676 | + %)) | |
| 677 | + (when-not (= (:path @attachment) path) (discard-file! path))) | |
| 678 | + (catch Exception e | |
| 679 | + (swap! attachment #(if (= (:path %) path) nil %)) | |
| 680 | + (discard-file! path) | |
| 681 | + (reset! error (or (ex-message e) (str e)))))))) | |
| 682 | + | |
| 621 | 683 | (defn paste-image! |
| 622 | 684 | "Take the picture on the clipboard and hold it against the next line. |
| 623 | 685 | |
| @@ -625,37 +687,107 @@ | ||
| 625 | 687 | means — but that is a fact about the wire, not something the reader should |
| 626 | 688 | have to type around. The picture is attached: shown under the draft while |
| 627 | 689 | they write whatever they are sending it with, and turned into a link only on |
| 628 | - the way out. | |
| 629 | - | |
| 630 | - The upload runs off the UI thread and starts at once rather than at send, so | |
| 631 | - by the time a line is written the picture is usually already up. A failure | |
| 632 | - lands in `error` like any other, and takes the attachment with it — there is | |
| 633 | - nothing to send and nothing to show." | |
| 690 | + the way out." | |
| 634 | 691 | [] |
| 635 | 692 | (let [path (paste-path)] |
| 636 | 693 | (host/mkdirs! (str (media/cache-dir) "/outgoing")) |
| 637 | 694 | (if-not (vidya/clipboard-image-png! path) |
| 695 | + ;; Android has no clipboard of pictures to read at all, which is the | |
| 696 | + ;; other half of why the picker below exists. | |
| 638 | 697 | (reset! error "No picture on the clipboard.") |
| 639 | - (let [did (:did @session) | |
| 640 | - host-name @form-host | |
| 641 | - channel @current] | |
| 642 | - (reset! error nil) | |
| 643 | - (clear-attachment!) | |
| 644 | - (reset! attachment {:path path :status :uploading}) | |
| 645 | - (future | |
| 646 | - (try | |
| 647 | - (let [url (upload/upload! host-name did channel path "paste.png")] | |
| 648 | - ;; Only if this is still the paste on screen: a reader who | |
| 649 | - ;; pasted again, or cleared it, has said what they want, and an | |
| 650 | - ;; upload landing afterwards does not get to undo that. | |
| 651 | - (swap! attachment #(if (= (:path %) path) | |
| 652 | - (assoc % :url url :status :ready) | |
| 653 | - %)) | |
| 654 | - (when-not (= (:path @attachment) path) (discard-file! path))) | |
| 655 | - (catch Exception e | |
| 656 | - (swap! attachment #(if (= (:path %) path) nil %)) | |
| 657 | - (discard-file! path) | |
| 658 | - (reset! error (or (ex-message e) (str e)))))))))) | |
| 698 | + (attach! path "paste.png")))) | |
| 699 | + | |
| 700 | +;; ------------------------------------------------------------------ picking | |
| 701 | + | |
| 702 | +;; Where the picker is looking, or nil when it is closed. A path, so the | |
| 703 | +;; browsing is just this cell moving. | |
| 704 | +(defonce image-picker (atom nil)) | |
| 705 | + | |
| 706 | +(defn- readable-dir? [path] | |
| 707 | + (try (and (host/file-exists? path) (host/directory? path)) | |
| 708 | + (catch Exception _ false))) | |
| 709 | + | |
| 710 | +(defn picker-roots | |
| 711 | + "The places worth opening the picker on, on whichever platform this is. | |
| 712 | + | |
| 713 | + Only the ones that are actually there: a phone has no ~/Pictures and a | |
| 714 | + desktop no /sdcard, and a list of directories that are not there is a list of | |
| 715 | + dead ends. On Android everything outside the app's own storage is behind a | |
| 716 | + runtime permission this activity has no code to ask for, so what survives | |
| 717 | + this filter there is usually the app's own files — which is the honest | |
| 718 | + answer, not a bug to paper over." | |
| 719 | + [] | |
| 720 | + (let [home (or (host/getenv "HOME") "") | |
| 721 | + under (fn [base] (when (seq base) | |
| 722 | + (map #(str base "/" %) | |
| 723 | + ["Pictures" "Downloads" "Download" "DCIM"])))] | |
| 724 | + (vec (distinct (filter readable-dir? | |
| 725 | + (concat (under home) | |
| 726 | + (under "/sdcard") | |
| 727 | + (under "/storage/emulated/0") | |
| 728 | + [(media/cache-dir) home])))))) | |
| 729 | + | |
| 730 | +(defn- png? [name] | |
| 731 | + (str/ends-with? (str/lower-case (str name)) ".png")) | |
| 732 | + | |
| 733 | +(defn picker-entries | |
| 734 | + "What `dir` holds, as `{:dirs [...] :files [...]}` of full paths. | |
| 735 | + | |
| 736 | + PNG only, for the same reason the media cache reads PNG only: it is what the | |
| 737 | + tree backend paints and what the upload sends. An unreadable directory — | |
| 738 | + which on Android is most of them — answers empty rather than throwing. | |
| 739 | + | |
| 740 | + Hidden entries are left out: nothing a reader means to send lives in one, and | |
| 741 | + a home directory is unusable as a list with them in it." | |
| 742 | + [dir] | |
| 743 | + (let [names (try (sort (host/list-dir dir)) (catch Exception _ nil)) | |
| 744 | + keep (remove #(str/starts-with? (str %) ".") names) | |
| 745 | + path (fn [n] (str dir "/" n))] | |
| 746 | + {:dirs (vec (filter readable-dir? (map path keep))) | |
| 747 | + :files (vec (map path (filter png? keep)))})) | |
| 748 | + | |
| 749 | +(defn parent-dir | |
| 750 | + "The directory above `dir`, or nil at the top." | |
| 751 | + [dir] | |
| 752 | + (let [up (str/join "/" (butlast (str/split (str dir) #"/")))] | |
| 753 | + (when (and (seq up) (not= up dir) (readable-dir? up)) up))) | |
| 754 | + | |
| 755 | +(defn open-image-picker! | |
| 756 | + "Open the picker, on the first place there is to look." | |
| 757 | + [] | |
| 758 | + (reset! error nil) | |
| 759 | + (reset! image-picker (or (first (picker-roots)) "/"))) | |
| 760 | + | |
| 761 | +(defn close-image-picker! [] (reset! image-picker nil)) | |
| 762 | + | |
| 763 | +(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir))) | |
| 764 | + | |
| 765 | +(defn- copy-file! | |
| 766 | + "Copy `from` to `to`, byte for byte." | |
| 767 | + [from to] | |
| 768 | + (let [in (java.io.FileInputStream. from)] | |
| 769 | + (try | |
| 770 | + (let [out (java.io.FileOutputStream. to)] | |
| 771 | + (try (.write out (.readAllBytes in)) | |
| 772 | + (finally (.close out)))) | |
| 773 | + (finally (try (.close in) (catch Exception _ nil)))))) | |
| 774 | + | |
| 775 | +(defn pick-image! | |
| 776 | + "Attach the picture at `path` and close the picker. | |
| 777 | + | |
| 778 | + Copied into `outgoing/` first rather than attached where it lies: the send | |
| 779 | + drops the attachment's file when it is done with it, and what it drops has to | |
| 780 | + be ours — not the reader's own picture, sitting in their pictures folder." | |
| 781 | + [path] | |
| 782 | + (let [copy (paste-path)] | |
| 783 | + (try | |
| 784 | + (host/mkdirs! (str (media/cache-dir) "/outgoing")) | |
| 785 | + (copy-file! path copy) | |
| 786 | + (close-image-picker!) | |
| 787 | + (attach! copy (or (last (str/split (str path) #"/")) "picture.png")) | |
| 788 | + (catch Exception e | |
| 789 | + (discard-file! copy) | |
| 790 | + (reset! error (str "Could not read that picture: " (or (ex-message e) e))))))) | |
| 659 | 791 | |
| 660 | 792 | (defn send-draft! |
| 661 | 793 | "Send the draft, with whatever picture is attached to it. |
| @@ -89,6 +89,32 @@ | |||
| 89 | (defonce lightbox (atom nil)) ; {:path :url} | 89 | (defonce lightbox (atom nil)) ; {:path :url} |
| 90 | (defonce join-input (atom "")) | 90 | (defonce join-input (atom "")) |
| 91 | 91 | ||
| 92 | +;; The window's content width in points, polled from the backend a few times a | ||
| 93 | +;; second. The app is laid out for a phone-width window, and this is what lets | ||
| 94 | +;; a wide one be more than a phone with margins: past `wide-width` the channel | ||
| 95 | +;; list and the conversation are both on screen instead of taking turns. | ||
| 96 | +(defonce window-width (atom 0)) | ||
| 97 | + | ||
| 98 | +;; Where the second pane starts paying for itself. Below this a 300pt list | ||
| 99 | +;; beside a conversation leaves the messages narrower than the phone layout | ||
| 100 | +;; they were written for. | ||
| 101 | +(def wide-width 900) | ||
| 102 | + | ||
| 103 | +(defn wide? | ||
| 104 | + "True while the window has room for the list and a conversation at once." | ||
| 105 | + [] | ||
| 106 | + (>= @window-width wide-width)) | ||
| 107 | + | ||
| 108 | +(defn chat-visible? | ||
| 109 | + "Whether the conversation in `current` is on screen. | ||
| 110 | + | ||
| 111 | + On a narrow window that is the chat screen alone. On a wide one the chats | ||
| 112 | + screen shows it too, in the pane beside the list — so this, and not the | ||
| 113 | + screen, is what decides whether an arriving line counts as unread." | ||
| 114 | + [] | ||
| 115 | + (or (= :chat @screen) | ||
| 116 | + (and (wide?) (= :chats @screen)))) | ||
| 117 | + | ||
| 92 | ;; Whether the chat view is showing the newest line, and a counter the view | 118 | ;; Whether the chat view is showing the newest line, and a counter the view |
| 93 | ;; watches to be told to go back to it. A counter rather than a flag: a flag | 119 | ;; watches to be told to go back to it. A counter rather than a flag: a flag |
| 94 | ;; would need clearing, and there is no frame in which to clear it. | 120 | ;; would need clearing, and there is no frame in which to clear it. |
| @@ -151,7 +177,7 @@ | |||
| 151 | (swap! channels | 177 | (swap! channels |
| 152 | (fn [m] | 178 | (fn [m] |
| 153 | (let [m (ensure-channel m channel) | 179 | (let [m (ensure-channel m channel) |
| 154 | - viewing? (and (= :chat @screen) (= channel @current))] | 180 | + viewing? (and (chat-visible?) (= channel @current))] |
| 155 | (-> m | 181 | (-> m |
| 156 | (update-in [channel :messages] conj | 182 | (update-in [channel :messages] conj |
| 157 | {:from from :text text :system? (= "*" from) | 183 | {:from from :text text :system? (= "*" from) |
| @@ -171,7 +197,10 @@ | |||
| 171 | buffer stays), and opening one is a request to be in it." | 197 | buffer stays), and opening one is a request to be in it." |
| 172 | [name] | 198 | [name] |
| 173 | (reset! current name) | 199 | (reset! current name) |
| 174 | - (reset! screen :chat) | 200 | + ;; On a wide window the conversation lives in the chats screen's second |
| 201 | + ;; pane, beside the list; :chat is the narrow window's way of showing it | ||
| 202 | + ;; instead of the list, and there is nothing there to trade it for. | ||
| 203 | + (reset! screen (if (wide?) :chats :chat)) | ||
| 175 | ;; A picker belongs to the message it was opened on; carrying it into another | 204 | ;; A picker belongs to the message it was opened on; carrying it into another |
| 176 | ;; buffer would offer to react to something that is no longer on screen. | 205 | ;; buffer would offer to react to something that is no longer on screen. |
| 177 | (reset! reacting nil) | 206 | (reset! reacting nil) |
| @@ -618,6 +647,39 @@ | |||
| 618 | (reset! attachment nil) | 647 | (reset! attachment nil) |
| 619 | (discard-file! (:path a)))) | 648 | (discard-file! (:path a)))) |
| 620 | 649 | ||
| 650 | +(defn- attach! | ||
| 651 | + "Hold the picture already written to `path` — a copy of ours under | ||
| 652 | + `outgoing/` — against the next line, and start its upload. | ||
| 653 | + | ||
| 654 | + The upload runs off the UI thread and starts at once rather than at send, so | ||
| 655 | + by the time a line is written the picture is usually already up. A failure | ||
| 656 | + lands in `error` like any other, and takes the attachment with it — there is | ||
| 657 | + nothing to send and nothing to show. | ||
| 658 | + | ||
| 659 | + `filename` is what the server files it under; it says which gesture the | ||
| 660 | + picture came in by, and nothing else depends on it." | ||
| 661 | + [path filename] | ||
| 662 | + (let [did (:did @session) | ||
| 663 | + host-name @form-host | ||
| 664 | + channel @current] | ||
| 665 | + (reset! error nil) | ||
| 666 | + (clear-attachment!) | ||
| 667 | + (reset! attachment {:path path :status :uploading}) | ||
| 668 | + (future | ||
| 669 | + (try | ||
| 670 | + (let [url (upload/upload! host-name did channel path filename)] | ||
| 671 | + ;; Only if this is still the picture on screen: a reader who attached | ||
| 672 | + ;; another, or cleared it, has said what they want, and an upload | ||
| 673 | + ;; landing afterwards does not get to undo that. | ||
| 674 | + (swap! attachment #(if (= (:path %) path) | ||
| 675 | + (assoc % :url url :status :ready) | ||
| 676 | + %)) | ||
| 677 | + (when-not (= (:path @attachment) path) (discard-file! path))) | ||
| 678 | + (catch Exception e | ||
| 679 | + (swap! attachment #(if (= (:path %) path) nil %)) | ||
| 680 | + (discard-file! path) | ||
| 681 | + (reset! error (or (ex-message e) (str e)))))))) | ||
| 682 | + | ||
| 621 | (defn paste-image! | 683 | (defn paste-image! |
| 622 | "Take the picture on the clipboard and hold it against the next line. | 684 | "Take the picture on the clipboard and hold it against the next line. |
| 623 | 685 | ||
| @@ -625,37 +687,107 @@ | |||
| 625 | means — but that is a fact about the wire, not something the reader should | 687 | means — but that is a fact about the wire, not something the reader should |
| 626 | have to type around. The picture is attached: shown under the draft while | 688 | have to type around. The picture is attached: shown under the draft while |
| 627 | they write whatever they are sending it with, and turned into a link only on | 689 | they write whatever they are sending it with, and turned into a link only on |
| 628 | - the way out. | 690 | + the way out." |
| 629 | - | ||
| 630 | - The upload runs off the UI thread and starts at once rather than at send, so | ||
| 631 | - by the time a line is written the picture is usually already up. A failure | ||
| 632 | - lands in `error` like any other, and takes the attachment with it — there is | ||
| 633 | - nothing to send and nothing to show." | ||
| 634 | [] | 691 | [] |
| 635 | (let [path (paste-path)] | 692 | (let [path (paste-path)] |
| 636 | (host/mkdirs! (str (media/cache-dir) "/outgoing")) | 693 | (host/mkdirs! (str (media/cache-dir) "/outgoing")) |
| 637 | (if-not (vidya/clipboard-image-png! path) | 694 | (if-not (vidya/clipboard-image-png! path) |
| 695 | + ;; Android has no clipboard of pictures to read at all, which is the | ||
| 696 | + ;; other half of why the picker below exists. | ||
| 638 | (reset! error "No picture on the clipboard.") | 697 | (reset! error "No picture on the clipboard.") |
| 639 | - (let [did (:did @session) | 698 | + (attach! path "paste.png")))) |
| 640 | - host-name @form-host | 699 | + |
| 641 | - channel @current] | 700 | +;; ------------------------------------------------------------------ picking |
| 642 | - (reset! error nil) | 701 | + |
| 643 | - (clear-attachment!) | 702 | +;; Where the picker is looking, or nil when it is closed. A path, so the |
| 644 | - (reset! attachment {:path path :status :uploading}) | 703 | +;; browsing is just this cell moving. |
| 645 | - (future | 704 | +(defonce image-picker (atom nil)) |
| 646 | - (try | 705 | + |
| 647 | - (let [url (upload/upload! host-name did channel path "paste.png")] | 706 | +(defn- readable-dir? [path] |
| 648 | - ;; Only if this is still the paste on screen: a reader who | 707 | + (try (and (host/file-exists? path) (host/directory? path)) |
| 649 | - ;; pasted again, or cleared it, has said what they want, and an | 708 | + (catch Exception _ false))) |
| 650 | - ;; upload landing afterwards does not get to undo that. | 709 | + |
| 651 | - (swap! attachment #(if (= (:path %) path) | 710 | +(defn picker-roots |
| 652 | - (assoc % :url url :status :ready) | 711 | + "The places worth opening the picker on, on whichever platform this is. |
| 653 | - %)) | 712 | + |
| 654 | - (when-not (= (:path @attachment) path) (discard-file! path))) | 713 | + Only the ones that are actually there: a phone has no ~/Pictures and a |
| 655 | - (catch Exception e | 714 | + desktop no /sdcard, and a list of directories that are not there is a list of |
| 656 | - (swap! attachment #(if (= (:path %) path) nil %)) | 715 | + dead ends. On Android everything outside the app's own storage is behind a |
| 657 | - (discard-file! path) | 716 | + runtime permission this activity has no code to ask for, so what survives |
| 658 | - (reset! error (or (ex-message e) (str e)))))))))) | 717 | + this filter there is usually the app's own files — which is the honest |
| 718 | + answer, not a bug to paper over." | ||
| 719 | + [] | ||
| 720 | + (let [home (or (host/getenv "HOME") "") | ||
| 721 | + under (fn [base] (when (seq base) | ||
| 722 | + (map #(str base "/" %) | ||
| 723 | + ["Pictures" "Downloads" "Download" "DCIM"])))] | ||
| 724 | + (vec (distinct (filter readable-dir? | ||
| 725 | + (concat (under home) | ||
| 726 | + (under "/sdcard") | ||
| 727 | + (under "/storage/emulated/0") | ||
| 728 | + [(media/cache-dir) home])))))) | ||
| 729 | + | ||
| 730 | +(defn- png? [name] | ||
| 731 | + (str/ends-with? (str/lower-case (str name)) ".png")) | ||
| 732 | + | ||
| 733 | +(defn picker-entries | ||
| 734 | + "What `dir` holds, as `{:dirs [...] :files [...]}` of full paths. | ||
| 735 | + | ||
| 736 | + PNG only, for the same reason the media cache reads PNG only: it is what the | ||
| 737 | + tree backend paints and what the upload sends. An unreadable directory — | ||
| 738 | + which on Android is most of them — answers empty rather than throwing. | ||
| 739 | + | ||
| 740 | + Hidden entries are left out: nothing a reader means to send lives in one, and | ||
| 741 | + a home directory is unusable as a list with them in it." | ||
| 742 | + [dir] | ||
| 743 | + (let [names (try (sort (host/list-dir dir)) (catch Exception _ nil)) | ||
| 744 | + keep (remove #(str/starts-with? (str %) ".") names) | ||
| 745 | + path (fn [n] (str dir "/" n))] | ||
| 746 | + {:dirs (vec (filter readable-dir? (map path keep))) | ||
| 747 | + :files (vec (map path (filter png? keep)))})) | ||
| 748 | + | ||
| 749 | +(defn parent-dir | ||
| 750 | + "The directory above `dir`, or nil at the top." | ||
| 751 | + [dir] | ||
| 752 | + (let [up (str/join "/" (butlast (str/split (str dir) #"/")))] | ||
| 753 | + (when (and (seq up) (not= up dir) (readable-dir? up)) up))) | ||
| 754 | + | ||
| 755 | +(defn open-image-picker! | ||
| 756 | + "Open the picker, on the first place there is to look." | ||
| 757 | + [] | ||
| 758 | + (reset! error nil) | ||
| 759 | + (reset! image-picker (or (first (picker-roots)) "/"))) | ||
| 760 | + | ||
| 761 | +(defn close-image-picker! [] (reset! image-picker nil)) | ||
| 762 | + | ||
| 763 | +(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir))) | ||
| 764 | + | ||
| 765 | +(defn- copy-file! | ||
| 766 | + "Copy `from` to `to`, byte for byte." | ||
| 767 | + [from to] | ||
| 768 | + (let [in (java.io.FileInputStream. from)] | ||
| 769 | + (try | ||
| 770 | + (let [out (java.io.FileOutputStream. to)] | ||
| 771 | + (try (.write out (.readAllBytes in)) | ||
| 772 | + (finally (.close out)))) | ||
| 773 | + (finally (try (.close in) (catch Exception _ nil)))))) | ||
| 774 | + | ||
| 775 | +(defn pick-image! | ||
| 776 | + "Attach the picture at `path` and close the picker. | ||
| 777 | + | ||
| 778 | + Copied into `outgoing/` first rather than attached where it lies: the send | ||
| 779 | + drops the attachment's file when it is done with it, and what it drops has to | ||
| 780 | + be ours — not the reader's own picture, sitting in their pictures folder." | ||
| 781 | + [path] | ||
| 782 | + (let [copy (paste-path)] | ||
| 783 | + (try | ||
| 784 | + (host/mkdirs! (str (media/cache-dir) "/outgoing")) | ||
| 785 | + (copy-file! path copy) | ||
| 786 | + (close-image-picker!) | ||
| 787 | + (attach! copy (or (last (str/split (str path) #"/")) "picture.png")) | ||
| 788 | + (catch Exception e | ||
| 789 | + (discard-file! copy) | ||
| 790 | + (reset! error (str "Could not read that picture: " (or (ex-message e) e))))))) | ||
| 659 | 791 | ||
| 660 | (defn send-draft! | 792 | (defn send-draft! |
| 661 | "Send the draft, with whatever picture is attached to it. | 793 | "Send the draft, with whatever picture is attached to it. |