Keep health checks as data, drive their review and key backup from HomeHealthChecks, and persist the backup reminder as needsKeyBackup

This commit is contained in:
Jon Staab 2026-09-25 22:54:25 -07:00
parent f1946b9a55
commit 9254238d81
8 changed files with 180 additions and 178 deletions

View file

@ -245,7 +245,7 @@ A derivation that every row subscribes to, or that joins large sets, is built by
| repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted | | repository, tracker, relays, relay stats, handles, zappers, plaintext, wraps | IndexedDB | yes | deleted |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted | | settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `session`, `wallet` | `ss` | no | cleared | | `session`, `wallet` | `ss` | no | cleared |
| `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state | `kv` | no | cleared | | `theme`, `flTheme`, `checked`, `shouldUnwrap`, `device`, `notificationSettings`, push state, `needsKeyBackup` | `kv` | no | cleared |
| drafts, dictations | a module `Map`, lost on reload | no | page reloads | | drafts, dictations | a module `Map`, lost on reload | no | page reloads |
### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`) ### IndexedDB (`src/app/storage.ts`, `src/lib/indexeddb.ts`)
@ -284,8 +284,8 @@ anything in them outlives a login and is cleared only by logout. Secrets go in `
default first, and the stored value arrives later (`.ready`). `theme` and `flTheme` default first, and the stored value arrives later (`.ready`). `theme` and `flTheme`
(`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it. (`theme.ts`), `checked` (`notifications.ts`) and `shouldUnwrap` (`sync.ts`) use it.
- `sync({key, store, storage})` binds a store that already exists. The root layout awaits it - `sync({key, store, storage})` binds a store that already exists. The root layout awaits it
for `device`, `wallet`, `notificationSettings` and `pushState` before first render, so boot for `device`, `wallet`, `notificationSettings`, `pushState` and `needsKeyBackup` before first
code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists. render, so boot code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists.
Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them Raw `localStorage` holds only `theme`, `fl-theme` and `font-size`. The root layout mirrors them
there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme. there to apply them synchronously before `kv` loads, which avoids a flash of the wrong theme.

View file

