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 |
| settings (`SettingsValues`) | an encrypted app-data event, cached in IndexedDB | yes | local copy deleted |
| `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 |
### 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`
(`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
for `device`, `wallet`, `notificationSettings` and `pushState` before first render, so boot
code sees the restored values. It also binds `shouldUnwrap`, which `synced` already persists.
for `device`, `wallet`, `notificationSettings`, `pushState` and `needsKeyBackup` before first
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
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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import type {HealthCheck} from "@app/healthChecks"
import {healthChecks, reviewPlans} from "@app/healthChecks"
type Props = {
healthCheck: HealthCheck
title: string
description: string
onfix: () => void
}
const {healthCheck}: Props = $props()
const start = () => reviewPlans([$healthChecks.plan(healthCheck)])
const {title, description, onfix}: Props = $props()
</script>
<div
role="group"
aria-label={healthCheck.title}
class="flex items-center justify-between gap-3 px-4 py-3">
<div role="group" aria-label={title} class="flex items-center justify-between gap-3 px-4 py-3">
<div class="flex min-w-0 flex-col gap-1">
<strong class="text-sm">{healthCheck.title}</strong>
<p class="text-sm opacity-75">{healthCheck.description}</p>
<strong class="text-sm">{title}</strong>
<p class="text-sm opacity-75">{description}</p>
</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} />
Fix
</Button>

View file

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

View file

@ -7,20 +7,51 @@
import Button from "@lib/components/Button.svelte"
import HomeSection from "@app/components/HomeSection.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 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>
<HomeSection title="Health checks" icon={Stethoscope}>
{#snippet action()}
{#if $pending.length > 0}
<Badge variant="warning">{$pending.length} open</Badge>
{#if count > 0}
<Badge variant="warning">{count} open</Badge>
{/if}
{/snippet}
{#if $pending.length === 0}
{#if count === 0}
<p class="flex items-center gap-2 px-4 pb-4 text-sm opacity-75">
<Icon icon={CheckCircle} size={4} />
Your connection to the network looks healthy.
@ -28,9 +59,18 @@
{:else}
<div class="flex flex-col divide-y divide-line border-t border-line">
{#each $pending as healthCheck (healthCheck.id)}
<HealthCheckItem {healthCheck} />
<HealthCheckItem
title={healthCheck.title}
description={healthCheck.description}
onfix={() => review([healthCheck])} />
{/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">
<Button class="button button-primary button-sm" onclick={reviewAll}>
<Icon icon={Stars} size={4} />

View file

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

View file

@ -1,22 +1,10 @@
import {derived, get, writable} from "svelte/store"
import {assoc, sample} from "@welshman/lib"
import {derived, writable} from "svelte/store"
import {sample} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {
MessagingRelayLists,
RelayLists,
Relays,
SearchRelayLists,
projection,
publish,
} from "@welshman/app"
import {MessagingRelayLists, RelayLists, Relays, SearchRelayLists, projection} from "@welshman/app"
import type {Command, IApp, Projection} from "@welshman/app"
import {errorMessage} from "@lib/util"
import KeyDownload from "@app/components/KeyDownload.svelte"
import HealthCheckReview from "@app/components/HealthCheckReview.svelte"
import {session, usePlugin} from "@app/core"
import {usePlugin} from "@app/core"
import {DEFAULT_RELAYS} from "@app/env"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
export type HealthCheckContext = {
readRelays: string[]
@ -34,7 +22,7 @@ export type HealthCheckChange = {
export type HealthCheckPlan = {
summary: string
changes: HealthCheckChange[]
apply: () => unknown
apply: () => Promise<Command>
}
export type HealthCheck = {
@ -45,12 +33,14 @@ export type HealthCheck = {
plan: (context: HealthCheckContext) => HealthCheckPlan
}
const publishRelayList = async (command: Command) => {
const error = await publish(command).waitForError()
if (error) {
pushToast({theme: "error", message: `Your relays couldn't be saved: ${errorMessage(error)}`})
}
type RelayListSpec = {
id: string
name: string
noun: string
// 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 = (
@ -58,7 +48,7 @@ const relayPlan = (
label: string,
current: string[],
next: string[],
apply: (urls: string[]) => unknown,
save: (urls: string[]) => Promise<Command>,
): HealthCheckPlan => {
const currentUrls = current.map(normalizeRelayUrl)
const nextUrls = next.map(normalizeRelayUrl)
@ -66,20 +56,44 @@ const relayPlan = (
const removed = currentUrls.filter(url => !nextUrls.includes(url))
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[]) => {
if (plans.some(plan => plan.changes.length > 0)) {
pushModal(HealthCheckReview, {plans})
} else {
for (const plan of plans) {
plan.apply()
}
}
}
const missingRelaysCheck = (
spec: RelayListSpec,
{description, recommended}: NonNullable<RelayListSpec["missing"]>,
): HealthCheck => ({
id: `missing-${spec.id}-relays`,
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 {
context: Projection<HealthCheckContext>
@ -109,90 +123,51 @@ export class HealthChecks {
)
this.pending = projection(
derived(
[this.context.$, forceHealthChecks, app.use(Relays).index.$],
([$context, $forceHealthChecks]) =>
this.checks.filter(check => $forceHealthChecks[check.id] ?? check.isPending($context)),
derived([this.context.$, app.use(Relays).index.$], ([$context]) =>
this.checks.filter(check => check.isPending($context)),
),
)
}
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[] = [
{
id: "missing-inbox-relays",
title: "Missing Inbox Relays",
description: "Other people aren't currently able to reliably tag you in public notes.",
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),
),
},
...this.relayLists.flatMap(spec =>
spec.missing ? [missingRelaysCheck(spec, spec.missing)] : [],
),
...this.relayLists.map(tooManyRelaysCheck),
{
id: "invalid-search-relays",
title: "Invalid Search Relays",
@ -204,36 +179,11 @@ export class HealthChecks {
"Search relays",
context.searchRelays,
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())
}

View file

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

View file

@ -34,7 +34,7 @@
import {kv, ss, storage} from "@app/storage"
import {device} from "@app/device"
import {userSettingsValues, notificationSettings} from "@app/settings"
import {forceHealthChecks} from "@app/healthChecks"
import {needsKeyBackup} from "@app/healthChecks"
import {setupShareIntents, shareFromNative} from "@app/share"
import {shouldUnwrap, syncApplicationData} from "@app/sync"
import * as env from "@app/env"
@ -190,12 +190,20 @@
storage: kv,
}),
sync({
key: "forceHealthChecks",
store: forceHealthChecks,
key: "needsKeyBackup",
store: needsKeyBackup,
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
await storage.get()?.ready