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

This commit is contained in:
Jon Staab 2026-09-25 23:08:41 -07:00
parent 9254238d81
commit e5a270aa5c
7 changed files with 200 additions and 178 deletions

View file

@ -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/<feature>.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.

View file

@ -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

View file

@ -1,5 +1,5 @@
<script lang="ts">
import {avg, spec} from "@welshman/lib"
import {spec} from "@welshman/lib"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
@ -9,21 +9,13 @@
import LogOut from "@app/components/LogOut.svelte"
import {pushModal} from "@app/modal"
import {session} from "@app/core"
import {signerRequests} from "@app/signer"
import {signerHealth, signerRequests} from "@app/signer"
import PomadeSessions from "@app/components/PomadeSessions.svelte"
const finished = $derived($signerRequests.filter(request => request.finishedAt))
const pending = $derived($signerRequests.filter(request => !request.finishedAt))
const success = $derived(finished.filter(spec({ok: true})))
const failure = $derived(finished.filter(spec({ok: false})))
const cutoff = $derived(Date.now() - 10_000)
const recentCompleted = $derived(finished.filter(request => request.finishedAt! > cutoff))
const recentAvg = $derived(avg(recentCompleted.map(r => r.finishedAt! - r.startedAt)))
const recentFailure = $derived(recentCompleted.filter(request => !request.ok))
const recentSuccess = $derived(recentCompleted.filter(request => request.ok))
const isDisconnected = $derived(
recentCompleted.length > 0 && recentFailure.length === recentCompleted.length,
)
const logout = () => pushModal(LogOut)
</script>
@ -34,14 +26,12 @@
<div class="flex items-center justify-between">
<span class="text-xl font-bold">Signer Status</span>
<span class="flex items-center gap-2">
{#if isDisconnected}
{#if $signerHealth.disconnected}
<Icon icon={CloseCircle} class="text-error" size={4} /> Disconnected
{:else if recentFailure.length > 3}
{:else if $signerHealth.failing}
<Icon icon={Danger} class="text-warning" size={4} /> Partial Failure
{:else if recentAvg > 1000 || pending.length > 10}
{:else if $signerHealth.slow}
<Icon icon={ClockCircle} class="text-warning" size={4} /> Slow connection
{:else if recentSuccess.length === 0 && recentFailure.length > 0}
<Icon icon={Danger} class="text-warning" size={4} /> Partial Failure
{:else}
<Icon icon={CheckCircle} class="text-success" size={4} /> Ok
{/if}
@ -67,7 +57,7 @@
</p>
</div>
</div>
{#if isDisconnected}
{#if $signerHealth.disconnected}
<Button class="button button-error" onclick={logout}>Logout to Reconnect</Button>
{:else}
<PomadeSessions />

73
src/app/deepLinks.ts Normal file
View file

@ -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()
}

View file

@ -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"),
},
})
}
})

View file

@ -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)
}

View file

