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 { 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>
|
||||
)
|
||||
|
||||
@@ -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 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
@@ -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) ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user