@ -2,27 +2,22 @@
import Stars from "@assets/icons/stars.svg?dataurl" import Stars from "@assets/icons/stars.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import type {HealthCheck} from "@app/healthChecks"
import {healthChecks, reviewPlans} from "@app/healthChecks"
type Props = { type Props = {
healthCheck: HealthCheck title: string
description: string
onfix: () => void
} }
const {healthCheck}: Props = $props() const {title, description, onfix}: Props = $props()
const start = () => reviewPlans([$healthChecks.plan(healthCheck)])
</script> </script>
<div <div role="group" aria-label={title} class="flex items-center justify-between gap-3 px-4 py-3">
role="group"
aria-label={healthCheck.title}
class="flex items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<strong class="text-sm">{healthCheck.title}</strong> <strong class="text-sm">{title}</strong>
<p class="text-sm opacity-75">{healthCheck.description}</p> <p class="text-sm opacity-75">{description}</p>
</div> </div>
<Button class="button button-neutral button-sm shrink-0" onclick={start}> <Button class="button button-neutral button-sm shrink-0" onclick={onfix}>
<Icon icon={Stars} size={4} /> <Icon icon={Stars} size={4} />
Fix Fix
</Button> </Button>

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {publish} from "@welshman/app"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {errorMessage} from "@lib/util"
import Stars from "@assets/icons/stars.svg?dataurl" import Stars from "@assets/icons/stars.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
@ -16,34 +18,38 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import type {HealthCheckPlan} from "@app/healthChecks" import type {HealthCheckPlan} from "@app/healthChecks"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = { type Props = {
plans: HealthCheckPlan[] plans: HealthCheckPlan[]
next?: () => void
} }
const {plans}: Props = $props() const {plans, next}: Props = $props()
const publishing = plans.filter(plan => plan.changes.length > 0)
const interactive = plans.filter(plan => plan.changes.length === 0)
let loading = $state(false) let loading = $state(false)
const back = () => history.back() const back = () => history.back()
const save = async (plan: HealthCheckPlan) => {
const error = await publish(await plan.apply()).waitForError()
if (error) {
pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`})
}
}
const confirm = async () => { const confirm = async () => {
loading = true loading = true
try { try {
await Promise.all(publishing.map(plan => plan.apply())) await Promise.all(plans.map(save))
} finally { } finally {
loading = false loading = false
} }
clearModals() clearModals()
next?.()
for (const plan of interactive) {
plan.apply()
}
} }
</script> </script>
@ -53,7 +59,7 @@
<ModalTitle>Review changes</ModalTitle> <ModalTitle>Review changes</ModalTitle>
<ModalSubtitle>Nothing changes until you confirm.</ModalSubtitle> <ModalSubtitle>Nothing changes until you confirm.</ModalSubtitle>
</ModalHeader> </ModalHeader>
{#each publishing as plan (plan.summary)} {#each plans as plan (plan.summary)}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p>{plan.summary}</p> <p>{plan.summary}</p>
{#each plan.changes as change (change.label)} {#each plan.changes as change (change.label)}

View file

@ -7,20 +7,51 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import HomeSection from "@app/components/HomeSection.svelte" import HomeSection from "@app/components/HomeSection.svelte"
import HealthCheckItem from "@app/components/HealthCheckItem.svelte" import HealthCheckItem from "@app/components/HealthCheckItem.svelte"
import {healthChecks, reviewPlans} from "@app/healthChecks" import HealthCheckReview from "@app/components/HealthCheckReview.svelte"
import KeyDownload from "@app/components/KeyDownload.svelte"
import type {HealthCheck} from "@app/healthChecks"
import {healthChecks, needsKeyBackup} from "@app/healthChecks"
import {session} from "@app/core"
import {pushModal} from "@app/modal"
const pending = $healthChecks.pending.$ const pending = $healthChecks.pending.$
const reviewAll = () => reviewPlans($pending.map(check => $healthChecks.plan(check))) const count = $derived($pending.length + ($needsKeyBackup ? 1 : 0))
const finishKeyBackup = () => {
needsKeyBackup.set(false)
history.back()
}
const backUpKey = () =>
pushModal(KeyDownload, {
secret: ($session!.data as {secret: string}).secret,
next: finishKeyBackup,
submitText: "Done",
})
const review = (checks: HealthCheck[], next?: () => void) => {
const plans = checks
.map(check => $healthChecks.plan(check))
.filter(plan => plan.changes.length > 0)
if (plans.length > 0) {
pushModal(HealthCheckReview, {plans, next})
} else {
next?.()
}
}
const reviewAll = () => review($pending, $needsKeyBackup ? backUpKey : undefined)
</script> </script>
<HomeSection title="Health checks" icon={Stethoscope}> <HomeSection title="Health checks" icon={Stethoscope}>
{#snippet action()} {#snippet action()}
{#if $pending.length > 0} {#if count > 0}
<Badge variant="warning">{$pending.length} open</Badge> <Badge variant="warning">{count} open</Badge>
{/if} {/if}
{/snippet} {/snippet}
{#if $pending.length === 0} {#if count === 0}
<p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75"> <p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75">
<Icon icon={CheckCircle} size={4} /> <Icon icon={CheckCircle} size={4} />
Your connection to the network looks healthy. Your connection to the network looks healthy.
@ -28,9 +59,18 @@
{:else} {:else}
<div class="flex flex-col divide-y divide-line border-t border-line"> <div class="flex flex-col divide-y divide-line border-t border-line">
{#each $pending as healthCheck (healthCheck.id)} {#each $pending as healthCheck (healthCheck.id)}
<HealthCheckItem {healthCheck} /> <HealthCheckItem
title={healthCheck.title}
description={healthCheck.description}
onfix={() => review([healthCheck])} />
{/each} {/each}
{#if $pending.length > 1} {#if $needsKeyBackup}
<HealthCheckItem
title="Back Up Your Key"
description="Save a backup of your private key in a secure place."
onfix={backUpKey} />
{/if}
{#if count > 1}
<div class="flex justify-center p-3"> <div class="flex justify-center p-3">
<Button class="button button-primary button-sm" onclick={reviewAll}> <Button class="button button-primary button-sm" onclick={reviewAll}>
<Icon icon={Stars} size={4} /> <Icon icon={Stars} size={4} />

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import type {ClientOptions} from "@pomade/core" import type {ClientOptions} from "@pomade/core"
import {assoc} from "@welshman/lib"
import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util" import {makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util"
import {Profile} from "@welshman/domain" import {Profile} from "@welshman/domain"
import {Thunks, nip01, publish, toSession} from "@welshman/app" import {Thunks, nip01, publish, toSession} from "@welshman/app"
@ -26,7 +25,7 @@
DEFAULT_MESSAGING_RELAYS, DEFAULT_MESSAGING_RELAYS,
DEFAULT_SPACES, DEFAULT_SPACES,
} from "@app/env" } from "@app/env"
import {forceHealthChecks} from "@app/healthChecks" import {needsKeyBackup} from "@app/healthChecks"
import {loginWithPomade} from "@app/pomade" import {loginWithPomade} from "@app/pomade"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {app, domain, login, roomLists} from "@app/core" import {app, domain, login, roomLists} from "@app/core"
@ -93,7 +92,7 @@
const secret = getKey<string>("signup.secret")! const secret = getKey<string>("signup.secret")!
await login(toSession(nip01, {secret})) await login(toSession(nip01, {secret}))
forceHealthChecks.update(assoc("backup-key", true)) needsKeyBackup.set(true)
await completeSignup() await completeSignup()
}, },
}, },

View file

@ -1,22 +1,10 @@
import {derived, get, writable} from "svelte/store" import {derived, writable} from "svelte/store"
import {assoc, sample} from "@welshman/lib" import {sample} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util" import {normalizeRelayUrl} from "@welshman/util"
import { import {MessagingRelayLists, RelayLists, Relays, SearchRelayLists, projection} from "@welshman/app"
MessagingRelayLists,
RelayLists,
Relays,
SearchRelayLists,
projection,
publish,
} from "@welshman/app"
import type {Command, IApp, Projection} from "@welshman/app" import type {Command, IApp, Projection} from "@welshman/app"
import {errorMessage} from "@lib/util" import {usePlugin} from "@app/core"
import KeyDownload from "@app/components/KeyDownload.svelte"
import HealthCheckReview from "@app/components/HealthCheckReview.svelte"
import {session, usePlugin} from "@app/core"
import {DEFAULT_RELAYS} from "@app/env" import {DEFAULT_RELAYS} from "@app/env"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
export type HealthCheckContext = { export type HealthCheckContext = {
readRelays: string[] readRelays: string[]
@ -34,7 +22,7 @@ export type HealthCheckChange = {
export type HealthCheckPlan = { export type HealthCheckPlan = {
summary: string summary: string
changes: HealthCheckChange[] changes: HealthCheckChange[]
apply: () => unknown apply: () => Promise<Command>
} }
export type HealthCheck = { export type HealthCheck = {
@ -45,12 +33,14 @@ export type HealthCheck = {
plan: (context: HealthCheckContext) => HealthCheckPlan plan: (context: HealthCheckContext) => HealthCheckPlan
} }
const publishRelayList = async (command: Command) => { type RelayListSpec = {
const error = await publish(command).waitForError() id: string
name: string
if (error) { noun: string
pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`}) // Only lists this app can fill from its own recommendations get a missing-relays check.
} missing?: {description: string; recommended: string[]}
urls: (context: HealthCheckContext) => string[]
save: (urls: string[]) => Promise<Command>
} }
const relayPlan = ( const relayPlan = (
@ -58,7 +48,7 @@ const relayPlan = (
label: string, label: string,
current: string[], current: string[],
next: string[], next: string[],
apply: (urls: string[]) => unknown, save: (urls: string[]) => Promise<Command>,
): HealthCheckPlan => { ): HealthCheckPlan => {
const currentUrls = current.map(normalizeRelayUrl) const currentUrls = current.map(normalizeRelayUrl)
const nextUrls = next.map(normalizeRelayUrl) const nextUrls = next.map(normalizeRelayUrl)
@ -66,20 +56,44 @@ const relayPlan = (
const removed = currentUrls.filter(url => !nextUrls.includes(url)) const removed = currentUrls.filter(url => !nextUrls.includes(url))
const changes = added.length + removed.length > 0 ? [{label, added, removed}] : [] const changes = added.length + removed.length > 0 ? [{label, added, removed}] : []
return {summary, changes, apply: () => apply(nextUrls)} return {summary, changes, apply: () => save(nextUrls)}
} }
export const reviewPlans = (plans: HealthCheckPlan[]) => { const missingRelaysCheck = (
if (plans.some(plan => plan.changes.length > 0)) { spec: RelayListSpec,
pushModal(HealthCheckReview, {plans}) {description, recommended}: NonNullable<RelayListSpec["missing"]>,
} else { ): HealthCheck => ({
for (const plan of plans) { id: `missing-${spec.id}-relays`,
plan.apply() title: `Missing ${spec.name} Relays`,
} description,
} isPending: context => spec.urls(context).length <= 1,
} plan: context =>
relayPlan(
`Sets your ${spec.noun} relays to the ones this app recommends.`,
`${spec.name} relays`,
spec.urls(context),
recommended,
spec.save,
),
})
export const forceHealthChecks = writable<Record<string, boolean>>({}) const tooManyRelaysCheck = (spec: RelayListSpec): HealthCheck => ({
id: `too-many-${spec.id}-relays`,
title: `Too Many ${spec.name} Relays`,
description: `You have more ${spec.noun} relays than is really necessary, which can affect resource usage.`,
isPending: context => spec.urls(context).length > 8,
plan: context =>
relayPlan(
`Keeps five of your ${spec.noun} relays and drops the rest.`,
`${spec.name} relays`,
spec.urls(context),
sample(5, spec.urls(context)),
spec.save,
),
})
// Set when a key is generated in-app, and cleared once the user has saved a backup of it
export const needsKeyBackup = writable(false)
export class HealthChecks { export class HealthChecks {
context: Projection<HealthCheckContext> context: Projection<HealthCheckContext>
@ -109,90 +123,51 @@ export class HealthChecks {
) )
this.pending = projection( this.pending = projection(
derived( derived([this.context.$, app.use(Relays).index.$], ([$context]) =>
[this.context.$, forceHealthChecks, app.use(Relays).index.$], this.checks.filter(check => check.isPending($context)),
([$context, $forceHealthChecks]) =>
this.checks.filter(check => $forceHealthChecks[check.id] ?? check.isPending($context)),
), ),
) )
} }
private supportsSearch = (url: string) => this.app.use(Relays).get(url)?.hasNip(50) ?? true private supportsSearch = (url: string) => this.app.use(Relays).get(url)?.hasNip(50) ?? true
private relayLists: RelayListSpec[] = [
{
id: "inbox",
name: "Inbox",
noun: "inbox",
missing: {
description: "Other people aren't currently able to reliably tag you in public notes.",
recommended: DEFAULT_RELAYS,
},
urls: context => context.readRelays,
save: urls => this.app.use(RelayLists).setReadUrls(urls),
},
{
id: "outbox",
name: "Outbox",
noun: "outbox",
missing: {
description: "Other people aren't currently able to reliably find your public notes.",
recommended: DEFAULT_RELAYS,
},
urls: context => context.writeRelays,
save: urls => this.app.use(RelayLists).setWriteUrls(urls),
},
{
id: "dm",
name: "DM",
noun: "DM",
urls: context => context.messagingRelays,
save: urls => this.app.use(MessagingRelayLists).setUrls(urls),
},
]
private checks: HealthCheck[] = [ private checks: HealthCheck[] = [
{ ...this.relayLists.flatMap(spec =>
id: "missing-inbox-relays", spec.missing ? [missingRelaysCheck(spec, spec.missing)] : [],
title: "Missing Inbox Relays", ),
description: "Other people aren't currently able to reliably tag you in public notes.", ...this.relayLists.map(tooManyRelaysCheck),
isPending: context => context.readRelays.length <= 1,
plan: context =>
relayPlan(
"Sets your inbox relays to the ones this app recommends.",
"Inbox relays",
context.readRelays,
DEFAULT_RELAYS,
urls => this.app.use(RelayLists).setReadUrls(urls).then(publishRelayList),
),
},
{
id: "missing-outbox-relays",
title: "Missing Outbox Relays",
description: "Other people aren't currently able to reliably find your public notes.",
isPending: context => context.writeRelays.length <= 1,
plan: context =>
relayPlan(
"Sets your outbox relays to the ones this app recommends.",
"Outbox relays",
context.writeRelays,
DEFAULT_RELAYS,
urls => this.app.use(RelayLists).setWriteUrls(urls).then(publishRelayList),
),
},
{
id: "too-many-inbox-relays",
title: "Too Many Inbox Relays",
description:
"You have more inbox relays than is really necessary, which can affect resource usage.",
isPending: context => context.readRelays.length > 8,
plan: context =>
relayPlan(
"Keeps five of your inbox relays and drops the rest.",
"Inbox relays",
context.readRelays,
sample(5, context.readRelays),
urls => this.app.use(RelayLists).setReadUrls(urls).then(publishRelayList),
),
},
{
id: "too-many-outbox-relays",
title: "Too Many Outbox Relays",
description:
"You have more outbox relays than is really necessary, which can affect resource usage.",
isPending: context => context.writeRelays.length > 8,
plan: context =>
relayPlan(
"Keeps five of your outbox relays and drops the rest.",
"Outbox relays",
context.writeRelays,
sample(5, context.writeRelays),
urls => this.app.use(RelayLists).setWriteUrls(urls).then(publishRelayList),
),
},
{
id: "too-many-dm-relays",
title: "Too Many DM Relays",
description:
"You have more DM relays than is really necessary, which can affect resource usage.",
isPending: context => context.messagingRelays.length > 8,
plan: context =>
relayPlan(
"Keeps five of your DM relays and drops the rest.",
"DM relays",
context.messagingRelays,
sample(5, context.messagingRelays),
urls => this.app.use(MessagingRelayLists).setUrls(urls).then(publishRelayList),
),
},
{ {
id: "invalid-search-relays", id: "invalid-search-relays",
title: "Invalid Search Relays", title: "Invalid Search Relays",
@ -204,36 +179,11 @@ export class HealthChecks {
"Search relays", "Search relays",
context.searchRelays, context.searchRelays,
context.searchRelays.filter(this.supportsSearch), context.searchRelays.filter(this.supportsSearch),
urls => this.app.use(SearchRelayLists).setUrls(urls).then(publishRelayList), urls => this.app.use(SearchRelayLists).setUrls(urls),
), ),
}, },
{
id: "backup-key",
title: "Back Up Your Key",
description: "Save a backup of your private key in a secure place.",
isPending: () => false,
plan: () => ({
summary: "Opens the key backup dialog, where you choose how to save your key.",
changes: [],
apply: () => {
const {secret} = session.get()!.data as {secret: string}
pushModal(KeyDownload, {
secret,
next: () => {
forceHealthChecks.update(assoc("backup-key", false))
history.back()
},
submitText: "Done",
})
},
}),
},
] ]
isPending = (healthCheck: HealthCheck) =>
get(forceHealthChecks)[healthCheck.id] ?? healthCheck.isPending(this.context.get())
plan = (healthCheck: HealthCheck) => healthCheck.plan(this.context.get()) plan = (healthCheck: HealthCheck) => healthCheck.plan(this.context.get())
} }

