Continue fixing bugs, show details in a modal
This commit is contained in:
+22
-39
@@ -8,6 +8,7 @@ import { ProposeQuorum } from "./components/forms/DkgForms"
|
|||||||
import { ProposeResharing } from "./components/forms/ResharingForms"
|
import { ProposeResharing } from "./components/forms/ResharingForms"
|
||||||
import { ProposeSign } from "./components/forms/SigningForms"
|
import { ProposeSign } from "./components/forms/SigningForms"
|
||||||
import Avatar from "./components/Avatar"
|
import Avatar from "./components/Avatar"
|
||||||
|
import Modal from "./components/Modal"
|
||||||
import { displayProfile } from "@welshman/util"
|
import { displayProfile } from "@welshman/util"
|
||||||
import { useActivePubkey, useProfile } from "./hooks"
|
import { useActivePubkey, useProfile } from "./hooks"
|
||||||
import { isDesktop } from "./lib/media"
|
import { isDesktop } from "./lib/media"
|
||||||
@@ -182,51 +183,33 @@ export default function Layout() {
|
|||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<Show when={showProposeQuorum()}>
|
<Show when={showProposeQuorum()}>
|
||||||
<div
|
<Modal onClose={() => setShowProposeQuorum(false)} contentClass="w-full max-w-md">
|
||||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
{close => (
|
||||||
onClick={() => setShowProposeQuorum(false)}
|
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl p-6">
|
||||||
>
|
<ProposeQuorum onClose={close} />
|
||||||
<div
|
</div>
|
||||||
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
|
)}
|
||||||
onClick={e => e.stopPropagation()}
|
</Modal>
|
||||||
>
|
|
||||||
<ProposeQuorum onClose={() => setShowProposeQuorum(false)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showProposeResharing()}>
|
<Show when={showProposeResharing()}>
|
||||||
<div
|
<Modal onClose={() => setShowProposeResharing(false)} contentClass="w-full max-w-md">
|
||||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
{close => (
|
||||||
onClick={() => setShowProposeResharing(false)}
|
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl p-6">
|
||||||
>
|
<ProposeResharing quorumPubkey={activeQuorum()?.quorumPubkey ?? ""} onClose={close} />
|
||||||
<div
|
</div>
|
||||||
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
|
)}
|
||||||
onClick={e => e.stopPropagation()}
|
</Modal>
|
||||||
>
|
|
||||||
<ProposeResharing
|
|
||||||
quorumPubkey={activeQuorum()?.quorumPubkey ?? ""}
|
|
||||||
onClose={() => setShowProposeResharing(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={showProposeSign()}>
|
<Show when={showProposeSign()}>
|
||||||
<div
|
<Modal onClose={() => setShowProposeSign(false)} contentClass="w-full max-w-md">
|
||||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
{close => (
|
||||||
onClick={() => setShowProposeSign(false)}
|
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl p-6">
|
||||||
>
|
<ProposeSign quorumPubkey={activeQuorum()?.quorumPubkey ?? ""} onClose={close} />
|
||||||
<div
|
</div>
|
||||||
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
|
)}
|
||||||
onClick={e => e.stopPropagation()}
|
</Modal>
|
||||||
>
|
|
||||||
<ProposeSign
|
|
||||||
quorumPubkey={activeQuorum()?.quorumPubkey ?? ""}
|
|
||||||
onClose={() => setShowProposeSign(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { For, Show, createMemo, createSignal } from "solid-js"
|
import { For, Show, createMemo, createSignal } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import toast from "solid-toast"
|
import toast from "solid-toast"
|
||||||
|
import { Pubkey } from "@welshman/util"
|
||||||
import type { TrustedEvent } 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 { 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 { dkgSessions, acceptDkg, declineDkg } from "../engine/dkg"
|
||||||
import { resharingSessions, acceptResharing, declineResharing } from "../engine/resharing"
|
import { resharingSessions, acceptResharing, declineResharing } from "../engine/resharing"
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +67,19 @@ type ActionDef = {
|
|||||||
onDecline: (message: string) => Promise<void>
|
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 ──
|
// ── Generic session card: title + status badge, optional body, optional action ──
|
||||||
function SessionCard(props: {
|
function SessionCard(props: {
|
||||||
title: string
|
title: string
|
||||||
@@ -69,6 +88,7 @@ function SessionCard(props: {
|
|||||||
declined?: () => number
|
declined?: () => number
|
||||||
body?: JSX.Element
|
body?: JSX.Element
|
||||||
action?: () => ActionDef | undefined
|
action?: () => ActionDef | undefined
|
||||||
|
onOpen?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [message, setMessage] = createSignal("")
|
const [message, setMessage] = createSignal("")
|
||||||
const [busy, setBusy] = createSignal(false)
|
const [busy, setBusy] = createSignal(false)
|
||||||
@@ -85,7 +105,10 @@ function SessionCard(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{props.title}</span>
|
<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">
|
<div class="flex items-center gap-1.5 shrink-0">
|
||||||
@@ -104,7 +127,8 @@ function SessionCard(props: {
|
|||||||
|
|
||||||
<Show when={props.action?.()}>
|
<Show when={props.action?.()}>
|
||||||
{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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class={messageInputClass}
|
class={messageInputClass}
|
||||||
@@ -128,7 +152,7 @@ function SessionCard(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── DKG session ────────────────────────────────────────────────────────────────
|
// ── DKG session ────────────────────────────────────────────────────────────────
|
||||||
function DkgSessionCard(props: { session: DkgSession }) {
|
function DkgSessionCard(props: { session: DkgSession; onShowDetail: (d: SessionDetail) => void }) {
|
||||||
const me = useActivePubkey()
|
const me = useActivePubkey()
|
||||||
const progress = useReadable(sessionProgress)
|
const progress = useReadable(sessionProgress)
|
||||||
const joined = () => Object.keys(props.session.round1).length
|
const joined = () => Object.keys(props.session.round1).length
|
||||||
@@ -163,12 +187,20 @@ function DkgSessionCard(props: { session: DkgSession }) {
|
|||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
declined={() => Object.keys(props.session.declines).length}
|
declined={() => Object.keys(props.session.declines).length}
|
||||||
action={action}
|
action={action}
|
||||||
|
onOpen={() => props.onShowDetail({
|
||||||
|
sessionId: props.session.inviteId,
|
||||||
|
title: "Key generation",
|
||||||
|
statusLabel: statusLabel(),
|
||||||
|
statusKind: statusKind(),
|
||||||
|
members: props.session.members,
|
||||||
|
participationLabel: "Joined",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resharing session ────────────────────────────────────────────────────────
|
// ── Resharing session ────────────────────────────────────────────────────────
|
||||||
function ResharingSessionCard(props: { session: ResharingSessionView }) {
|
function ResharingSessionCard(props: { session: ResharingSessionView; onShowDetail: (d: SessionDetail) => void }) {
|
||||||
const me = useActivePubkey()
|
const me = useActivePubkey()
|
||||||
const progress = useReadable(sessionProgress)
|
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 (
|
return (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
title={`Key rotation → ${props.session.newMembers.length} members, threshold ${props.session.newThreshold}`}
|
title={title}
|
||||||
statusKind={statusKind}
|
statusKind={statusKind}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
declined={() => props.session.declinedCount}
|
declined={() => props.session.declinedCount}
|
||||||
action={action}
|
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 ────────────────────────────────────────────────────────────
|
// ── 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 me = useActivePubkey()
|
||||||
const progress = useReadable(sessionProgress)
|
const progress = useReadable(sessionProgress)
|
||||||
const threshold = () => getQuorumRecord(props.session.quorumPubkey)?.threshold ?? 0
|
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 (
|
return (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
title={`Signature request · ${signKindLabel(kind())}`}
|
title={title()}
|
||||||
statusKind={statusKind}
|
statusKind={statusKind}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
declined={() => Object.keys(props.session.declines).length}
|
declined={() => Object.keys(props.session.declines).length}
|
||||||
action={action}
|
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={
|
body={
|
||||||
<Show when={props.request}>
|
<Show when={props.request}>
|
||||||
<div class="flex flex-col gap-1">
|
<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 ──────────────
|
// ── Sessions for one quorum, grouped (one card each), newest first ──────────────
|
||||||
export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
|
export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
|
||||||
|
const [detail, setDetail] = createSignal<SessionDetail | null>(null)
|
||||||
|
|
||||||
const requestById = createMemo(() => {
|
const requestById = createMemo(() => {
|
||||||
const m = new Map<string, TrustedEvent>()
|
const m = new Map<string, TrustedEvent>()
|
||||||
for (const e of signRequestEvents()) { m.set(e.id, e) }
|
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)
|
const isEmpty = createMemo(() => !dkg() && signing().length === 0 && resharing().length === 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<>
|
||||||
when={!isEmpty()}
|
<Show
|
||||||
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
|
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()}>
|
<div class="flex flex-col gap-3">
|
||||||
{session => <SigningSessionCard session={session} request={requestById().get(session.requestId)} />}
|
<For each={signing()}>
|
||||||
</For>
|
{session => (
|
||||||
<For each={resharing()}>
|
<SigningSessionCard
|
||||||
{session => <ResharingSessionCard session={session} />}
|
session={session}
|
||||||
</For>
|
request={requestById().get(session.requestId)}
|
||||||
<Show when={dkg()}>
|
onShowDetail={setDetail}
|
||||||
{session => <DkgSessionCard session={session()} />}
|
/>
|
||||||
</Show>
|
)}
|
||||||
</div>
|
</For>
|
||||||
</Show>
|
<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
@@ -89,22 +89,18 @@ sessionProgress.subscribe(v => setProgressMirror(v))
|
|||||||
quora.subscribe(v => setQuoraMirror(v))
|
quora.subscribe(v => setQuoraMirror(v))
|
||||||
shardRecords.subscribe(v => setShardsMirror(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 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[]>([])
|
function deriveResharingViews(me: string): ResharingSessionView[] {
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const me = pubkey.get()
|
|
||||||
// Touch the store mirrors so this recomputes on persisted-state changes.
|
|
||||||
progressMirror()
|
|
||||||
quoraMirror()
|
|
||||||
shardsMirror()
|
|
||||||
|
|
||||||
if (!me) {
|
|
||||||
setSessions([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const proposals = reshareProposalEvents()
|
const proposals = reshareProposalEvents()
|
||||||
const round1BySession = groupBySession(reshareRound1Events())
|
const round1BySession = groupBySession(reshareRound1Events())
|
||||||
const round2BySession = groupBySession(reshareRound2Events())
|
const round2BySession = groupBySession(reshareRound2Events())
|
||||||
@@ -206,8 +202,8 @@ createEffect(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessions(views)
|
return views
|
||||||
})
|
}
|
||||||
|
|
||||||
function computePhase(
|
function computePhase(
|
||||||
proposalId: string,
|
proposalId: string,
|
||||||
@@ -233,11 +229,18 @@ function computePhase(
|
|||||||
|
|
||||||
/** Reactive accessor for the resharing session list (UI + coordinator). */
|
/** Reactive accessor for the resharing session list (UI + coordinator). */
|
||||||
export function resharingSessions(): ResharingSessionView[] {
|
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 {
|
function findSession(proposalId: string): ResharingSessionView | undefined {
|
||||||
return sessions().find(s => s.proposalId === proposalId)
|
return resharingSessions().find(s => s.proposalId === proposalId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initiate (kind 7054) ───────────────────────────────────────────────────────
|
// ── Initiate (kind 7054) ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 (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`
|
||||||
|
}
|
||||||
+6
-1
@@ -30,7 +30,12 @@ export function persistRepository(): void {
|
|||||||
const tx = db.transaction(STORE, "readwrite")
|
const tx = db.transaction(STORE, "readwrite")
|
||||||
for (const { added, removed } of updates) {
|
for (const { added, removed } of updates) {
|
||||||
for (const e of added) {
|
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) {
|
for (const id of removed) {
|
||||||
tx.store.delete(id)
|
tx.store.delete(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user