@ -1,18 +1,16 @@
<script lang="ts">
import "@lib/components/theme.css"
import "@welshman/editor/index.css"
import {debounce} from "throttle-debounce"
import * as nip19 from "nostr-tools/nip19"
import type {Unsubscriber} from "svelte/store"
import {get} from "svelte/store"
import {App, type URLOpenListenerEvent} from "@capacitor/app"
import {App} from "@capacitor/app"
import {Capacitor} from "@capacitor/core"
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
import {dev} from "$app/environment"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {context as pomadeContext} from "@pomade/core"
import {sync, throttled} from "@welshman/store"
import {sync} from "@welshman/store"
import {setNip55Plugin} from "@welshman/signer"
import * as util from "@welshman/util"
import * as lib from "@welshman/lib"
@ -22,45 +20,34 @@
import Startup from "@app/components/Startup.svelte"
import ModalContainer from "@app/components/ModalContainer.svelte"
import * as core from "@app/core"
import {goToChat, goToHome, setupHistory} from "@app/routes"
import Search from "@app/components/Search.svelte"
import {clearModals, getModal, navigate, pushModal} from "@app/modal"
import {app} from "@app/core"
import {setupHistory} from "@app/routes"
import {setupAnalytics} from "@app/analytics"
import {setupLogging} from "@app/logger"
import "@app/policies"
import {restoreSession} from "@app/session"
import {signerRequests} from "@app/signer"
import {syncSignerAlerts} from "@app/signer"
import {wallet} from "@app/lightning"
import {kv, ss, storage} from "@app/storage"
import {device} from "@app/device"
import {userSettingsValues, notificationSettings} from "@app/settings"
import {notificationSettings} from "@app/settings"
import {needsKeyBackup} from "@app/healthChecks"
import {setupShareIntents, shareFromNative} from "@app/share"
import {setupShareIntents} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync"
import * as env from "@app/env"
import {activeTheme, flTheme, theme} from "@app/theme"
import {toast, pushToast} from "@app/toast"
import {applySavedTheme, flTheme, syncTheme, theme} from "@app/theme"
import * as notifications from "@app/notifications"
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
import {Push} from "@app/push"
import {onPushNotificationAction, pushState} from "@app/push/adapters/common"
import {pushState} from "@app/push/adapters/common"
import {setupDeepLinks} from "@app/deepLinks"
import {syncKeyboard} from "@app/keyboard"
import {getPageTitle} from "@app/title"
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
const {children} = $props()
// The stores these mirror are in indexeddb, which loads well after first paint.
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const savedTheme = localStorage.getItem("theme")
const initialTheme =
savedTheme === "light" || savedTheme === "dark" ? savedTheme : prefersDark ? "dark" : "light"
// @ts-ignore
document.documentElement.style["font-size"] = `${localStorage.getItem("font-size") || 1.1}rem`
document.documentElement.style.colorScheme = initialTheme
document.body.setAttribute("data-fl-theme", localStorage.getItem("fl-theme") || env.FL_THEME)
document.body.setAttribute("data-theme", initialTheme)
applySavedTheme()
// Add stuff to window for convenience
Object.assign(window, {get, nip19, theme, ...plugins, ...lib, ...util, ...core})
@ -73,72 +60,6 @@
setNip55Plugin(NostrSignerPlugin)
}
// Handles warm-start links from appUrlOpen and cold-start links from getLaunchUrl.
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}}})
return
}
if (url.protocol === "flotilla:" && url.host === "shortcut") {
if (core.app.get().user) {
switch (url.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
}
}
return
}
// The iOS share extension has no channel to the app, so it posts a flotilla://share url.
if (url.host === "share") {
shareFromNative(Object.fromEntries(url.searchParams))
return
}
if (url.host === "x-callback-url") {
if (url.pathname === "/authError") {
const errorMessage = url.searchParams.get("errorMessage")
pushToast({
theme: "error",
message: errorMessage || "Signer authorization failed.",
})
}
if (["/authSuccess", "/authError"].includes(url.pathname)) {
return
}
}
const target = `${url.pathname}${url.search}${url.hash}`
goto(target, {replaceState: false, noScroll: false})
}
// Capacitor emits this from onNewIntent only, so it covers a warm start and not a cold one.
App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => handleDeepLink(event.url))
// Handle back button on mobile
App.addListener("backButton", () => {
if (window.history.length > 1) {
@ -207,12 +128,8 @@
// Wait for critical storage data only
await storage.get()?.ready
// 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)
}
// Handle warm and cold start deep links
unsubscribers.push(await setupDeepLinks())
// Close the database connection on reload
unsubscribers.push(closeStorage)
@ -239,7 +156,7 @@
unsubscribers.push(Push.sync())
// Wait for the new app's storage the way startup does, so sync reconciles against cached events.
let currentApp = core.app.get()
let currentApp = app.get()
const resync = async () => {
await storage.get()?.ready
@ -248,62 +165,19 @@
}
unsubscribers.push(
core.app.subscribe($app => {
if ($app !== currentApp) {
currentApp = $app
app.subscribe(nextApp => {
if (nextApp !== currentApp) {
currentApp = nextApp
resync()
}
}),
)
// Listen for signer errors, report to user via toast
unsubscribers.push(
throttled(10_000, signerRequests).subscribe($requests => {
if ($toast) {
return
}
const longCutoff = Date.now() - 30_000
const shortCutoff = Date.now() - 10_000
const pending = $requests.filter(r => !r.finishedAt && r.startedAt < longCutoff)
const completed = $requests.filter(r => r.finishedAt && r.finishedAt > shortCutoff)
const showPendingError = pending.length > 10
const showCompletedError = completed.length > 5 && completed.filter(r => r.ok).length === 0
if (showPendingError || showCompletedError) {
pushToast({
theme: "error",
timeout: 60_000,
message: "Your signer isn't responding.",
action: {
message: "Details",
onclick: () => goto("/settings/profile"),
},
})
}
}),
)
unsubscribers.push(syncSignerAlerts())
// Sync theme and font size
unsubscribers.push(
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))
// @ts-ignore
document.documentElement.style["font-size"] = `${$settings.font_size}rem`
}),
),
)
unsubscribers.push(syncTheme())
return () => unsubscribers.forEach(lib.call)
})
@ -315,8 +189,7 @@
})
$effect(() => {
const {user} = core.app.get()
const title = getPageTitle({page: $page, pubkey: user?.pubkey})
const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey})
const count = $documentActive ? $notificationCount : $backgroundNotificationCount
document.title = count > 0 ? `(${count}) ${title}` : title