Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ghostkey-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ const EmergencyPage = lazy(() =>
const RemindersPage = lazy(() =>
import("./VaultToolPages").then((m) => ({ default: m.RemindersPage })),
);
const ToolsPage = lazy(() =>
import("./VaultToolPages").then((m) => ({ default: m.ToolsPage })),
);
const RecoveryKitPage = lazy(() =>
import("./RecoveryKitPage").then((m) => ({ default: m.RecoveryKitPage })),
);
Expand Down Expand Up @@ -125,6 +128,7 @@ export type Route =
| "practice"
| "emergency"
| "reminders"
| "tools"
| "recovery"
| "recovery-guide"
| "checkin"
Expand All @@ -146,6 +150,7 @@ const VALID: Route[] = [
"practice",
"emergency",
"reminders",
"tools",
"recovery",
"recovery-guide",
"checkin",
Expand Down Expand Up @@ -409,6 +414,7 @@ export default function App() {
{location.kind === "route" && location.route === "practice" && <PracticeRunPage onNavigate={setRoute} />}
{location.kind === "route" && location.route === "emergency" && <EmergencyPage onNavigate={setRoute} />}
{location.kind === "route" && location.route === "reminders" && <RemindersPage onNavigate={setRoute} />}
{location.kind === "route" && location.route === "tools" && <ToolsPage onNavigate={setRoute} />}
{location.kind === "route" && location.route === "recovery" && <RecoveryKitPage onNavigate={setRoute} />}
{location.kind === "route" && location.route === "recovery-guide" && <RecoveryGuide onNavigate={setRoute} />}
{location.kind === "route" && location.route === "checkin" && <SignInPortal onNavigate={setRoute} />}
Expand Down
22 changes: 18 additions & 4 deletions ghostkey-web/src/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
import { unsealOwner } from "./crypto/sealing";
import { usePrice, btcAndUsd, satsToUsd, formatUsd } from "./fiat";
import { lastResortCheckinOpen } from "./checkin";
import { useToolDoneState } from "./toolStatus";
import type { Route } from "./App";

interface Props {
Expand Down Expand Up @@ -120,6 +121,9 @@ export function Dashboard({ onNavigate }: Props) {
// VAPID public key from /health, or null when the server has no
// push keypair configured. Gates the reminder opt-in card.
const [pushKey, setPushKey] = useState<string | null>(null);
// Set-once tools that are already done drop off the More list below;
// the nav's Tools page stays their permanent home.
const toolsDone = useToolDoneState(activeId, ownerToken);

const now = useTicker(1000);

Expand Down Expand Up @@ -482,11 +486,20 @@ export function Dashboard({ onNavigate }: Props) {

{/* Set-once tools live on their own pages now, reached from
this compact list, so the dashboard stays status + money +
heir. Recovery kit already works the same way (in the nav). */}
heir. Once a tool is done (video saved, practice sent,
reminders on) its link leaves this list too; the nav's
Tools page is the permanent home for all of them. */}
<MoreLinks
onNavigate={onNavigate}
showMessage={Boolean(vault) && !isClosed}
showPractice={Boolean(vault) && !isClosed && !isClaiming}
showMessage={
Boolean(vault) && !isClosed && toolsDone.hasVideo !== true
}
showPractice={
Boolean(vault) &&
!isClosed &&
!isClaiming &&
!vault?.drill_started_at
}
showEmergency={
Boolean(vault?.lnurl_panic) &&
vault?.status !== "frozen" &&
Expand All @@ -499,7 +512,8 @@ export function Dashboard({ onNavigate }: Props) {
!isClaiming &&
!isUnfunded &&
Boolean(pushKey) &&
Boolean(ownerToken)
Boolean(ownerToken) &&
toolsDone.remindersOn !== true
}
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions ghostkey-web/src/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const SIGNED_OUT_ITEMS: NavItem[] = [

const SIGNED_IN_ITEMS: NavItem[] = [
{ key: "dashboard", label: "Dashboard" },
{ key: "tools", label: "Tools" },
{ key: "recovery", label: "Recovery kit" },
{ key: "inherit", label: "Inherit" },
];
Expand Down
63 changes: 55 additions & 8 deletions ghostkey-web/src/PracticeClaimCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,46 @@ export interface DrillProgress {
drill_completed_at?: string | null;
}

/** How this heir is reached, from the server's heir profile. The copy
* must match the real channel: a WhatsApp heir never gets an email,
* so the card must not promise one. Unknown falls back to "message". */
export type HeirChannel = "email" | "sms" | "whatsapp" | null | undefined;

function practiceNoun(channel: HeirChannel): string {
switch (channel) {
case "email":
return "practice email";
case "sms":
return "practice text message";
case "whatsapp":
return "practice WhatsApp message";
default:
return "practice message";
}
}

function sendWords(channel: HeirChannel, who: string): {
alert: string;
button: string;
} {
switch (channel) {
case "email":
return { alert: `This emails ${who} right now.`, button: `Email ${who} now` };
case "sms":
return { alert: `This texts ${who} right now.`, button: `Text ${who} now` };
case "whatsapp":
return {
alert: `This sends ${who} a WhatsApp message right now.`,
button: `Message ${who} on WhatsApp`,
};
default:
return {
alert: `This sends ${who} a message right now.`,
button: `Send it to ${who} now`,
};
}
}

function fmtDay(rfc: string): string | null {
const d = new Date(rfc);
if (Number.isNaN(d.getTime())) return null;
Expand All @@ -33,7 +73,11 @@ function fmtDay(rfc: string): string | null {

/** One line describing where the rehearsal stands, for the card and
* its tests. */
export function drillStatusLine(progress: DrillProgress, who: string): string {
export function drillStatusLine(
progress: DrillProgress,
who: string,
channel?: HeirChannel,
): string {
if (progress.drill_completed_at) {
const when = fmtDay(progress.drill_completed_at);
return when
Expand All @@ -52,7 +96,7 @@ export function drillStatusLine(progress: DrillProgress, who: string): string {
? `Practice sent ${when}. ${who} hasn't opened it yet.`
: `Practice sent. ${who} hasn't opened it yet.`;
}
return `See the claim work while you're here to help. ${who} gets a clearly-marked practice email and walks the real steps. Nothing can move.`;
return `See the claim work while you're here to help. ${who} gets a clearly-marked ${practiceNoun(channel)} and walks the real steps. Nothing can move.`;
}

type Stage = "idle" | "confirming" | "sending" | "sent";
Expand All @@ -61,11 +105,13 @@ export function PracticeClaimCard({
vaultId,
ownerToken,
heirName,
heirChannel,
progress,
}: {
vaultId: string;
ownerToken: string | null;
heirName?: string;
heirChannel?: HeirChannel;
progress: DrillProgress;
}) {
const [stage, setStage] = useState<Stage>("idle");
Expand All @@ -78,8 +124,9 @@ export function PracticeClaimCard({
// A just-sent drill beats whatever the vault fetch knew.
const line =
stage === "sent" && result
? drillStatusLine({ drill_started_at: result.started_at }, who)
: drillStatusLine(progress, who);
? drillStatusLine({ drill_started_at: result.started_at }, who, heirChannel)
: drillStatusLine(progress, who, heirChannel);
const send = sendWords(heirChannel, who);
const completed = Boolean(progress.drill_completed_at) && stage !== "sent";
const startedBefore = Boolean(progress.drill_started_at) || stage === "sent";

Expand Down Expand Up @@ -129,8 +176,8 @@ export function PracticeClaimCard({
<div className="mt-3">
<InlineAlert tone="warning">
<p className="text-sm">
This emails {who} right now. The message says clearly that you
are fine and that this is practice.
{send.alert} The message says clearly that you are fine and
that this is practice.
</p>
</InlineAlert>
{error ? (
Expand All @@ -142,7 +189,7 @@ export function PracticeClaimCard({
onClick={() => void onSend()}
disabled={stage === "sending"}
>
{stage === "sending" ? "Sending…" : `Email ${who} now`}
{stage === "sending" ? "Sending…" : send.button}
</Button>
<Button
variant="ghost"
Expand All @@ -165,7 +212,7 @@ export function PracticeClaimCard({
<p className="text-sm">
{result.heir_notified
? `On its way. You'll see it here when ${who} opens the link and when they finish.`
: `We couldn't email ${who} automatically. Share this practice link with them yourself:`}
: `We couldn't reach ${who} automatically. Share this practice link with them yourself:`}
</p>
{!result.heir_notified ? (
<p className="mt-2 break-all font-mono text-xs">
Expand Down
124 changes: 121 additions & 3 deletions ghostkey-web/src/VaultToolPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
* the real work. Keeping the dashboard to status + money + heir was the
* goal; these pages are one tap away from it.
*/
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";

import type { Route } from "./App";
import { api } from "./api";
import { useActiveVault } from "./useActiveVault";
import { useToolDoneState } from "./toolStatus";
import { VideoMessageCard } from "./VideoMessageCard";
import { PracticeClaimCard } from "./PracticeClaimCard";
import { PracticeClaimCard, type HeirChannel } from "./PracticeClaimCard";
import { PanicCard, PushOptInCard } from "./Dashboard";
import { Button } from "./ui";

Expand Down Expand Up @@ -108,20 +110,39 @@ export function HeirMessagePage({ onNavigate }: PageProps) {

export function PracticeRunPage({ onNavigate }: PageProps) {
const { meta, ownerToken, vault, loading } = useActiveVault();
// The card's copy names the channel the practice actually travels on
// (email, text, WhatsApp), which only the server's heir profile knows.
const [heirChannel, setHeirChannel] = useState<HeirChannel>(undefined);
useEffect(() => {
if (!meta?.id || !ownerToken) return;
let alive = true;
api
.getVaultHeir(meta.id, ownerToken)
.then((h) => {
if (alive) setHeirChannel((h.channel as HeirChannel) ?? null);
})
.catch(() => {
/* unknown channel keeps the generic wording */
});
return () => {
alive = false;
};
}, [meta?.id, ownerToken]);
const isClaiming =
vault?.status === "timelock_started" || vault?.status === "claiming";
const ready = Boolean(meta && vault && vault.status !== "claimed" && !isClaiming);
return (
<ToolPage
title="Practice a claim"
intro="Let your heir rehearse the real claim while you're here — no funds move, nothing changes."
intro="Let your heir rehearse the real claim while you're here. No money moves, nothing changes."
onNavigate={onNavigate}
>
{ready && meta && vault ? (
<PracticeClaimCard
vaultId={meta.id}
ownerToken={ownerToken}
heirName={meta.heir.name}
heirChannel={heirChannel}
progress={vault}
/>
) : (
Expand Down Expand Up @@ -169,6 +190,103 @@ export function EmergencyPage({ onNavigate }: PageProps) {
);
}

/**
* The permanent home for every vault tool, linked from the nav.
* The dashboard's More list only shows tools that still need doing;
* once a video is saved, a practice is sent, or reminders are on,
* this page is where the owner comes back to review or change them.
*/
export function ToolsPage({ onNavigate }: PageProps) {
const { meta, ownerToken, vault, pushKey, loading } = useActiveVault();
const done = useToolDoneState(meta?.id ?? null, ownerToken);
const isClaiming =
vault?.status === "timelock_started" || vault?.status === "claiming";
const isClosed = vault?.status === "claimed";

const items: Array<{ label: string; desc: string; route: Route }> = [];
if (vault && !isClosed) {
items.push({
label: "Message for your heir",
desc:
done.hasVideo === true
? "Video saved. Watch, replace, or remove it"
: "Record a short video for your heir",
route: "heir-message",
});
}
if (vault && !isClosed && !isClaiming) {
items.push({
label: "Practice a claim",
desc: vault.drill_completed_at
? "Completed. Send another any time"
: vault.drill_started_at
? "Practice sent. See where it stands"
: "Let your heir rehearse safely",
route: "practice",
});
}
if (vault && ownerToken && pushKey && !isClosed) {
items.push({
label: "Reminders",
desc:
done.remindersOn === true
? "On for this device"
: "Get a nudge to check in",
route: "reminders",
});
}
if (vault?.lnurl_panic && vault.status !== "frozen" && !isClosed && !isClaiming) {
items.push({
label: "Emergency options",
desc: "Freeze this vault if needed",
route: "emergency",
});
}

return (
<ToolPage
title="Vault tools"
intro="Everything you can set up or check for this vault, in one place."
onNavigate={onNavigate}
>
{items.length > 0 ? (
<nav
className="card-flat divide-y divide-[var(--border)] p-0"
aria-label="Vault tools"
>
{items.map((it) => (
<button
key={it.route}
type="button"
onClick={() => onNavigate(it.route)}
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-[var(--surface-2)]"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-[var(--text)]">
{it.label}
</span>
<span className="block truncate text-xs text-muted">
{it.desc}
</span>
</span>
<span aria-hidden="true" className="shrink-0 text-lg text-dim">
</span>
</button>
))}
</nav>
) : (
<Fallback
loading={loading}
hasVault={Boolean(meta)}
emptyText="No tools apply to this vault right now."
onNavigate={onNavigate}
/>
)}
</ToolPage>
);
}

export function RemindersPage({ onNavigate }: PageProps) {
const { meta, ownerToken, vault, pushKey, loading } = useActiveVault();
const ready = Boolean(meta && ownerToken && pushKey && vault?.status !== "claimed");
Expand Down
Loading
Loading