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

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>
nandi committed 2026-08-30T18:05:00-07:00 Browse files
326bb74 parents: 9cc16c7 5326d79
modified deps.edn +6 -2
@@ -2,12 +2,16 @@
22
33 ;; glimmer owns the reactive half (ratom, components, reconciler);
44 ;; 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
66 ;; component unmounted between a cell firing and the queued render running
77 ;; used to render anyway, into widgets the backend had already freed and
88 ;; 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.
913 :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
10- :git/sha "4e733aa26343cef0478d8dfd3f2b76c0113b0969"}
14+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}
1115 nandi/glimmer-vidya {:local/root "../jolt-native/jolt/glimmer-vidya"}}
1216
1317 ;; 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: a5+ ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
6 ;; component unmounted between a cell firing and the queued render running6 ;; component unmounted between a cell firing and the queued render running
7 ;; used to render anyway, into widgets the backend had already freed and7 ;; 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 @@
632632
633633 :else nil)])
634634
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+
635690 (defn chat-screen []
636691 (let [name @s/current
637692 buffer (get @s/channels name)]
@@ -642,16 +697,23 @@
642697 ;; bottom of a screen wants is above it, not beneath it.
643698 [:vbox {:spacing 8 :margin 12 :margin-bottom 4}
644699 [: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)}])]
646707 [: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)}])]]
655717 [error-note]
656718 [call-bar name]
657719 ;; :reserve leaves room for everything below: the jump button's row, the
@@ -716,14 +778,57 @@
716778 ;; `:on-paste-empty` a keystroke the field had nothing to put in
717779 ;; itself, which is exactly the one that means "the clipboard has
718780 ;; 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!}]
719784 [:entry {:text @s/draft
720- :width-request 300
785+ :width-request 260
721786 :placeholder "Message"
722787 :on-change #(reset! s/draft %)
723788 :on-paste-empty s/paste-image!
724789 :on-activate s/send-draft!}]
725790 [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]]))
726791
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+
727832 ;; ---------------------------------------------------------------- discover
728833
729834 (defn discover-screen []
@@ -780,14 +885,20 @@
780885 ;; ---------------------------------------------------------------- shell
781886
782887 (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])))
791902
792903 (defn -main [& _]
793904 ;; Before the window: the rooms this client has been in, and a saved sign-in
@@ -812,4 +923,11 @@
812923 ;; that belongs to the state layer.
813924 (reset! av/on-dropped s/announce-leave!)
814925 (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))))
815933 (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/current691 (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 the709+ ;; 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 name711+ [: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, the719 ;; :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 in778 ;; `:on-paste-empty` a keystroke the field had nothing to put in
717 ;; itself, which is exactly the one that means "the clipboard has779 ;; 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/draft784 [:entry {:text @s/draft
720- :width-request 300785+ :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 ;; ---------------------------------------------------------------- discover832 ;; ---------------------------------------------------------------- discover
728 833
729 (defn discover-screen []834 (defn discover-screen []
@@ -780,14 +885,20 @@
780 ;; ---------------------------------------------------------------- shell885 ;; ---------------------------------------------------------------- shell
781 886
782 (defn app []887 (defn app []
783- (if @s/lightbox888+ (cond
784- [lightbox-screen]889+ @s/lightbox [lightbox-screen]
785- (case @s/screen890+ @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-in904 ;; 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 @@
8989 (defonce lightbox (atom nil)) ; {:path :url}
9090 (defonce join-input (atom ""))
9191
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+
92118 ;; Whether the chat view is showing the newest line, and a counter the view
93119 ;; watches to be told to go back to it. A counter rather than a flag: a flag
94120 ;; would need clearing, and there is no frame in which to clear it.
@@ -151,7 +177,7 @@
151177 (swap! channels
152178 (fn [m]
153179 (let [m (ensure-channel m channel)
154- viewing? (and (= :chat @screen) (= channel @current))]
180+ viewing? (and (chat-visible?) (= channel @current))]
155181 (-> m
156182 (update-in [channel :messages] conj
157183 {:from from :text text :system? (= "*" from)
@@ -171,7 +197,10 @@
171197 buffer stays), and opening one is a request to be in it."
172198 [name]
173199 (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))
175204 ;; A picker belongs to the message it was opened on; carrying it into another
176205 ;; buffer would offer to react to something that is no longer on screen.
177206 (reset! reacting nil)
@@ -618,6 +647,39 @@
618647 (reset! attachment nil)
619648 (discard-file! (:path a))))
620649
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+
621683 (defn paste-image!
622684 "Take the picture on the clipboard and hold it against the next line.
623685
@@ -625,37 +687,107 @@
625687 means but that is a fact about the wire, not something the reader should
626688 have to type around. The picture is attached: shown under the draft while
627689 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."
634691 []
635692 (let [path (paste-path)]
636693 (host/mkdirs! (str (media/cache-dir) "/outgoing"))
637694 (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.
638697 (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)))))))
659791
660792 (defn send-draft!
661793 "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 view118 ;; 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 flag119 ;; 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! channels177 (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 (-> m181 (-> m
156 (update-in [channel :messages] conj182 (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 another204 ;; 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 should687 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 while688 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 on689 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-host699+
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- (future704+(defonce image-picker (atom nil))
646- (try705+
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 who707+ (try (and (host/file-exists? path) (host/directory? path))
649- ;; pasted again, or cleared it, has said what they want, and an708+ (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 e714+ 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.