From 9ef012ba884fcf618dea7267a583f6aa81a6732c Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 11 Jun 2026 19:34:15 -0700 Subject: [PATCH] Continue fixing bugs, show details in a modal --- src/Layout.tsx | 61 +++---- src/components/Modal.tsx | 42 +++++ src/components/QuorumSessions.tsx | 254 +++++++++++++++++++++++++++--- src/engine/resharing.ts | 39 ++--- src/lib/format.ts | 30 ++++ src/storage.ts | 7 +- 6 files changed, 351 insertions(+), 82 deletions(-) create mode 100644 src/components/Modal.tsx create mode 100644 src/lib/format.ts diff --git a/src/Layout.tsx b/src/Layout.tsx index 4cedf3f..51129e2 100644 --- a/src/Layout.tsx +++ b/src/Layout.tsx @@ -8,6 +8,7 @@ import { ProposeQuorum } from "./components/forms/DkgForms" import { ProposeResharing } from "./components/forms/ResharingForms" import { ProposeSign } from "./components/forms/SigningForms" import Avatar from "./components/Avatar" +import Modal from "./components/Modal" import { displayProfile } from "@welshman/util" import { useActivePubkey, useProfile } from "./hooks" import { isDesktop } from "./lib/media" @@ -182,51 +183,33 @@ export default function Layout() { {/* Modals */} -
setShowProposeQuorum(false)} - > -
e.stopPropagation()} - > - setShowProposeQuorum(false)} /> -
-
+ setShowProposeQuorum(false)} contentClass="w-full max-w-md"> + {close => ( +
+ +
+ )} +
-
setShowProposeResharing(false)} - > -
e.stopPropagation()} - > - setShowProposeResharing(false)} - /> -
-
+ setShowProposeResharing(false)} contentClass="w-full max-w-md"> + {close => ( +
+ +
+ )} +
-
setShowProposeSign(false)} - > -
e.stopPropagation()} - > - setShowProposeSign(false)} - /> -
-
+ setShowProposeSign(false)} contentClass="w-full max-w-md"> + {close => ( +
+ +
+ )} +
) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..76dec8f --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,42 @@ +import { createSignal, onMount } from "solid-js" +import type { JSX } from "solid-js" + +const DURATION = 200 + +/** + * Overlay modal with an enter/exit animation. The children render-prop receives a `close` + * function that plays the exit animation before invoking `onClose` (so the caller can keep + * the modal mounted under a `` and still get an exit transition). + */ +export default function Modal(props: { + onClose: () => void + contentClass?: string + children: (close: () => void) => JSX.Element +}) { + const [open, setOpen] = createSignal(false) + + // Mount hidden, then flip to visible. A double rAF is required: a single frame fires + // before the browser paints the initial hidden state, so the change would coalesce into + // one paint and skip the enter transition. Waiting two frames guarantees the hidden state + // is painted first, so opacity/scale actually animate in. + onMount(() => requestAnimationFrame(() => requestAnimationFrame(() => setOpen(true)))) + + function close() { + setOpen(false) + setTimeout(props.onClose, DURATION) + } + + return ( +
+
e.stopPropagation()} + > + {props.children(close)} +
+
+ ) +} diff --git a/src/components/QuorumSessions.tsx b/src/components/QuorumSessions.tsx index c6e3d31..3a48092 100644 --- a/src/components/QuorumSessions.tsx +++ b/src/components/QuorumSessions.tsx @@ -1,9 +1,15 @@ import { For, Show, createMemo, createSignal } from "solid-js" import type { JSX } from "solid-js" import toast from "solid-toast" +import { Pubkey } from "@welshman/util" import type { TrustedEvent } from "@welshman/util" +import { repository } from "@welshman/app" +import { deriveEvents } from "@welshman/store" import { useReadable } from "../lib/stores" -import { useActivePubkey } from "../hooks" +import Modal from "./Modal" +import { useActivePubkey, useProfileDisplay } from "../hooks" +import { kindLabel, relativeTime } from "../lib/format" +import { PROTOCOL_KINDS } from "../protocol" import { dkgSessions, acceptDkg, declineDkg } from "../engine/dkg" import { resharingSessions, acceptResharing, declineResharing } from "../engine/resharing" import { @@ -61,6 +67,19 @@ type ActionDef = { onDecline: (message: string) => Promise } +// Kinds that signal active participation in a session (round / confirmation events). +const PARTICIPATION_KINDS = new Set([7051, 7052, 7053, 7055, 7056, 7057, 7059, 7060]) + +// What "participated" is called per session type, for the detail summary. +type SessionDetail = { + sessionId: string + title: string + statusLabel: string + statusKind: StatusKind + members: string[] + participationLabel: string +} + // ── Generic session card: title + status badge, optional body, optional action ── function SessionCard(props: { title: string @@ -69,6 +88,7 @@ function SessionCard(props: { declined?: () => number body?: JSX.Element action?: () => ActionDef | undefined + onOpen?: () => void }) { const [message, setMessage] = createSignal("") const [busy, setBusy] = createSignal(false) @@ -85,7 +105,10 @@ function SessionCard(props: { } return ( -
+
props.onOpen?.()} + >
{props.title}
@@ -104,7 +127,8 @@ function SessionCard(props: { {action => ( -
+ // Stop clicks here from opening the detail modal so the controls work normally. +
e.stopPropagation()}> void }) { const me = useActivePubkey() const progress = useReadable(sessionProgress) const joined = () => Object.keys(props.session.round1).length @@ -163,12 +187,20 @@ function DkgSessionCard(props: { session: DkgSession }) { statusLabel={statusLabel} declined={() => Object.keys(props.session.declines).length} action={action} + onOpen={() => props.onShowDetail({ + sessionId: props.session.inviteId, + title: "Key generation", + statusLabel: statusLabel(), + statusKind: statusKind(), + members: props.session.members, + participationLabel: "Joined", + })} /> ) } // ── Resharing session ──────────────────────────────────────────────────────── -function ResharingSessionCard(props: { session: ResharingSessionView }) { +function ResharingSessionCard(props: { session: ResharingSessionView; onShowDetail: (d: SessionDetail) => void }) { const me = useActivePubkey() const progress = useReadable(sessionProgress) @@ -195,19 +227,33 @@ function ResharingSessionCard(props: { session: ResharingSessionView }) { } } + const title = `Key rotation → ${props.session.newMembers.length} members, threshold ${props.session.newThreshold}` + return ( props.session.declinedCount} action={action} + onOpen={() => props.onShowDetail({ + sessionId: props.session.proposalId, + title, + statusLabel: statusLabel(), + statusKind: statusKind(), + members: [...new Set([...props.session.contributors, ...props.session.newMembers])], + participationLabel: "Contributed", + })} /> ) } // ── Signing session ──────────────────────────────────────────────────────────── -function SigningSessionCard(props: { session: SigningSession; request: TrustedEvent | undefined }) { +function SigningSessionCard(props: { + session: SigningSession + request: TrustedEvent | undefined + onShowDetail: (d: SessionDetail) => void +}) { const me = useActivePubkey() const progress = useReadable(sessionProgress) const threshold = () => getQuorumRecord(props.session.quorumPubkey)?.threshold ?? 0 @@ -244,13 +290,23 @@ function SigningSessionCard(props: { session: SigningSession; request: TrustedEv } } + const title = () => `Signature request · ${signKindLabel(kind())}` + return ( Object.keys(props.session.declines).length} action={action} + onOpen={() => props.onShowDetail({ + sessionId: props.session.requestId, + title: title(), + statusLabel: statusLabel(), + statusKind: statusKind(), + members: getQuorumRecord(props.session.quorumPubkey)?.members.map(m => m.pubkey) ?? [], + participationLabel: "Signed", + })} body={
@@ -269,8 +325,142 @@ function SigningSessionCard(props: { session: SigningSession; request: TrustedEv ) } +// ── Detail modal: summary + event timeline for one session ─────────────────────── + +function PubkeyName(props: { pubkey: string }) { + const name = useProfileDisplay(() => props.pubkey) + const npub = () => new Pubkey(props.pubkey).toNpub() + return ( + e.stopPropagation()} + > + @{name()} + + ) +} + +function NameList(props: { pubkeys: string[] }) { + return ( + 0} + fallback={} + > + + + {(pk, i) => ( + + {i() < props.pubkeys.length - 1 ? "," : ""} + + )} + + + + ) +} + +function SessionDetailModal(props: { detail: SessionDetail; onClose: () => void }) { + const d = props.detail + // The session's events: the initiating event itself plus everything that "e"-tags it. + const events = useReadable(deriveEvents({ + repository, + filters: [{ kinds: PROTOCOL_KINDS, "#e": [d.sessionId] }, { ids: [d.sessionId] }], + })) + const timeline = createMemo(() => [...events()].sort((a, b) => a.created_at - b.created_at)) + + const requester = () => events().find(e => e.id === d.sessionId)?.pubkey + const participated = createMemo(() => { + const seen = new Set() + for (const e of events()) { if (PARTICIPATION_KINDS.has(e.kind)) { seen.add(e.pubkey) } } + return [...seen] + }) + const declined = createMemo(() => { + const seen = new Set() + for (const e of events()) { if (e.kind === 7061) { seen.add(e.pubkey) } } + return [...seen] + }) + const awaiting = createMemo(() => { + const acted = new Set([...participated(), ...declined()]) + return d.members.filter(m => !acted.has(m)) + }) + + return ( +
+
+
+ {d.title} + + {d.statusLabel} + +
+ +
+ +
+
+ Requested by + + }> + {pk => } + + + + {d.participationLabel} + + + 0}> + Declined + + + + Awaiting + +
+ +
+ + Timeline + +
    + No events yet.} + > + {e => ( +
  • +
    + {kindLabel(e.kind)} + + + +
    + + {relativeTime(e.created_at)} + +
  • + )} +
    +
