| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 1 | (ns frq.main |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 2 | "The Flutter entry point: what `frq.cosmic` is to the jolt half. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 3 | |
| 4 | Two jobs, and the first one is why `frq.io.dart` cannot install itself the |
| 5 | way `frq.io.jolt` does: the app's storage directory arrives from |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 6 | path_provider as a Future, so `main` is async, awaits it, and installs the |
| 7 | host before a widget is built. Everything under ../common asks `frq.io` for |
| 8 | the filesystem, so nothing shared can run until that has happened. |
| 9 | |
| 10 | `ensureInitialized` first, and not as a formality: path_provider is a |
| 11 | platform channel, and a channel used before the binding exists throws. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 12 | |
| 13 | The screens themselves are not written. `frq.app` is 1,834 lines of hiccup |
| 14 | over glimmer's widget tags, and glimmer is not here — Flutter brings its own |
| 15 | reconciler, so those screens are a rewrite against cljd.flutter rather than a |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 16 | port. What this paints is the proof the shared half is alive under a second |
| 17 | compiler: the clock and the saved session, read through exactly the |
| 18 | namespaces the desktop reads them through." |
| Tell the truth about TLS on the phone, and stop the guest spinner ba9e7a9 nandi 8d ago | 19 | (:require ["dart:async" :as async] |
| 20 | ["package:flutter/material.dart" :as m] |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 21 | ["package:path_provider/path_provider.dart" :as pp] |
| 22 | [cljd.flutter :as f] |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 8d ago | 23 | [frq.hiccup :as h] |
| Paint the phone in COSMIC's own theme, read from cosmic-config ba4e71b nandi 8d ago | 24 | [frq.theme :as t] |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 25 | [frq.io.dart :as host] |
| Sign in with Bluesky on the phone, browser and all 939d2ac nandi 7d ago | 26 | ;; The seam, not the implementation above: `open-url!` is asked for |
| 27 | ;; the same way the shared half asks for it. |
| 28 | [frq.io :as fio] |
| Sign on the phone: msgsig shared, crypto seamed d8fadee nandi 7d ago | 29 | [frq.crypto.dart :as crypto-dart] |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 8d ago | 30 | [frq.net.dart :as net] |
| Resolve an identity from the phone, over the shared AT Protocol core f735240 nandi 8d ago | 31 | [frq.atproto.dart :as atproto] |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 32 | [frq.clock :as clock] |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 33 | [frq.store :as store] |
| 34 | [frq.actions :as actions] |
| 35 | [frq.cells :as cells] |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 36 | [frq.screens.connect :as connect] |
| 37 | [frq.screens.chats :as chats] |
| Draw the conversation on the phone c0121fe nandi 7d ago | 38 | [frq.screens.chat :as chat] |
| Share Discover and Settings, and the frame around them 49fb82f nandi 7d ago | 39 | [frq.screens.settings :as settings] |
| Share the root, and with it the last of the screens 433c3c8 nandi 7d ago | 40 | [frq.screens.app :as screens] |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 41 | [frq.rooms :as rooms] |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 42 | [frq.members :as members] |
| 43 | [frq.oauth.core :as oauth] |
| Sign in with Bluesky on the phone, browser and all 939d2ac nandi 7d ago | 44 | [frq.oauth.dart :as oauth-dart] |
| Sign in from the phone with the handshake both halves now share dd67233 nandi 7d ago | 45 | [frq.irc.parse :as irc] |
| 46 | [frq.irc.handshake :as handshake])) |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 47 | |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 8d ago | 48 | (defonce ^:private lines (atom [])) |
| 49 | (defonce ^:private conn (atom nil)) |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 8d ago | 50 | |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 51 | (defonce ^:private last-line (atom nil)) |
| 52 | |
| Ask for the backlog on the phone, as the desktop does 7dff3cf nandi 7d ago | 53 | (def ^:private history-limit |
| 54 | "How many lines of backlog to ask a room for, as `frq.state` asks for them." |
| 55 | 100) |
| 56 | |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 57 | (defn- room! |
| 58 | "The little of `frq.state/apply-msg!` the conversation list needs: the two |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 59 | messages that make a room appear and give it a last line, and everything |
| 60 | that says who is in it. |
| 61 | |
| 62 | The membership half is `frq.members`, unchanged from what the desktop folds |
| 63 | — the server is the one deciding who is in a room, and it tells both halves |
| 64 | in the same words." |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 65 | [m] |
| 66 | (let [cmd (str (:command m)) |
| 67 | params (vec (:params m)) |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 68 | who (irc/nick-of (:prefix m)) |
| 69 | me? (= who (str @cells/form-nick)) |
| 70 | ensure (fn [m name] (update m name #(merge {:name name :messages [] :unread 0} %)))] |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 71 | (cond |
| 72 | (= "JOIN" cmd) |
| 73 | (let [name (first params)] |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 74 | (swap! cells/channels |
| 75 | #(-> (ensure % name) |
| 76 | (members/add-user name who) |
| 77 | (cond-> me? (update name merge {:joined? true |
| 78 | :accessed (clock/now-ms)})))) |
| 79 | ;; Only our own JOIN opens the room. Someone else arriving in a |
| 80 | ;; channel used to move the reader into it. |
| 81 | (when me? (reset! cells/current name))) |
| 82 | |
| 83 | ;; 353 is the roll, in as many lines as it takes; 366 ends it. |
| 84 | (= "353" cmd) |
| 85 | (let [name (nth params 2 nil)] |
| 86 | (when name |
| 87 | (swap! cells/channels #(members/with-names (ensure % name) name (last params))))) |
| 88 | |
| 89 | (= "366" cmd) |
| 90 | (let [name (nth params 1 nil)] |
| Ask for the backlog on the phone, as the desktop does 7dff3cf nandi 7d ago | 91 | (when name |
| 92 | (swap! cells/channels members/names-done name) |
| 93 | ;; End of NAMES, and the only moment this client knows a room is |
| 94 | ;; fully arrived. freeq re-joins an authenticated user's channels at |
| 95 | ;; registration and leaves the backlog for the client to ask for, so |
| 96 | ;; a room that reaches here with an empty buffer has no history |
| 97 | ;; coming unless we ask — which is why nothing but new lines ever |
| 98 | ;; appeared here. Same request `frq.state` makes on 366, and the |
| 99 | ;; replayed lines come back as ordinary PRIVMSGs the branch below |
| 100 | ;; already folds. |
| 101 | (when (and @conn (empty? (get-in @cells/channels [name :messages]))) |
| 102 | (net/send-line! @conn |
| 103 | (str "CHATHISTORY LATEST " name " * " history-limit))))) |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 104 | |
| 105 | (= "PART" cmd) |
| 106 | (swap! cells/channels members/remove-user (first params) who) |
| 107 | |
| 108 | (= "KICK" cmd) |
| 109 | (swap! cells/channels members/remove-user (first params) (nth params 1 nil)) |
| 110 | |
| 111 | (= "QUIT" cmd) |
| 112 | (swap! cells/channels members/remove-everywhere who) |
| 113 | |
| 114 | (= "NICK" cmd) |
| 115 | (do (swap! cells/channels members/rename-user who (last params)) |
| 116 | ;; Our own rename is the server settling what we are called — it |
| 117 | ;; hands a guest a name of its choosing, so this is the usual way |
| 118 | ;; the nick on screen becomes the real one. |
| 119 | (when me? (reset! cells/form-nick (last params)))) |
| 120 | |
| 121 | (= "MODE" cmd) |
| 122 | (swap! cells/channels members/with-mode |
| 123 | (first params) (nth params 1 "") (drop 2 params)) |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 124 | |
| 125 | (= "PRIVMSG" cmd) |
| 126 | (let [target (first params) |
| 127 | text (last params) |
| 128 | name (if (rooms/dm? target) who target)] |
| 129 | (swap! cells/channels update name |
| 130 | #(-> (merge {:name name :messages [] :unread 0} %) |
| 131 | (update :messages conj {:from who :text text}))))))) |
| 132 | |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 8d ago | 133 | (defn- note! [m] |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 134 | (reset! last-line (str (:command m) " " (last (:params m)))) |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 135 | (swap! lines (fn [v] (vec (take-last 8 (conj v m)))))) |
| 136 | |
| Tell the truth about TLS on the phone, and stop the guest spinner ba9e7a9 nandi 8d ago | 137 | (def ^:private registration-failed |
| 138 | "Numerics that mean registration will never complete. |
| 139 | |
| 140 | Only 001 used to clear `connecting?`, so anything the server said instead of |
| 141 | it spun for ever with no message — and the likeliest of them is the dullest: |
| 142 | 433, the nick is already in use, which is what a second phone or a session |
| 143 | left open elsewhere gets." |
| 144 | #{"431" ; no nickname given |
| 145 | "432" ; erroneous nickname |
| 146 | "433" ; nickname in use |
| 147 | "436" ; nick collision |
| 148 | "464" ; password incorrect |
| 149 | "465" ; banned |
| 150 | "ERROR"}) |
| 151 | |
| 152 | (defonce ^:private attempt (atom 0)) |
| 153 | |
| Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago | 154 | ;; Ticked whenever any cell changes, so one `:watch` covers all of them. |
| 155 | ;; |
| 156 | ;; What this replaced was a list of cells named by hand in the widget below, |
| 157 | ;; and everything not on it was a control that flipped its cell and repainted |
| 158 | ;; nothing — People, Overview and hide join/part all did. |
| 159 | (defonce ^:private repaint (atom 0)) |
| 160 | |
| 161 | (defn- watch-cells! |
| 162 | "Every cell in `frq.cells` ticking `repaint` when it changes. |
| 163 | |
| 164 | Called from `main` rather than run from a top-level `defonce`, and that is |
| 165 | not a style preference: ClojureDart compiles a `def` to a Dart top-level |
| 166 | variable, Dart initialises those on first read, and a `defonce` whose value |
| 167 | nothing ever reads simply never runs. The first attempt at this installed no |
| 168 | watches at all and printed nothing to say so." |
| 169 | [] |
| 170 | (doseq [c (cells/all-cells)] |
| 171 | (add-watch c ::repaint |
| 172 | (fn [_ _ old new] |
| 173 | (when (not= old new) (swap! repaint inc)) |
| 174 | nil)))) |
| 175 | |
| Sign in from the phone with the handshake both halves now share dd67233 nandi 7d ago | 176 | (defonce ^:private caps |
| 177 | ;; What the server has agreed to on this connection. `frq.irc.handshake` |
| 178 | ;; holds none of it: it takes the set and hands a new one back, so the |
| 179 | ;; desktop can keep it in an atom on the connection and the phone here. |
| 180 | (atom #{})) |
| 181 | |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 182 | (defonce ^:private closing? |
| 183 | ;; Whether the close about to arrive is one we asked for. Without it a tap |
| 184 | ;; on Disconnect comes back as "closed by the server", which is both untrue |
| 185 | ;; and alarming. |
| 186 | (atom false)) |
| 187 | |
| Tell the truth about TLS on the phone, and stop the guest spinner ba9e7a9 nandi 8d ago | 188 | (defn- fail! [why] |
| 189 | (reset! cells/connecting? false) |
| 190 | (reset! cells/status "Not connected") |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 191 | (reset! cells/screen :connect) |
| Tell the truth about TLS on the phone, and stop the guest spinner ba9e7a9 nandi 8d ago | 192 | (reset! cells/error why)) |
| 193 | |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 194 | (defn ^:async bluesky-session! |
| 195 | "A web-token session from freeq's auth broker. |
| 196 | |
| 197 | Only the half that needs no browser. A saved broker token is durable and the |
| 198 | web-token minted from it is single-use, so a return visit is one POST to |
| 199 | /session and `frq.oauth.core` already says what to send and what the answer |
| 200 | means. Getting the *first* broker token is the other half: the browser hands |
| 201 | it back to a loopback listener on the desktop, and a phone has nowhere for |
| 202 | that to land — it wants an Android app link, which is a decision about what |
| 203 | the broker will redirect to and not a porting problem. So that case says so |
| 204 | rather than quietly connecting as somebody else." |
| 205 | [] |
| 206 | (if-let [bt @cells/broker-token] |
| 207 | (do |
| 208 | (reset! cells/status "Resuming your session…") |
| 209 | (let [tokens (oauth/refresh-session-parse |
| 210 | bt |
| 211 | (await (atproto/fetch |
| 212 | (oauth/refresh-session-req oauth/default-broker bt))))] |
| 213 | (reset! cells/broker-token (:broker-token tokens)) |
| 214 | ;; On every sign-in and not only the first: /session can hand back a |
| 215 | ;; rotated broker token, and the old one may stop working the moment |
| 216 | ;; it does. |
| 217 | (store/save-session! tokens) |
| 218 | (assoc tokens :kind :web-token))) |
| Sign in with Bluesky on the phone, browser and all 939d2ac nandi 7d ago | 219 | ;; No saved token: the browser leg. `frq.oauth.dart` binds the loopback, |
| 220 | ;; and what comes back through it is the first broker token. |
| 221 | (let [tokens (await (oauth-dart/await-callback! |
| 222 | oauth/default-broker |
| 223 | (str @cells/form-handle) |
| 224 | (fn [url] |
| 225 | (reset! cells/login-url url) |
| 226 | (reset! cells/status |
| 227 | (if (fio/open-url! url) |
| 228 | "Waiting for the browser…" |
| 229 | "Open the sign-in link below to continue")) |
| 230 | nil)))] |
| 231 | (reset! cells/login-url nil) |
| 232 | (reset! cells/broker-token (:broker-token tokens)) |
| 233 | (store/save-session! tokens) |
| 234 | (assoc tokens :kind :web-token)))) |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 235 | |
| 236 | (defn ^:async sign-in! |
| 237 | "Fill `cells/session` for the mode that was chosen, or say why not. |
| 238 | |
| 239 | True when the connection may go ahead. A guest carries no session, and must |
| 240 | not carry the last one either: a leftover would have `frq.irc.handshake` ask |
| 241 | for sasl and authenticate as whoever signed in before." |
| 242 | [] |
| 243 | (case @cells/auth-mode |
| 244 | :guest (do (reset! cells/session nil) true) |
| 245 | (try |
| 246 | (reset! cells/connecting? true) |
| 247 | (reset! cells/error nil) |
| 248 | (reset! cells/session |
| 249 | (await (if (= :bluesky @cells/auth-mode) |
| 250 | (bluesky-session!) |
| 251 | (do (reset! cells/status "Signing in…") |
| 252 | (atproto/create-session @cells/form-handle |
| 253 | @cells/form-app-password))))) |
| Sign in with Bluesky on the phone, browser and all 939d2ac nandi 7d ago | 254 | ;; An authenticated connection still needs a nick — the DID is the |
| 255 | ;; identity, the nick is only what the channel calls you. Same order the |
| 256 | ;; desktop picks it in: what the broker said, else the first label of the |
| 257 | ;; handle, else whatever is in the box. |
| 258 | (let [sess @cells/session |
| 259 | nick (or (:nick sess) |
| 260 | (first (.split (str (or (:handle sess) "")) ".")) |
| 261 | nil)] |
| 262 | (when (seq (str (or (:handle sess) ""))) |
| 263 | (reset! cells/form-handle (:handle sess))) |
| 264 | (when (seq (str (or nick ""))) |
| 265 | (reset! cells/form-nick nick)) |
| 266 | ;; The password did its work at the PDS; do not keep it. |
| 267 | (reset! cells/form-app-password "")) |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 268 | true |
| 269 | (catch Object e |
| 270 | (reset! cells/session nil) |
| 271 | ;; The message, not the exception: `frq.atproto.core` puts the body it |
| 272 | ;; could not read in the ex-data, and a PDS that answers a resolve with |
| 273 | ;; an HTML error page puts the whole page there. |
| 274 | (fail! (str "Sign-in failed: " (or (ex-message e) e))) |
| 275 | false)))) |
| 276 | |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 277 | (defn ^:async connect! |
| 278 | "What `frq.actions/connect!` is on the phone. |
| 279 | |
| 280 | The desktop's is `frq.state/connect!` — jolt's TLS, SASL, a reader thread. |
| 281 | This is the dart:io one, and it reads the same cells the screen wrote: the |
| 282 | host, the port and the TLS tick are `frq.cells`, filled in by the entry and |
| 283 | the checkbutton on screen. |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 8d ago | 284 | |
| Sign in from the phone with the handshake both halves now share dd67233 nandi 7d ago | 285 | Guest and app-password both. The handshake itself is not here: it is |
| 286 | `frq.irc.handshake`, which answers each line with the lines to send back, |
| 287 | and all this does is write them." |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 288 | [] |
| Tell the truth about TLS on the phone, and stop the guest spinner ba9e7a9 nandi 8d ago | 289 | ;; Close whatever was open first. Connecting twice left the old socket |
| 290 | ;; holding the nick, so the second attempt got 433 from its own predecessor. |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 291 | (when-let [c @conn] |
| 292 | (reset! closing? true) |
| 293 | (net/close! c) |
| 294 | (reset! conn nil)) |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 295 | ;; Whatever identity was asked for, settled before the socket opens. Both |
| 296 | ;; of the signed-in modes are an HTTPS round trip that has nothing to do |
| 297 | ;; with IRC, and a failure in either must stop here: connecting anyway lands |
| 298 | ;; us on the server as a guest, which looks like a success and is not the |
| 299 | ;; one that was asked for. Bluesky did exactly that until it was asked. |
| 300 | (when (await (sign-in!)) |
| 301 | (let [n (swap! attempt inc)] |
| Sign in from the phone with the handshake both halves now share dd67233 nandi 7d ago | 302 | (reset! caps #{}) |
| 303 | (reset! cells/connecting? true) |
| 304 | (reset! cells/error nil) |
| 305 | (reset! cells/status (str "Connecting to " @cells/form-host "…")) |
| 306 | (reset! lines []) |
| 307 | ;; A watchdog, because "no answer at all" is a real outcome: a TLS |
| 308 | ;; handshake that hangs, or a server that accepts the socket and says |
| 309 | ;; nothing, leaves every callback below unfired. |
| 310 | (.then (async/Future.delayed (Duration .seconds 20)) |
| 311 | (fn [_] |
| 312 | (when (and @cells/connecting? (= n @attempt)) |
| 313 | (fail! "No answer from the server after 20s")))) |
| 314 | (try |
| 315 | (let [sock (await (net/connect! |
| 316 | {:host @cells/form-host |
| 317 | ;; The cell is a string, because it is what an |
| 318 | ;; :entry holds. |
| 319 | :port (or (parse-long (str @cells/form-port)) 6697) |
| 320 | :tls? (boolean @cells/form-tls?) |
| 321 | :on-msg (fn [m] |
| 322 | (note! m) |
| 323 | (room! m) |
| 324 | ;; Capability negotiation and SASL, out of |
| 325 | ;; common/ — the same steps the desktop |
| 326 | ;; takes, and all this does is write what |
| 327 | ;; they answer with. |
| 328 | (let [{:keys [send] next-caps :caps} |
| 329 | (handshake/step {:session @cells/session |
| 330 | :caps @caps} |
| 331 | m)] |
| 332 | (reset! caps next-caps) |
| 333 | (doseq [line send] |
| 334 | (when-let [c @conn] (net/send-line! c line)))) |
| 335 | (let [cmd (str (:command m)) |
| 336 | text (str (last (:params m)))] |
| 337 | (cond |
| 338 | (= "001" cmd) |
| 339 | (do (reset! cells/connecting? false) |
| 340 | (reset! cells/error nil) |
| 341 | (reset! cells/status |
| 342 | (str "Connected as " @cells/form-nick)) |
| 343 | ;; Off the connect screen, as |
| 344 | ;; frq.state does on 001. Without |
| 345 | ;; this a successful connect just |
| 346 | ;; puts the Connect button back |
| 347 | ;; and looks exactly like a |
| 348 | ;; failure. |
| 349 | (reset! cells/screen :chats) |
| 350 | (when-let [c @conn] |
| 351 | (net/send-line! c "JOIN #test"))) |
| 352 | |
| 353 | (contains? registration-failed cmd) |
| 354 | (fail! (if (= "433" cmd) |
| 355 | (str "Nick " @cells/form-nick |
| 356 | " is already in use — try another") |
| 357 | (str cmd " " text))) |
| 358 | |
| 359 | ;; Something is happening; say so |
| 360 | ;; rather than sit on one message. |
| 361 | @cells/connecting? |
| 362 | (reset! cells/status (str "… " cmd))))) |
| 363 | :on-close (fn [why] |
| 364 | (reset! conn nil) |
| 365 | ;; Say what the last thing seen was. A |
| 366 | ;; connection that registers and then |
| 367 | ;; drops is a different bug from one |
| 368 | ;; that never registers, and only the |
| 369 | ;; last line apart tells them. |
| 370 | (if @closing? |
| 371 | (reset! closing? false) |
| 372 | (fail! (str (or why "closed by the server") |
| 373 | " (after " (count @lines) |
| 374 | " lines, last: " @last-line ")"))))}))] |
| 375 | (reset! conn sock) |
| 376 | ;; CAP first, then registration — the order the server expects, and the |
| 377 | ;; order `frq.irc` uses. |
| 378 | (net/send-line! sock "CAP LS 302") |
| 379 | (let [nick (if-let [h (:handle @cells/session)] h @cells/form-nick)] |
| 380 | (net/send-line! sock (str "NICK " nick)) |
| 381 | (net/send-line! sock (str "USER " nick " 0 * :frq")))) |
| 382 | ;; Object, not Exception. Dart keeps Error and Exception in separate |
| 383 | ;; hierarchies, so a TypeError is not an Exception and would leave |
| 384 | ;; `connecting?` true for ever. |
| 385 | (catch Object e |
| 386 | (fail! (str e))))))) |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 387 | |
| 388 | (defn- disconnect! [] |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 389 | (reset! closing? true) |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 390 | (when-let [c @conn] (net/close! c)) |
| 391 | (reset! conn nil) |
| 392 | (reset! cells/connecting? false) |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 393 | (reset! cells/status "Not connected") |
| 394 | (reset! cells/screen :connect)) |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 8d ago | 395 | |
| Resolve an identity from the phone, over the shared AT Protocol core f735240 nandi 8d ago | 396 | (defonce ^:private handle (atom "nandi-test.bsky.social")) |
| 397 | (defonce ^:private identity-out (atom nil)) |
| 398 | |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 399 | (defn ^:async resolve-identity! [] |
| Resolve an identity from the phone, over the shared AT Protocol core f735240 nandi 8d ago | 400 | (reset! identity-out ["resolving…"]) |
| 401 | (try |
| 402 | (let [h @handle |
| 403 | did (await (atproto/resolve-handle h)) |
| 404 | pds (await (atproto/pds-endpoint did))] |
| 405 | (reset! identity-out [(str "did " did) (str "pds " pds)])) |
| 406 | (catch Exception e |
| 407 | (reset! identity-out [(str "failed: " e)])))) |
| 408 | |
| Go somewhere when the connect succeeds 19a2ec6 nandi 8d ago | 409 | (defn- connected-screen |
| 410 | "Where a connect lands, until `frq.app`'s chats screen is portable. |
| 411 | |
| 412 | Not much, and honest about it: the status, what the server said, and the way |
| 413 | back. The desktop goes to :chats here — that screen reads rooms, messages, |
| 414 | avatars and the media plane, which is most of what is not ported yet." |
| 415 | [] |
| 416 | [:page {:max-width 520} |
| 417 | [:title {:label "frq"}] |
| 418 | [:status {:live true :label @cells/status}] |
| 419 | [:card {} |
| 420 | [:title-2 {:label "What the server said"}] |
| 421 | (if (empty? @lines) |
| 422 | [:dim-label {:label "nothing yet"}] |
| 423 | (for [[i m] (map-indexed vector @lines)] |
| 424 | [:label {:key i |
| 425 | :label (str (:command m) |
| 426 | (when-let [p (seq (:params m))] |
| 427 | (str " " (last p))))}]))] |
| 428 | [:hbox {:spacing 8} |
| 429 | [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]] |
| 430 | [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]]) |
| 431 | |
| Open a channel, and give it somewhere to open onto 8c903d8 nandi 7d ago | 432 | (defn- send-draft! [] |
| Draw the conversation on the phone c0121fe nandi 7d ago | 433 | (let [text (str @cells/draft) |
| Open a channel, and give it somewhere to open onto 8c903d8 nandi 7d ago | 434 | room (str @cells/current)] |
| 435 | (when (and (seq text) (seq room) @conn) |
| 436 | (net/send-line! @conn (str "PRIVMSG " room " :" text)) |
| Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago | 437 | ;; Echoed locally only when the server will not echo it back. With |
| 438 | ;; `echo-message` negotiated it does — that is what the cap is for, and |
| 439 | ;; it is how a client learns the msgid of its own line — so adding one |
| 440 | ;; here as well put every sent message in the room twice. |
| 441 | (when-not (handshake/acked? @caps "echo-message") |
| 442 | (swap! cells/channels update room |
| 443 | #(update % :messages conj {:from @cells/form-nick :text text}))) |
| Draw the conversation on the phone c0121fe nandi 7d ago | 444 | (reset! cells/draft "")))) |
| Open a channel, and give it somewhere to open onto 8c903d8 nandi 7d ago | 445 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 446 | (defn ^:async main [] |
| 447 | (m/WidgetsFlutterBinding.ensureInitialized) |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 448 | ;; Layout errors do not come back as exceptions — they happen after the |
| 449 | ;; build, so nothing can catch them, and what they leave is a blank screen |
| 450 | ;; and an empty log. Flutter routes them here instead. |
| 451 | (set! (.-onError m/FlutterError) |
| 452 | (fn [details] |
| 453 | (m/debugPrint (str "frq: FLUTTER ERROR " (.-exception details))) |
| 454 | (m/debugPrint (str "frq: LIBRARY " (.-library details) |
| 455 | " CONTEXT " (.-context details))))) |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 456 | (let [dir (.-path (await (pp/getApplicationSupportDirectory)))] |
| 457 | (host/install! dir) |
| Sign on the phone: msgsig shared, crypto seamed d8fadee nandi 7d ago | 458 | ;; Ed25519 for the reactions freeq will not take on trust. Verified |
| 459 | ;; against RFC 8032 test 1 on the device: the same public key and the |
| 460 | ;; same signature OpenSSL gives on the desktop, so a signature minted |
| 461 | ;; here verifies the same way at the server. |
| 462 | (crypto-dart/install!) |
| Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago | 463 | ;; Before any widget is built: a cell that changes before its watch is on |
| 464 | ;; is a change the screen never hears about. |
| 465 | (watch-cells!) |
| Sign in with Bluesky on the phone, browser and all 939d2ac nandi 7d ago | 466 | ;; A sign-in that already happened. Only the durable broker token comes |
| 467 | ;; back — the connection mints a fresh web-token from it — so this is not |
| 468 | ;; a session, it is the means to ask for one. |
| 469 | (when-let [saved (store/load-session)] |
| 470 | (reset! cells/broker-token (:broker-token saved)) |
| 471 | (when (seq (str (or (:handle saved) ""))) |
| 472 | (reset! cells/form-handle (:handle saved))) |
| 473 | (when (seq (str (or (:nick saved) ""))) |
| 474 | (reset! cells/form-nick (:nick saved))) |
| 475 | (reset! cells/auth-mode :bluesky) |
| 476 | (reset! cells/status (str "Signed in as " (:handle saved) " — Connect to resume"))) |
| Share the connect screen, and the cells under it 80eb088 nandi 8d ago | 477 | ;; What the shared screen calls. The desktop installs frq.state's |
| 478 | ;; reducers here; this installs the phone's. |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 479 | (actions/install! |
| 480 | {:connect! connect! |
| 481 | :disconnect! disconnect! |
| 482 | :connected? (fn [] (some? @conn)) |
| 483 | :join! (fn [name] |
| 484 | (when-let [c @conn] |
| 485 | (when (seq (str name)) |
| 486 | (net/send-line! c (str "JOIN " name))))) |
| 487 | :leave-channel! (fn [name] |
| 488 | (when-let [c @conn] (net/send-line! c (str "PART " name))) |
| 489 | (swap! cells/channels dissoc name)) |
| Draw the conversation on the phone c0121fe nandi 7d ago | 490 | :send-draft! send-draft! |
| 491 | :wide? (fn [] false) |
| 492 | :desktop? (fn [] false) |
| 493 | :mine? (fn [from] (= (str from) (str @cells/form-nick))) |
| 494 | :message-by-id (fn [room id] |
| 495 | (->> (get-in @cells/channels [room :messages]) |
| 496 | (filter #(= id (:id %))) |
| 497 | first)) |
| Share who is in the room, and stop Bluesky signing in as somebody else c6506f2 nandi 7d ago | 498 | :member-count (fn [room] (members/member-count @cells/channels room)) |
| 499 | :member-list (fn [room] (members/member-list @cells/channels room)) |
| Draw the conversation on the phone c0121fe nandi 7d ago | 500 | :toggle-users! (fn [] (swap! cells/show-users? not)) |
| 501 | :toggle-overview! (fn [] (swap! cells/overview? not)) |
| 502 | :toggle-chat-list! (fn [] (swap! cells/hide-chat-list? not)) |
| Share Discover and Settings, and the frame around them 49fb82f nandi 7d ago | 503 | :toggle-hide-join-part! (fn [] (swap! cells/hide-join-part? not)) |
| 504 | :quit! (fn [] nil) |
| 505 | :forget-session! (fn [] |
| 506 | (reset! cells/broker-token nil) |
| 507 | (reset! cells/session nil)) |
| Draw the conversation on the phone c0121fe nandi 7d ago | 508 | :jump-to-present! (fn [] (reset! cells/at-present? true)) |
| Open a channel, and give it somewhere to open onto 8c903d8 nandi 7d ago | 509 | :open-channel! (fn [name] |
| 510 | (reset! cells/current name) |
| 511 | ;; `frq.state/open-channel!` does this and more: it |
| 512 | ;; marks the buffer read, remembers the room list and |
| 513 | ;; asks for NAMES. What it also does, and what made |
| 514 | ;; Open look like a no-op here, is go to the |
| 515 | ;; conversation — and join it on the way, because a row |
| 516 | ;; can outlive the membership behind it. |
| 517 | (swap! cells/channels update name |
| 518 | #(merge {:name name :messages [] :unread 0} %)) |
| 519 | (reset! cells/screen :chat) |
| 520 | (let [buffer (get @cells/channels name)] |
| 521 | (when (and @conn |
| 522 | (.startsWith (str name) "#") |
| 523 | (not (:joined? buffer))) |
| 524 | (net/send-line! @conn (str "JOIN " name)))))}) |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 525 | (f/run |
| Paint the phone in COSMIC's own theme, read from cosmic-config ba4e71b nandi 8d ago | 526 | (m/MaterialApp .title "frq" .theme (t/app-theme)) |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 527 | .home |
| Say "frq" once, and let the guest sign-in finish 232a455 nandi 8d ago | 528 | ;; No AppBar. `connect-screen` opens with [:title {:label "frq"}] — the |
| 529 | ;; desktop has no bar above it either, the window's title is the |
| 530 | ;; window's — so a bar here says "frq" twice. |
| 531 | (m/Scaffold) |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 8d ago | 532 | .body |
| Say "frq" once, and let the guest sign-in finish 232a455 nandi 8d ago | 533 | m/SafeArea |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 8d ago | 534 | (f/widget |
| Repaint for every cell, follow the conversation, and send once 8ead4ea nandi 7d ago | 535 | ;; One watch, not twenty. `lines` is a local atom and not a cell, so it |
| 536 | ;; is named beside the tick; everything under `frq.cells` arrives |
| 537 | ;; through `repaint`. |
| 538 | :watch [tick repaint ls lines] |
| Share the root, and with it the last of the screens 433c3c8 nandi 7d ago | 539 | (h/render |
| 540 | ;; `frq.screens.app` decides which screen shows, the same way it does |
| 541 | ;; on the desktop. The phone was switching by hand until this moved. |
| 542 | [screens/app]))))) |