View file

@ -71,11 +71,15 @@ export const kv = call(() => {
await enqueue(() => Preferences.set({key, value: JSON.stringify(value)})) await enqueue(() => Preferences.set({key, value: JSON.stringify(value)}))
} }
const remove = async (key: string) => {
await enqueue(() => Preferences.remove({key}))
}
const clear = async () => { const clear = async () => {
await enqueue(() => Preferences.clear()) await enqueue(() => Preferences.clear())
} }
return {get, set, clear} return {get, set, remove, clear}
}) })
const secretStorage = const secretStorage =

View file

@ -34,7 +34,7 @@
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 {userSettingsValues, notificationSettings} from "@app/settings"
import {forceHealthChecks} from "@app/healthChecks" import {needsKeyBackup} from "@app/healthChecks"
import {setupShareIntents, shareFromNative} from "@app/share" import {setupShareIntents, shareFromNative} 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"
@ -190,12 +190,20 @@
storage: kv, storage: kv,
}), }),
sync({ sync({
key: "forceHealthChecks", key: "needsKeyBackup",
store: forceHealthChecks, store: needsKeyBackup,
storage: kv, storage: kv,
}), }),
]) ])
// The key backup reminder used to live in a forceHealthChecks record
const legacyHealthChecks = await kv.get<Record<string, boolean>>("forceHealthChecks")
if (legacyHealthChecks) {
needsKeyBackup.set(Boolean(legacyHealthChecks["backup-key"]))
await kv.remove("forceHealthChecks")
}
// Wait for critical storage data only // Wait for critical storage data only
await storage.get()?.ready await storage.get()?.ready