+
+
+
+ ) +} + // ── Sessions for one quorum, grouped (one card each), newest first ────────────── export default function QuorumSessions(props: { quorum: DisplayedQuorum }) { + const [detail, setDetail] = createSignal(null) + const requestById = createMemo(() => { const m = new Map() for (const e of signRequestEvents()) { m.set(e.id, e) } @@ -298,21 +488,37 @@ export default function QuorumSessions(props: { quorum: DisplayedQuorum }) { const isEmpty = createMemo(() => !dkg() && signing().length === 0 && resharing().length === 0) return ( - No sessions yet.

} - > -
- - {session => } - - - {session => } - - - {session => } - -
-
+ <> + No sessions yet.

} + > +
+ + {session => ( + + )} + + + {session => } + + + {session => } + +
+
+ + + {d => ( + setDetail(null)} contentClass="w-full max-w-lg"> + {close => } + + )} + + ) } diff --git a/src/engine/resharing.ts b/src/engine/resharing.ts index d83dd72..fcd7c58 100644 --- a/src/engine/resharing.ts +++ b/src/engine/resharing.ts @@ -89,22 +89,18 @@ sessionProgress.subscribe(v => setProgressMirror(v)) quora.subscribe(v => setQuoraMirror(v)) shardRecords.subscribe(v => setShardsMirror(v)) +// Active pubkey as a Solid signal so the derivation re-runs when the session is restored on +// load. pubkey.get() alone is not reactive — on reload the events can hydrate before the +// pubkey, which left the proposal list empty until some other tracked signal changed. +const [mePubkey, setMePubkey] = createSignal("") +pubkey.subscribe(pk => setMePubkey(pk ?? "")) + // ── Derived session list ─────────────────────────────────────────────────────── +// Derived lazily (like dkg/signing) rather than via a module-scope effect → signal: a +// module-top-level createEffect is unreliable outside a reactive root, which left the +// proposal list stuck empty. resharingSessions() recomputes in its caller's reactive scope. -const [sessions, setSessions] = createSignal([]) - -createEffect(() => { - const me = pubkey.get() - // Touch the store mirrors so this recomputes on persisted-state changes. - progressMirror() - quoraMirror() - shardsMirror() - - if (!me) { - setSessions([]) - return - } - +function deriveResharingViews(me: string): ResharingSessionView[] { const proposals = reshareProposalEvents() const round1BySession = groupBySession(reshareRound1Events()) const round2BySession = groupBySession(reshareRound2Events()) @@ -206,8 +202,8 @@ createEffect(() => { }) } - setSessions(views) -}) + return views +} function computePhase( proposalId: string, @@ -233,11 +229,18 @@ function computePhase( /** Reactive accessor for the resharing session list (UI + coordinator). */ export function resharingSessions(): ResharingSessionView[] { - return sessions() + // Track persisted-state + identity changes so the list recomputes on any of them; the + // event signals are tracked inside deriveResharingViews. + progressMirror() + quoraMirror() + shardsMirror() + const me = mePubkey() + if (!me) { return [] } + return deriveResharingViews(me) } function findSession(proposalId: string): ResharingSessionView | undefined { - return sessions().find(s => s.proposalId === proposalId) + return resharingSessions().find(s => s.proposalId === proposalId) } // ── Initiate (kind 7054) ─────────────────────────────────────────────────────── diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..a33be89 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,30 @@ +// Shared display helpers for protocol events. + +const KIND_LABELS: Record = { + 7050: "DKG invite", + 7051: "DKG round 1", + 7052: "DKG round 2", + 7053: "DKG complete", + 7054: "Resharing proposed", + 7055: "Resharing round 1", + 7056: "Resharing round 2", + 7057: "Rotation complete", + 7058: "Sign request", + 7059: "Signing round 1", + 7060: "Signing round 2", + 7061: "Declined", +} + +/** Human-readable label for a protocol event kind (7050–7061). */ +export function kindLabel(kind: number): string { + return KIND_LABELS[kind] ?? `Event ${kind}` +} + +/** Compact relative time, e.g. "2m ago". */ +export function relativeTime(createdAt: number): string { + const age = Math.floor(Date.now() / 1000) - createdAt + if (age < 60) { return `${Math.max(0, age)}s ago` } + if (age < 3600) { return `${Math.floor(age / 60)}m ago` } + if (age < 86400) { return `${Math.floor(age / 3600)}h ago` } + return `${Math.floor(age / 86400)}d ago` +} diff --git a/src/storage.ts b/src/storage.ts index 37e1a2e..ea32dc9 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -30,7 +30,12 @@ export function persistRepository(): void { const tx = db.transaction(STORE, "readwrite") for (const { added, removed } of updates) { for (const e of added) { - if (isProtocolKind(e.kind)) tx.store.put(e) + // Persist protocol events AND quorum chat (kind 14). Chat must be kept locally + // because the sender never receives their own gift wrap, so their own messages + // can't be re-fetched from relays on reload. + if (isProtocolKind(e.kind) || (e.kind === 14 && e.tags.some(t => t[0] === "quorum"))) { + tx.store.put(e) + } } for (const id of removed) { tx.store.delete(id)