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:
parent
9254238d81
commit
e5a270aa5c
7 changed files with 200 additions and 178 deletions
|
|
@ -48,9 +48,9 @@ only gate.
|
||||||
and `IconPickerButton.svelte` open app modals. Don't copy them. A lib component that needs app
|
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`.
|
behavior takes it as a prop, or moves to `src/app/components`.
|
||||||
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`,
|
- **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`), `share.ts` (`Share`,
|
||||||
`ShareEvent`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal
|
`ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts` (`OpenRouterEnable`) import a component
|
||||||
mid-flow. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor`
|
so they can open a modal mid-flow. `editor/` holds `.svelte` files of its own (suggestion
|
||||||
mounts.
|
popovers), which `makeEditor` mounts.
|
||||||
- Nothing under `src/app` or `src/lib` imports from `src/routes`.
|
- Nothing under `src/app` or `src/lib` imports from `src/routes`.
|
||||||
|
|
||||||
## Top-level layout
|
## Top-level layout
|
||||||
|
|
@ -82,7 +82,7 @@ only gate.
|
||||||
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
|
- `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs
|
||||||
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
|
- `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings
|
||||||
- `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event
|
- `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
|
- `env.ts`: every `VITE_` value, parsed
|
||||||
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
|
- `logger.ts` (log capture and sending), `analytics.ts` (Plausible pageviews), `device.ts` (a
|
||||||
device id)
|
device id)
|
||||||
|
|
@ -121,7 +121,7 @@ only gate.
|
||||||
(prompts for missing inbox/outbox relays)
|
(prompts for missing inbox/outbox relays)
|
||||||
|
|
||||||
**Platform and voice**
|
**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
|
- `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,
|
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,
|
`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
|
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
|
1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped
|
||||||
`App` through `login`.
|
`App` through `login`.
|
||||||
2. The device, wallet and notification stores sync to `kv`/`ss`.
|
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`,
|
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
|
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`
|
`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
|
without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with
|
||||||
`registerPlugin` (`push/adapters/android.ts`, `share.ts`).
|
`registerPlugin` (`push/adapters/android.ts`, `share.ts`).
|
||||||
- `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a
|
- `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
|
`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.
|
`PushNotifications` (FCM/APNs through `PUSH_SERVER`), or web notifications.
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
and a new event detail route needs an `eventRoutes` entry and a branch in `getPageTitle`.
|
||||||
Without one the tab shows only `PLATFORM_NAME`.
|
Without one the tab shows only `PLATFORM_NAME`.
|
||||||
|
|
||||||
Deep links arrive in `handleDeepLink` in the root layout: push-notification links (`?relay=&id=`),
|
Deep links arrive in `handleDeepLink` in `src/app/deepLinks.ts`: push-notification links
|
||||||
the iOS share extension (the `share` host), signer returns (`x-callback-url`), and otherwise a
|
(`?relay=&id=`), the iOS share extension (the `share` host), signer returns (`x-callback-url`), and
|
||||||
plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and
|
otherwise a plain path. Nostr entities go through `/[bech32]`, which sends profiles to `makeProfilePath`, and
|
||||||
loads events before calling `goToEvent`.
|
loads events before calling `goToEvent`.
|
||||||
|
|
||||||
### Adding a space content page
|
### Adding a space content page
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {avg, spec} from "@welshman/lib"
|
import {spec} from "@welshman/lib"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
|
||||||
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
|
||||||
|
|
@ -9,21 +9,13 @@
|
||||||
import LogOut from "@app/components/LogOut.svelte"
|
import LogOut from "@app/components/LogOut.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {session} from "@app/core"
|
import {session} from "@app/core"
|
||||||
import {signerRequests} from "@app/signer"
|
import {signerHealth, signerRequests} from "@app/signer"
|
||||||
import PomadeSessions from "@app/components/PomadeSessions.svelte"
|
import PomadeSessions from "@app/components/PomadeSessions.svelte"
|
||||||
|
|
||||||
const finished = $derived($signerRequests.filter(request => request.finishedAt))
|
const finished = $derived($signerRequests.filter(request => request.finishedAt))
|
||||||
const pending = $derived($signerRequests.filter(request => !request.finishedAt))
|
const pending = $derived($signerRequests.filter(request => !request.finishedAt))
|
||||||
const success = $derived(finished.filter(spec({ok: true})))
|
const success = $derived(finished.filter(spec({ok: true})))
|
||||||
const failure = $derived(finished.filter(spec({ok: false})))
|
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)
|
const logout = () => pushModal(LogOut)
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -34,14 +26,12 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xl font-bold">Signer Status</span>
|
<span class="text-xl font-bold">Signer Status</span>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
{#if isDisconnected}
|
{#if $signerHealth.disconnected}
|
||||||
<Icon icon={CloseCircle} class="text-error" size={4} /> 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
|
<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
|
<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}
|
{:else}
|
||||||
<Icon icon={CheckCircle} class="text-success" size={4} /> Ok
|
<Icon icon={CheckCircle} class="text-success" size={4} /> Ok
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -67,7 +57,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if isDisconnected}
|
{#if $signerHealth.disconnected}
|
||||||
<Button class="button button-error" onclick={logout}>Logout to Reconnect</Button>
|
<Button class="button button-error" onclick={logout}>Logout to Reconnect</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<PomadeSessions />
|
<PomadeSessions />
|
||||||
|
|
|
||||||
73
src/app/deepLinks.ts
Normal file
73
src/app/deepLinks.ts
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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 {Logger} from "@welshman/app"
|
||||||
import type {LogMessage} from "@welshman/app"
|
import type {LogMessage} from "@welshman/app"
|
||||||
import {fromApp} from "@app/core"
|
import {fromApp} from "@app/core"
|
||||||
|
import {navigate} from "@app/modal"
|
||||||
|
import {pushToast, toast} from "@app/toast"
|
||||||
|
|
||||||
export type SignerRequest = {
|
export type SignerRequest = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -41,3 +44,42 @@ export const signerRequests = derived(
|
||||||
return Array.from(requestsById.values())
|
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"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import twColors from "tailwindcss/colors"
|
import twColors from "tailwindcss/colors"
|
||||||
import {derived, readable} from "svelte/store"
|
import {debounce} from "throttle-debounce"
|
||||||
import {hash} from "@welshman/lib"
|
import {derived, get, readable} from "svelte/store"
|
||||||
|
import {call, hash} from "@welshman/lib"
|
||||||
import {synced} from "@welshman/store"
|
import {synced} from "@welshman/store"
|
||||||
import {FL_THEME} from "@app/env"
|
import {FL_THEME} from "@app/env"
|
||||||
import {kv} from "@app/storage"
|
import {kv} from "@app/storage"
|
||||||
|
import {userSettingsValues} from "@app/settings"
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
twColors.amber[600],
|
twColors.amber[600],
|
||||||
|
|
@ -61,3 +63,41 @@ export const activeTheme = derived([theme, prefersDark], ([$theme, $prefersDark]
|
||||||
|
|
||||||
return $theme
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "@lib/components/theme.css"
|
import "@lib/components/theme.css"
|
||||||
import "@welshman/editor/index.css"
|
import "@welshman/editor/index.css"
|
||||||
import {debounce} from "throttle-debounce"
|
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import type {Unsubscriber} from "svelte/store"
|
import type {Unsubscriber} from "svelte/store"
|
||||||
import {get} 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 {Capacitor} from "@capacitor/core"
|
||||||
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
|
import {NostrSignerPlugin} from "nostr-signer-capacitor-plugin"
|
||||||
import {dev} from "$app/environment"
|
import {dev} from "$app/environment"
|
||||||
import {goto} from "$app/navigation"
|
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {context as pomadeContext} from "@pomade/core"
|
import {context as pomadeContext} from "@pomade/core"
|
||||||
import {sync, throttled} from "@welshman/store"
|
import {sync} from "@welshman/store"
|
||||||
import {setNip55Plugin} from "@welshman/signer"
|
import {setNip55Plugin} from "@welshman/signer"
|
||||||
import * as util from "@welshman/util"
|
import * as util from "@welshman/util"
|
||||||
import * as lib from "@welshman/lib"
|
import * as lib from "@welshman/lib"
|
||||||
|
|
@ -22,45 +20,34 @@
|
||||||
import Startup from "@app/components/Startup.svelte"
|
import Startup from "@app/components/Startup.svelte"
|
||||||
import ModalContainer from "@app/components/ModalContainer.svelte"
|
import ModalContainer from "@app/components/ModalContainer.svelte"
|
||||||
import * as core from "@app/core"
|
import * as core from "@app/core"
|
||||||
import {goToChat, goToHome, setupHistory} from "@app/routes"
|
import {app} from "@app/core"
|
||||||
import Search from "@app/components/Search.svelte"
|
import {setupHistory} from "@app/routes"
|
||||||
import {clearModals, getModal, navigate, pushModal} from "@app/modal"
|
|
||||||
import {setupAnalytics} from "@app/analytics"
|
import {setupAnalytics} from "@app/analytics"
|
||||||
import {setupLogging} from "@app/logger"
|
import {setupLogging} from "@app/logger"
|
||||||
import "@app/policies"
|
import "@app/policies"
|
||||||
import {restoreSession} from "@app/session"
|
import {restoreSession} from "@app/session"
|
||||||
import {signerRequests} from "@app/signer"
|
import {syncSignerAlerts} from "@app/signer"
|
||||||
import {wallet} from "@app/lightning"
|
import {wallet} from "@app/lightning"
|
||||||
import {kv, ss, storage} from "@app/storage"
|
import {kv, ss, storage} from "@app/storage"
|
||||||
import {device} from "@app/device"
|
import {device} from "@app/device"
|
||||||
import {userSettingsValues, notificationSettings} from "@app/settings"
|
import {notificationSettings} from "@app/settings"
|
||||||
import {needsKeyBackup} from "@app/healthChecks"
|
import {needsKeyBackup} from "@app/healthChecks"
|
||||||
import {setupShareIntents, shareFromNative} from "@app/share"
|
import {setupShareIntents} from "@app/share"
|
||||||
import {shouldUnwrap, syncApplicationData} from "@app/sync"
|
import {shouldUnwrap, syncApplicationData} from "@app/sync"
|
||||||
import * as env from "@app/env"
|
import * as env from "@app/env"
|
||||||
import {activeTheme, flTheme, theme} from "@app/theme"
|
import {applySavedTheme, flTheme, syncTheme, theme} from "@app/theme"
|
||||||
import {toast, pushToast} from "@app/toast"
|
|
||||||
import * as notifications from "@app/notifications"
|
import * as notifications from "@app/notifications"
|
||||||
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
|
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
|
||||||
import {Push} from "@app/push"
|
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 {syncKeyboard} from "@app/keyboard"
|
||||||
import {getPageTitle} from "@app/title"
|
import {getPageTitle} from "@app/title"
|
||||||
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
import NewNotificationSound from "@src/app/components/NewNotificationSound.svelte"
|
||||||
|
|
||||||
const {children} = $props()
|
const {children} = $props()
|
||||||
|
|
||||||
// The stores these mirror are in indexeddb, which loads well after first paint.
|
applySavedTheme()
|
||||||
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)
|
|
||||||
|
|
||||||
// Add stuff to window for convenience
|
// Add stuff to window for convenience
|
||||||
Object.assign(window, {get, nip19, theme, ...plugins, ...lib, ...util, ...core})
|
Object.assign(window, {get, nip19, theme, ...plugins, ...lib, ...util, ...core})
|
||||||
|
|
@ -73,72 +60,6 @@
|
||||||
setNip55Plugin(NostrSignerPlugin)
|
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
|
// Handle back button on mobile
|
||||||
App.addListener("backButton", () => {
|
App.addListener("backButton", () => {
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
|
|
@ -207,12 +128,8 @@
|
||||||
// Wait for critical storage data only
|
// Wait for critical storage data only
|
||||||
await storage.get()?.ready
|
await storage.get()?.ready
|
||||||
|
|
||||||
// A link launching the app from a killed state arrives via onCreate, which emits no appUrlOpen.
|
// Handle warm and cold start deep links
|
||||||
const launch = await App.getLaunchUrl()
|
unsubscribers.push(await setupDeepLinks())
|
||||||
|
|
||||||
if (launch?.url) {
|
|
||||||
handleDeepLink(launch.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the database connection on reload
|
// Close the database connection on reload
|
||||||
unsubscribers.push(closeStorage)
|
unsubscribers.push(closeStorage)
|
||||||
|
|
@ -239,7 +156,7 @@
|
||||||
unsubscribers.push(Push.sync())
|
unsubscribers.push(Push.sync())
|
||||||
|
|
||||||
// Wait for the new app's storage the way startup does, so sync reconciles against cached events.
|
// 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 () => {
|
const resync = async () => {
|
||||||
await storage.get()?.ready
|
await storage.get()?.ready
|
||||||
|
|
@ -248,62 +165,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
core.app.subscribe($app => {
|
app.subscribe(nextApp => {
|
||||||
if ($app !== currentApp) {
|
if (nextApp !== currentApp) {
|
||||||
currentApp = $app
|
currentApp = nextApp
|
||||||
resync()
|
resync()
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Listen for signer errors, report to user via toast
|
// Listen for signer errors, report to user via toast
|
||||||
unsubscribers.push(
|
unsubscribers.push(syncSignerAlerts())
|
||||||
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"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sync theme and font size
|
// Sync theme and font size
|
||||||
unsubscribers.push(
|
unsubscribers.push(syncTheme())
|
||||||
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`
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => unsubscribers.forEach(lib.call)
|
return () => unsubscribers.forEach(lib.call)
|
||||||
})
|
})
|
||||||
|
|
@ -315,8 +189,7 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const {user} = core.app.get()
|
const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey})
|
||||||
const title = getPageTitle({page: $page, pubkey: user?.pubkey})
|
|
||||||
const count = $documentActive ? $notificationCount : $backgroundNotificationCount
|
const count = $documentActive ? $notificationCount : $backgroundNotificationCount
|
||||||
|
|
||||||
document.title = count > 0 ? `(${count}) ${title}` : title
|
document.title = count > 0 ? `(${count}) ${title}` : title
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue