From e5a270aa5c9103a59338be1a92896c1780855b65 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 25 Sep 2026 23:08:41 -0700 Subject: [PATCH] Move theme, deep link and signer alert boot jobs out of the root layout, with one signerHealth store behind the toast and the status card --- .agents/skills/flotilla-architecture/SKILL.md | 22 ++- .agents/skills/flotilla-views/SKILL.md | 6 +- src/app/components/SignerStatus.svelte | 22 +-- src/app/deepLinks.ts | 73 ++++++++ src/app/signer.ts | 44 ++++- src/app/theme.ts | 44 ++++- src/routes/+layout.svelte | 167 +++--------------- 7 files changed, 200 insertions(+), 178 deletions(-) create mode 100644 src/app/deepLinks.ts diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md index 37a8a2ef..627d9314 100644 --- a/.agents/skills/flotilla-architecture/SKILL.md +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -48,9 +48,9 @@ only gate. and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app behavior takes it as a prop, or moves to `src/app/components`. - **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`, - `ShareEvent`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal - mid-flow. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor` - mounts. + `ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts` (`OpenRouterEnable`) import a component + so they can open a modal mid-flow. `editor/` holds `.svelte` files of its own (suggestion + popovers), which `makeEditor` mounts. - Nothing under `src/app` or `src/lib` imports from `src/routes`. ## Top-level layout @@ -82,7 +82,7 @@ only gate. - `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs - `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings - `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event -- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking) +- `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`) - `env.ts`: every `VITE_` value, parsed - `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a device id) @@ -121,7 +121,7 @@ only gate. (prompts for missing inbox/outbox relays) **Platform and voice** -- `push/` (notification adapters), `share.ts`, `keyboard.ts` +- `push/` (notification adapters), `share.ts`, `deepLinks.ts`, `keyboard.ts` - `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter A feature gets a `src/app/.ts` only when it has non-UI logic to hold. Polls, goals, @@ -148,14 +148,17 @@ client. `src/routes/+layout.svelte` runs the boot sequence. It imports `@app/policies` for its side effect, and `@app/storage`, which registers `storagePolicy` the same way, so every `AppPolicy` is on -`appPolicies` before anything calls `app.get()`. Then, in order: +`appPolicies` before anything calls `app.get()`. `applySavedTheme()` stamps the last theme and font +size from `localStorage` before first paint. Then, in order: 1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped `App` through `login`. 2. The device, wallet and notification stores sync to `kv`/`ss`. -3. It waits for storage, then handles a cold-start deep link. +3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a + cold-start one. 4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`, - `syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`. + `syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`, + `syncSignerAlerts`, and `syncTheme`, which mirrors the theme stores back to `localStorage`. When login swaps in a new `App`, the layout runs `syncApplicationData` again. Routes render inside `AppContainer`, behind the login gate, and `ModalContainer` renders outside it. `flotilla-state` @@ -195,7 +198,8 @@ Flotilla's own native code: without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with `registerPlugin` (`push/adapters/android.ts`, `share.ts`). - `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a - `flotilla://share` URL. `handleDeepLink` in the root layout passes that to `shareFromNative`. + `flotilla://share` URL. `handleDeepLink` in `src/app/deepLinks.ts` passes that to + `shareFromNative`. `Push` in `src/app/push/index.ts` chooses an adapter at runtime: the Android fallback, Capacitor `PushNotifications` (FCM/APNs through `PUSH_SERVER`), or web notifications. diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md index c195824a..44262526 100644 --- a/.agents/skills/flotilla-views/SKILL.md +++ b/.agents/skills/flotilla-views/SKILL.md @@ -110,9 +110,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves and a new event detail route needs an `eventRoutes` entry and a branch in `getPageTitle`. Without one the tab shows only `PLATFORM_NAME`. -Deep links arrive in `handleDeepLink` in the root layout: push-notification links (`?relay=&id=`), -the iOS share extension (the `share` host), signer returns (`x-callback-url`), and otherwise a -plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and +Deep links arrive in `handleDeepLink` in `src/app/deepLinks.ts`: push-notification links +(`?relay=&id=`), the iOS share extension (the `share` host), signer returns (`x-callback-url`), and +otherwise a plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and loads events before calling `goToEvent`. ### Adding a space content page diff --git a/src/app/components/SignerStatus.svelte b/src/app/components/SignerStatus.svelte index c6c0f237..987d0a3a 100644 --- a/src/app/components/SignerStatus.svelte +++ b/src/app/components/SignerStatus.svelte @@ -1,5 +1,5 @@ @@ -34,14 +26,12 @@
Signer Status - {#if isDisconnected} + {#if $signerHealth.disconnected} Disconnected - {:else if recentFailure.length > 3} + {:else if $signerHealth.failing} Partial Failure - {:else if recentAvg > 1000 || pending.length > 10} + {:else if $signerHealth.slow} Slow connection - {:else if recentSuccess.length === 0 && recentFailure.length > 0} - Partial Failure {:else} Ok {/if} @@ -67,7 +57,7 @@

- {#if isDisconnected} + {#if $signerHealth.disconnected} {:else} diff --git a/src/app/deepLinks.ts b/src/app/deepLinks.ts new file mode 100644 index 00000000..367220e3 --- /dev/null +++ b/src/app/deepLinks.ts @@ -0,0 +1,73 @@ +import {App} from "@capacitor/app" +import type {URLOpenListenerEvent} from "@capacitor/app" +import {goto} from "$app/navigation" +import {app} from "@app/core" +import {clearModals, getModal, navigate, pushModal} from "@app/modal" +import {goToChat, goToHome} from "@app/routes" +import {shareFromNative} from "@app/share" +import {pushToast} from "@app/toast" +import {onPushNotificationAction} from "@app/push/adapters/common" +import Search from "@app/components/Search.svelte" + +const openShortcut = (pathname: string) => { + switch (pathname) { + case "/messages": + goToChat() + break + case "/search": { + const modal = getModal() + + if (modal?.component !== Search) { + pushModal(Search, {}, {replaceState: Boolean(modal)}) + } + break + } + case "/spaces": + navigate("/spaces") + break + case "/inbox": + clearModals() + goToHome() + break + } +} + +const handleDeepLink = (rawUrl: string) => { + const url = new URL(rawUrl) + const relay = url.searchParams.get("relay") + const id = url.searchParams.get("id") + + if (relay && id) { + onPushNotificationAction({notification: {data: {relay, id}}}) + } else if (url.protocol === "flotilla:" && url.host === "shortcut") { + if (app.get().user) { + openShortcut(url.pathname) + } + } else if (url.host === "share") { + // The iOS share extension has no channel to the app, so it posts a flotilla://share url. + shareFromNative(Object.fromEntries(url.searchParams)) + } else if (url.host === "x-callback-url" && url.pathname === "/authError") { + pushToast({ + theme: "error", + message: url.searchParams.get("errorMessage") || "Signer authorization failed.", + }) + } else if (url.host !== "x-callback-url" || url.pathname !== "/authSuccess") { + goto(`${url.pathname}${url.search}${url.hash}`, {replaceState: false, noScroll: false}) + } +} + +export const setupDeepLinks = async () => { + // Capacitor emits this from onNewIntent only, and retains it until a listener is attached. + const listener = await App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => + handleDeepLink(event.url), + ) + + // A link launching the app from a killed state arrives via onCreate, which emits no appUrlOpen. + const launch = await App.getLaunchUrl() + + if (launch?.url) { + handleDeepLink(launch.url) + } + + return () => listener.remove() +} diff --git a/src/app/signer.ts b/src/app/signer.ts index 31a9697f..189a6c1f 100644 --- a/src/app/signer.ts +++ b/src/app/signer.ts @@ -1,7 +1,10 @@ -import {derived} from "svelte/store" +import {derived, get, readable} from "svelte/store" +import {ago, avg, ms, now, spec} from "@welshman/lib" import {Logger} from "@welshman/app" import type {LogMessage} from "@welshman/app" import {fromApp} from "@app/core" +import {navigate} from "@app/modal" +import {pushToast, toast} from "@app/toast" export type SignerRequest = { id: string @@ -41,3 +44,42 @@ export const signerRequests = derived( return Array.from(requestsById.values()) }, ) + +const clock = readable(now(), set => { + const interval = setInterval(() => set(now()), ms(1)) + + return () => clearInterval(interval) +}) + +// Logger timestamps are in milliseconds +export const signerHealth = derived([signerRequests, clock], ([$requests]) => { + const pending = $requests.filter(request => !request.finishedAt) + const stale = pending.filter(request => request.startedAt < ms(ago(30))) + const recent = $requests.filter(request => request.finishedAt && request.finishedAt > ms(ago(10))) + const recentFailures = recent.filter(spec({ok: false})) + const disconnected = recent.length > 0 && recentFailures.length === recent.length + + return { + disconnected, + failing: recentFailures.length > 3, + slow: + avg(recent.map(request => request.finishedAt! - request.startedAt)) > 1000 || + pending.length > 10, + unresponsive: stale.length > 10 || (recent.length > 5 && disconnected), + } +}) + +export const syncSignerAlerts = () => + derived(signerHealth, $signerHealth => $signerHealth.unresponsive).subscribe(unresponsive => { + if (unresponsive && !get(toast)) { + pushToast({ + theme: "error", + timeout: 60_000, + message: "Your signer isn't responding.", + action: { + message: "Details", + onclick: () => navigate("/settings/profile"), + }, + }) + } + }) diff --git a/src/app/theme.ts b/src/app/theme.ts index 59881e82..a5d04288 100644 --- a/src/app/theme.ts +++ b/src/app/theme.ts @@ -1,9 +1,11 @@ import twColors from "tailwindcss/colors" -import {derived, readable} from "svelte/store" -import {hash} from "@welshman/lib" +import {debounce} from "throttle-debounce" +import {derived, get, readable} from "svelte/store" +import {call, hash} from "@welshman/lib" import {synced} from "@welshman/store" import {FL_THEME} from "@app/env" import {kv} from "@app/storage" +import {userSettingsValues} from "@app/settings" const colors = [ twColors.amber[600], @@ -61,3 +63,41 @@ export const activeTheme = derived([theme, prefersDark], ([$theme, $prefersDark] return $theme }) + +// The stores syncTheme mirrors load well after first paint, so stamp their last values first. +export const applySavedTheme = () => { + const savedTheme = localStorage.getItem("theme") + const initialTheme = + savedTheme === "light" || savedTheme === "dark" + ? savedTheme + : get(prefersDark) + ? "dark" + : "light" + + document.documentElement.style.fontSize = `${localStorage.getItem("font-size") || 1.1}rem` + document.documentElement.style.colorScheme = initialTheme + document.body.setAttribute("data-fl-theme", localStorage.getItem("fl-theme") || FL_THEME) + document.body.setAttribute("data-theme", initialTheme) +} + +export const syncTheme = () => { + const unsubscribers = [ + activeTheme.subscribe($activeTheme => { + localStorage.setItem("theme", $activeTheme) + document.body.setAttribute("data-theme", $activeTheme) + document.documentElement.style.colorScheme = $activeTheme + }), + flTheme.subscribe($flTheme => { + localStorage.setItem("fl-theme", $flTheme) + document.body.setAttribute("data-fl-theme", $flTheme) + }), + userSettingsValues.subscribe( + debounce(100, $settings => { + localStorage.setItem("font-size", String($settings.font_size)) + document.documentElement.style.fontSize = `${$settings.font_size}rem` + }), + ), + ] + + return () => unsubscribers.forEach(call) +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a4aa250a..86659b70 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,18 +1,16 @@