Continue fixing bugs, show details in a modal

This commit is contained in:
Jon Staab
2026-06-11 19:34:15 -07:00
parent a1ddb3bbd7
commit 9ef012ba88
6 changed files with 351 additions and 82 deletions
+22 -39
View File
@@ -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 */}
<Show when={showProposeQuorum()}>
<div
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setShowProposeQuorum(false)}
>
<div
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
onClick={e => e.stopPropagation()}
>
<ProposeQuorum onClose={() => setShowProposeQuorum(false)} />
</div>
</div>
<Modal onClose={() => setShowProposeQuorum(false)} contentClass="w-full max-w-md">
{close => (
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl p-6">
<ProposeQuorum onClose={close} />
</div>
)}
</Modal>
</Show>
<Show when={showProposeResharing()}>
<div
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setShowProposeResharing(false)}
>
<div
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
onClick={e => e.stopPropagation()}
>
<ProposeResharing
quorumPubkey={activeQuorum()?.quorumPubkey ?? ""}
onClose={() => setShowProposeResharing(false)}
/>
</div>
</div>
<Modal onClose={() => setShowProposeResharing(false)} contentClass="w-full max-w-md">
{close => (
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl p-6">
<ProposeResharing quorumPubkey={activeQuorum()?.quorumPubkey ?? ""} onClose={close} />
</div>
)}
</Modal>
</Show>
<Show when={showProposeSign()}>
<div
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setShowProposeSign(false)}
>
<div
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
onClick={e => e.stopPropagation()}
>
<ProposeSign
quorumPubkey={activeQuorum()?.quorumPubkey ?? ""}
onClose={() => setShowProposeSign(false)}
/>
</div>
</div>
<Modal onClose={() => setShowProposeSign(false)} contentClass="w-full max-w-md">
{close => (
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl p-6">
<ProposeSign quorumPubkey={activeQuorum()?.quorumPubkey ?? ""} onClose={close} />
</div>
)}
</Modal>
</Show>
</div>
)
+42
View File
@@ -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 `<Show>` 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 (
<div
class={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-colors duration-200 ${open() ? "bg-black/50" : "bg-black/0"}`}
onClick={close}
>
<div
class={`transition-all duration-200 ease-out ${open() ? "opacity-100 scale-100" : "opacity-0 scale-95"} ${props.contentClass ?? "w-full max-w-md"}`}
onClick={e => e.stopPropagation()}
>
{props.children(close)}
</div>
</div>
)
}
+230 -24
View File
@@ -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<void>
}
// 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 (
<div class={cardClass}>
<div
class={`${cardClass} ${props.onOpen ? "cursor-pointer hover:border-blue-300 dark:hover:border-blue-700 transition-colors" : ""}`}
onClick={() => props.onOpen?.()}
>
<div class="flex items-start justify-between gap-3">
<span class="text-sm font-semibold text-gray-900 dark:text-white">{props.title}</span>
<div class="flex items-center gap-1.5 shrink-0">
@@ -104,7 +127,8 @@ function SessionCard(props: {
<Show when={props.action?.()}>
{action => (
<div class="flex flex-col gap-2">
// Stop clicks here from opening the detail modal so the controls work normally.
<div class="flex flex-col gap-2" onClick={e => e.stopPropagation()}>
<input
type="text"
class={messageInputClass}
@@ -128,7 +152,7 @@ function SessionCard(props: {
}
// ── DKG session ────────────────────────────────────────────────────────────────
function DkgSessionCard(props: { session: DkgSession }) {
function DkgSessionCard(props: { session: DkgSession; onShowDetail: (d: SessionDetail) => 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 (
<SessionCard
title={`Key rotation → ${props.session.newMembers.length} members, threshold ${props.session.newThreshold}`}
title={title}
statusKind={statusKind}
statusLabel={statusLabel}
declined={() => 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 (
<SessionCard
title={`Signature request · ${signKindLabel(kind())}`}
title={title()}
statusKind={statusKind}
statusLabel={statusLabel}
declined={() => 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={
<Show when={props.request}>
<div class="flex flex-col gap-1">
@@ -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 (
<a
href={`https://coracle.social/${npub()}`}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
onClick={e => e.stopPropagation()}
>
@{name()}
</a>
)
}
function NameList(props: { pubkeys: string[] }) {
return (
<Show
when={props.pubkeys.length > 0}
fallback={<span class="text-gray-400 dark:text-neutral-500"></span>}
>
<span class="flex flex-wrap gap-x-1.5">
<For each={props.pubkeys}>
{(pk, i) => (
<span>
<PubkeyName pubkey={pk} />{i() < props.pubkeys.length - 1 ? "," : ""}
</span>
)}
</For>
</span>
</Show>
)
}
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<string>()
for (const e of events()) { if (PARTICIPATION_KINDS.has(e.kind)) { seen.add(e.pubkey) } }
return [...seen]
})
const declined = createMemo(() => {
const seen = new Set<string>()
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 (
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-h-[85vh] flex flex-col">
<div class="flex items-start justify-between gap-3 px-5 py-4 border-b border-gray-200 dark:border-neutral-700">
<div class="flex items-center gap-2 min-w-0">
<span class="text-base font-semibold text-gray-900 dark:text-white truncate">{d.title}</span>
<span class={`px-2 py-0.5 rounded-full text-[11px] font-medium shrink-0 ${statusBadge[d.statusKind]}`}>
{d.statusLabel}
</span>
</div>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-neutral-200 shrink-0"
onClick={props.onClose}
aria-label="Close"
>
</button>
</div>
<div class="overflow-y-auto px-5 py-4 flex flex-col gap-5">
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<span class="text-gray-500 dark:text-neutral-400">Requested by</span>
<span>
<Show when={requester()} fallback={<span class="text-gray-400 dark:text-neutral-500"></span>}>
{pk => <PubkeyName pubkey={pk()} />}
</Show>
</span>
<span class="text-gray-500 dark:text-neutral-400">{d.participationLabel}</span>
<NameList pubkeys={participated()} />
<Show when={declined().length > 0}>
<span class="text-gray-500 dark:text-neutral-400">Declined</span>
<NameList pubkeys={declined()} />
</Show>
<span class="text-gray-500 dark:text-neutral-400">Awaiting</span>
<NameList pubkeys={awaiting()} />
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-neutral-400">
Timeline
</span>
<ul class="flex flex-col">
<For
each={timeline()}
fallback={<li class="text-sm text-gray-400 dark:text-neutral-500 py-1">No events yet.</li>}
>
{e => (
<li class="flex items-start justify-between gap-3 py-1.5 border-b border-gray-100 dark:border-neutral-700 last:border-b-0">
<div class="flex flex-col min-w-0">
<span class="text-sm text-gray-900 dark:text-white">{kindLabel(e.kind)}</span>
<span class="text-xs text-gray-500 dark:text-neutral-400 truncate">
<PubkeyName pubkey={e.pubkey} />
</span>
</div>
<span class="text-xs text-gray-400 dark:text-neutral-500 shrink-0">
{relativeTime(e.created_at)}
</span>
</li>
)}
</For>
</ul>
</div>
</div>
</div>
)
}
// ── Sessions for one quorum, grouped (one card each), newest first ──────────────
export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
const [detail, setDetail] = createSignal<SessionDetail | null>(null)
const requestById = createMemo(() => {
const m = new Map<string, TrustedEvent>()
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 (
<Show
when={!isEmpty()}
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
>
<div class="flex flex-col gap-3">
<For each={signing()}>
{session => <SigningSessionCard session={session} request={requestById().get(session.requestId)} />}
</For>
<For each={resharing()}>
{session => <ResharingSessionCard session={session} />}
</For>
<Show when={dkg()}>
{session => <DkgSessionCard session={session()} />}
</Show>
</div>
</Show>
<>
<Show
when={!isEmpty()}
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
>
<div class="flex flex-col gap-3">
<For each={signing()}>
{session => (
<SigningSessionCard
session={session}
request={requestById().get(session.requestId)}
onShowDetail={setDetail}
/>
)}
</For>
<For each={resharing()}>
{session => <ResharingSessionCard session={session} onShowDetail={setDetail} />}
</For>
<Show when={dkg()}>
{session => <DkgSessionCard session={session()} onShowDetail={setDetail} />}
</Show>
</div>
</Show>
<Show when={detail()}>
{d => (
<Modal onClose={() => setDetail(null)} contentClass="w-full max-w-lg">
{close => <SessionDetailModal detail={d()} onClose={close} />}
</Modal>
)}
</Show>
</>
)
}
+21 -18
View File
@@ -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<ResharingSessionView[]>([])
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) ───────────────────────────────────────────────────────
+30
View File
@@ -0,0 +1,30 @@
// Shared display helpers for protocol events.
const KIND_LABELS: Record<number, string> = {
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 (70507061). */
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`
}
+6 -1
View File
@@ -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)