Unsubscribe on hmr

This commit is contained in:
Jon Staab
2026-06-12 12:52:10 -07:00
parent 9ef012ba88
commit 9dff41f096
15 changed files with 258 additions and 100 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ Each protocol flow is initiated by a single event (the invite, resharing proposa
## Event Kinds
All events in this protocol are sent as the `content` payload of a NIP-59 gift wrap (kind 1059) with at least 16 bits of proof-of-work per NIP-13. Each wrapper carries a `["t", "b7ed"]` tag identifying this sub-protocol, allowing recipients to filter their inbox efficiently. The `content` is encrypted to the recipient's pubkey and the wrap is sent to the recipient's inbox relays (kind 10050) per NIP-17.
All events in this protocol are sent as the `content` payload of a NIP-59 gift wrap (kind 1059) with at least 16 bits of proof-of-work per NIP-13. The `content` is encrypted to the recipient's pubkey and the wrap is sent to the recipient's inbox relays (kind 10050) per NIP-17.
## Quorum Creation
+98 -45
View File
@@ -1,4 +1,4 @@
import { For, Show, createMemo, createSignal } from "solid-js"
import { For, Show, createMemo, createSignal, ErrorBoundary } from "solid-js"
import type { JSX } from "solid-js"
import toast from "solid-toast"
import { Pubkey } from "@welshman/util"
@@ -15,7 +15,7 @@ import { resharingSessions, acceptResharing, declineResharing } from "../engine/
import {
signingSessions, acceptSign, declineSign, isSigningAborted, publishedSignature,
} from "../engine/signing"
import { signRequestEvents } from "../engine/events"
import { signRequestEvents, reshareProposalEvents } from "../engine/events"
import { sessionProgress, getProgress, getQuorumRecord } from "../engine/secrets"
import type { DisplayedQuorum, DkgSession, SigningSession } from "../models"
import type { ResharingSessionView } from "../engine/resharing"
@@ -46,6 +46,15 @@ const phaseLabels: Record<string, string> = {
}
const phaseLabel = (phase: string) => phaseLabels[phase] ?? phase
/**
* A threshold request is dead once more than (total threshold) participants have declined,
* since fewer than `threshold` can still participate. Such a session shows as "Declined" with
* no actions.
*/
function isOverDeclined(declined: number, total: number, threshold: number): boolean {
return total > 0 && threshold > 0 && declined > total - threshold
}
function signKindLabel(kind: number): string {
if (kind === 1) { return "Public note" }
if (kind === 0) { return "Profile" }
@@ -78,6 +87,8 @@ type SessionDetail = {
statusKind: StatusKind
members: string[]
participationLabel: string
// For rotations: the member set before vs. after the rotation.
membership?: { before: string[]; after: string[] }
}
// ── Generic session card: title + status badge, optional body, optional action ──
@@ -204,11 +215,20 @@ function ResharingSessionCard(props: { session: ResharingSessionView; onShowDeta
const me = useActivePubkey()
const progress = useReadable(sessionProgress)
// The contributing set is fixed, so the rotation is dead once enough contributors decline.
const declinedOut = () =>
isOverDeclined(
props.session.contributors.filter(c => props.session.declines[c] !== undefined).length,
props.session.contributors.length,
getQuorumRecord(props.session.quorumPubkey)?.threshold ?? 0,
)
const statusKind = (): StatusKind =>
props.session.aborted ? "failed" : props.session.phase === "complete" ? "complete" : "active"
props.session.aborted || declinedOut() ? "failed" : props.session.phase === "complete" ? "complete" : "active"
const statusLabel = () => {
if (props.session.aborted) { return "Aborted" }
if (declinedOut()) { return "Declined" }
if (props.session.phase === "complete") { return "Complete" }
return `${phaseLabel(props.session.phase)} · ${props.session.round1Count}/${props.session.contributors.length} contributed`
}
@@ -216,7 +236,7 @@ function ResharingSessionCard(props: { session: ResharingSessionView; onShowDeta
const action = (): ActionDef | undefined => {
progress()
const mine = me()
if (!mine || props.session.phase === "complete" || props.session.aborted || props.session.declined) { return undefined }
if (!mine || props.session.phase === "complete" || props.session.aborted || props.session.declined || declinedOut()) { return undefined }
if (!props.session.iAmContributor && !props.session.iAmNewMember) { return undefined }
const p = getProgress(props.session.proposalId)
if (p.sentRound1 || p.finalized || p.declined) { return undefined }
@@ -243,6 +263,10 @@ function ResharingSessionCard(props: { session: ResharingSessionView; onShowDeta
statusKind: statusKind(),
members: [...new Set([...props.session.contributors, ...props.session.newMembers])],
participationLabel: "Contributed",
membership: {
before: Object.keys(props.session.dkgCommitsByPk),
after: props.session.newMembers,
},
})}
/>
)
@@ -256,9 +280,12 @@ function SigningSessionCard(props: {
}) {
const me = useActivePubkey()
const progress = useReadable(sessionProgress)
const threshold = () => getQuorumRecord(props.session.quorumPubkey)?.threshold ?? 0
const quorumRecord = () => getQuorumRecord(props.session.quorumPubkey)
const threshold = () => quorumRecord()?.threshold ?? 0
const collected = () => Object.keys(props.session.round1).length
const aborted = () => isSigningAborted(props.session.requestId)
const declinedOut = () =>
isOverDeclined(Object.keys(props.session.declines).length, quorumRecord()?.members.length ?? 0, threshold())
const kind = () => {
try {
@@ -269,10 +296,11 @@ function SigningSessionCard(props: {
}
const statusKind = (): StatusKind =>
aborted() ? "failed" : props.session.phase === "complete" ? "complete" : "active"
aborted() || declinedOut() ? "failed" : props.session.phase === "complete" ? "complete" : "active"
const statusLabel = () => {
if (aborted()) { return "Aborted" }
if (declinedOut()) { return "Declined" }
if (props.session.phase === "complete") { return "Signed" }
return `${phaseLabel(props.session.phase)} · ${collected()}/${threshold()} signing`
}
@@ -280,7 +308,7 @@ function SigningSessionCard(props: {
const action = (): ActionDef | undefined => {
progress()
const mine = me()
if (!mine || props.session.phase === "complete" || aborted()) { return undefined }
if (!mine || props.session.phase === "complete" || aborted() || declinedOut()) { return undefined }
const p = getProgress(props.session.requestId)
if (p.sentRound1 || p.declined) { return undefined }
return {
@@ -425,6 +453,17 @@ function SessionDetailModal(props: { detail: SessionDetail; onClose: () => void
<span class="text-gray-500 dark:text-neutral-400">Awaiting</span>
<NameList pubkeys={awaiting()} />
<Show when={d.membership}>
{membership => (
<>
<span class="text-gray-500 dark:text-neutral-400">Members before</span>
<NameList pubkeys={membership().before} />
<span class="text-gray-500 dark:text-neutral-400">Members after</span>
<NameList pubkeys={membership().after} />
</>
)}
</Show>
</div>
<div class="flex flex-col gap-1.5">
@@ -457,7 +496,12 @@ function SessionDetailModal(props: { detail: SessionDetail; onClose: () => void
)
}
// ── Sessions for one quorum, grouped (one card each), newest first ──────────────
// ── Sessions for one quorum, one card each, newest first ────────────────────────
type SessionItem =
| { kind: "signing"; createdAt: number; session: SigningSession; request: TrustedEvent | undefined }
| { kind: "resharing"; createdAt: number; session: ResharingSessionView }
| { kind: "dkg"; createdAt: number; session: DkgSession }
export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
const [detail, setDetail] = createSignal<SessionDetail | null>(null)
@@ -466,51 +510,60 @@ export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
for (const e of signRequestEvents()) { m.set(e.id, e) }
return m
})
const dkg = createMemo<DkgSession | undefined>(() =>
dkgSessions().find(s => s.inviteId === props.quorum.inviteId))
const signing = createMemo<SigningSession[]>(() => {
const pk = props.quorum.quorumPubkey
if (!pk) { return [] }
return signingSessions()
.filter(s => s.quorumPubkey === pk)
.sort((a, b) =>
(requestById().get(b.requestId)?.created_at ?? 0) - (requestById().get(a.requestId)?.created_at ?? 0))
const proposalById = createMemo(() => {
const m = new Map<string, TrustedEvent>()
for (const e of reshareProposalEvents()) { m.set(e.id, e) }
return m
})
const resharing = createMemo<ResharingSessionView[]>(() => {
// All of the quorum's sessions, interleaved and sorted by their initiating event's
// timestamp (newest first) so a freshly created session appears at the top.
const items = createMemo<SessionItem[]>(() => {
const pk = props.quorum.quorumPubkey
if (!pk) { return [] }
return resharingSessions().filter(s => s.quorumPubkey === pk)
})
const list: SessionItem[] = []
const isEmpty = createMemo(() => !dkg() && signing().length === 0 && resharing().length === 0)
const dkg = dkgSessions().find(s => s.inviteId === props.quorum.inviteId)
if (dkg) {
list.push({ kind: "dkg", createdAt: props.quorum.createdAt, session: dkg })
}
if (pk) {
for (const session of signingSessions().filter(s => s.quorumPubkey === pk)) {
const request = requestById().get(session.requestId)
list.push({ kind: "signing", createdAt: request?.created_at ?? 0, session, request })
}
for (const session of resharingSessions().filter(s => s.quorumPubkey === pk)) {
list.push({ kind: "resharing", createdAt: proposalById().get(session.proposalId)?.created_at ?? 0, session })
}
}
return list.sort((a, b) => b.createdAt - a.createdAt)
})
return (
<>
<Show
when={!isEmpty()}
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
<ErrorBoundary
fallback={err =>
<p class="text-sm text-red-600 dark:text-red-400">Failed to render sessions: {String(err)}</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={items().length > 0}
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={items()}>
{item => {
if (item.kind === "signing") {
return <SigningSessionCard session={item.session} request={item.request} onShowDetail={setDetail} />
}
if (item.kind === "resharing") {
return <ResharingSessionCard session={item.session} onShowDetail={setDetail} />
}
return <DkgSessionCard session={item.session} onShowDetail={setDetail} />
}}
</For>
</div>
</Show>
</ErrorBoundary>
<Show when={detail()}>
{d => (
+12 -8
View File
@@ -8,7 +8,10 @@ import { proposeResharing, acceptResharing, declineResharing } from "../../engin
import { getQuorumRecord } from "../../engine/secrets"
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
const [newMembers, setNewMembers] = createSignal<string[]>([])
// Pre-populate with the quorum's current members; the user edits from there (adds/removes).
const [newMembers, setNewMembers] = createSignal<string[]>(
getQuorumRecord(props.quorumPubkey)?.members.map(m => m.pubkey) ?? [],
)
const [newThreshold, setNewThreshold] = useAutoThreshold(newMembers)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -40,13 +43,9 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
setSubmitting(true)
try {
// Optimistic: a missing messaging relay is surfaced as a warning, but never blocks
// the rotation — the proposal is stored locally and delivered best-effort.
const relayError = await validateMessagingRelays(members)
if (relayError) {
toast(relayError)
}
// Create the proposal FIRST — it's stored locally and delivered best-effort, so it
// must never be gated behind the relay check below (which fetches relay lists for
// brand-new member pubkeys and can hang or reject, preventing the proposal entirely).
await proposeResharing({
quorumPubkey: props.quorumPubkey,
newMembers: members,
@@ -59,6 +58,11 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
} finally {
setSubmitting(false)
}
// Informational only: warn (without blocking) if any new member has no messaging relays.
void validateMessagingRelays(members)
.then(relayError => { if (relayError) { toast(relayError) } })
.catch(() => undefined)
}
return (
+3 -1
View File
@@ -12,6 +12,7 @@ import { deriveEvents } from "@welshman/store"
import { makeEvent, prep, getRelaysFromList, getTagValue } from "@welshman/util"
import type { TrustedEvent, HashedEvent } from "@welshman/util"
import { Router } from "@welshman/router"
import { trackForHmr } from "../lib/hmr"
// ── NIP-17 group chat among quorum members ─────────────────────────────────────
// Members exchange kind-14 chat messages under their OWN pubkeys, gift-wrapped per
@@ -68,7 +69,8 @@ export async function sendChatMessage(threadId: string, members: string[], text:
// One app-lifetime subscription over all kind-14 events (inbound unwrapped rumors +
// our own local copies); chatMessages filters to the requested thread on read.
const [allChat, setAllChat] = createSignal<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: [CHAT_KIND] }] }).subscribe(setAllChat)
// Drop on hot reload so an edit doesn't stack a second kind-14 subscription on the repository.
trackForHmr(import.meta.hot)(deriveEvents({ repository, filters: [{ kinds: [CHAT_KIND] }] }).subscribe(setAllChat))
/**
* Kind-14 messages for a quorum, sorted ascending by created_at. Pass every id the thread
+10 -4
View File
@@ -1,4 +1,4 @@
import { createEffect, createSignal } from "solid-js"
import { createSignal } from "solid-js"
import { toast } from "solid-toast"
import { pubkey, signer, loadMessagingRelayList } from "@welshman/app"
import { getTagValue } from "@welshman/util"
@@ -41,18 +41,24 @@ import {
sessionProgress,
} from "./secrets"
import { openQuorum, setDelivery } from "../store"
import { trackForHmr, trackEffect } from "../lib/hmr"
// Tear down the app-lifetime subscriptions + coordinator below on hot reload, so an edit
// doesn't stack a duplicate on the surviving session/progress singletons (a duplicate
// coordinator would double-process protocol steps — e.g. self-equivocating round 1).
const track = trackForHmr(import.meta.hot)
// ── Active pubkey, bridged to a Solid signal ───────────────────────────────────
// Mirrors the store.ts pattern: module-scope signals fed by welshman stores so the
// derivation re-runs reactively as the session/identity changes.
const [me, setMe] = createSignal("")
pubkey.subscribe(pk => setMe(pk ?? ""))
track(pubkey.subscribe(pk => setMe(pk ?? "")))
// Progress lives in localStorage (a svelte store), not a Solid signal, so changes to
// it must explicitly retrigger derivation. Bumping this tick on every progress write
// makes dkgSessions() recompute when a flag flips (e.g. after a guarded send).
const [progressTick, setProgressTick] = createSignal(0)
sessionProgress.subscribe(() => setProgressTick(n => n + 1))
track(sessionProgress.subscribe(() => setProgressTick(n => n + 1)))
// ── Session-state derivation ───────────────────────────────────────────────────
// A DKG session is a pure function of the 7050 invite, the 7051/7052/7053/7061 events
@@ -208,7 +214,7 @@ function isEquivocated(inviteId: string, members: string[]): boolean {
const inFlight = new Set<string>()
export function startDkg(): void {
createEffect(() => {
trackEffect(track, () => {
for (const s of dkgSessions()) {
void advanceDkg(s) // fire-and-forget; each step is guarded
}
+6 -1
View File
@@ -4,15 +4,20 @@ import { repository } from "@welshman/app"
import { getTagValue, getTagValues } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import type { Hex } from "../protocol"
import { trackForHmr } from "../lib/hmr"
// ── Per-kind repository-derived signals ───────────────────────────────────────
// One subscription per protocol kind, alive for the app lifetime — the same
// pattern src/store.ts uses. Each returns a Solid accessor of the current set of
// events of that kind in the repository (inbound rumors + our own local copies).
// Drop every per-kind subscription on hot reload so an edit doesn't stack a second one
// on the surviving repository singleton.
const track = trackForHmr(import.meta.hot)
function kindSignal(kind: number): () => TrustedEvent[] {
const [get, set] = createSignal<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: [kind] }] }).subscribe(set)
track(deriveEvents({ repository, filters: [{ kinds: [kind] }] }).subscribe(set))
return get
}
+10 -5
View File
@@ -6,6 +6,7 @@ import { getTagValue } from "@welshman/util"
import type { TrustedEvent } from "@welshman/util"
import { PROTOCOL_KINDS } from "../protocol"
import type { DisplayedQuorum } from "../models"
import { trackForHmr } from "../lib/hmr"
// ── Notification checkpoints ────────────────────────────────────────────────────
// A "checkpoint" is the timestamp up to which a quorum's tab has been read. A tab has
@@ -36,18 +37,22 @@ function read(): NotificationState {
return value
}
// Drop these app-lifetime subscriptions on hot reload so an edit doesn't stack duplicates
// on the surviving notifications store / session pubkey / repository singletons.
const track = trackForHmr(import.meta.hot)
const [state, setState] = createSignal<NotificationState>(read())
notifications.subscribe(setState)
track(notifications.subscribe(setState))
const [me, setMe] = createSignal("")
pubkey.subscribe(pk => {
track(pubkey.subscribe(pk => {
setMe(pk ?? "")
// First session ever (e.g. a restored login predating this feature): seed the baseline
// so existing history isn't all flagged as new. Explicit logins reset it via onLogin().
if (pk && read().baseline === 0) {
notifications.set({ ...read(), baseline: now() - WEEK })
}
})
}))
// Account-scoped so two local accounts in the same quorum don't share read state.
const checkpointKey = (pk: string, quorumId: string, tab: QuorumTab) => `${pk}:${quorumId}:${tab}`
@@ -72,10 +77,10 @@ function checkpoint(quorumId: string, tab: QuorumTab): number {
// ── Reactive event streams ──────────────────────────────────────────────────────
const [allProtocol, setAllProtocol] = createSignal<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: PROTOCOL_KINDS }] }).subscribe(setAllProtocol)
track(deriveEvents({ repository, filters: [{ kinds: PROTOCOL_KINDS }] }).subscribe(setAllProtocol))
const [allChat, setAllChat] = createSignal<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: [CHAT_KIND] }] }).subscribe(setAllChat)
track(deriveEvents({ repository, filters: [{ kinds: [CHAT_KIND] }] }).subscribe(setAllChat))
// A quorum's protocol events: the invite, anything that "e"-tags it (DKG rounds/declines),
// and once established anything carrying its quorum tag (confirmations, resharing, signing).
+32 -13
View File
@@ -1,4 +1,4 @@
import { createSignal, createEffect } from "solid-js"
import { createSignal } from "solid-js"
import { toast } from "solid-toast"
import { pubkey, signer, repository, loadMessagingRelayList } from "@welshman/app"
import { getTagValue } from "@welshman/util"
@@ -46,6 +46,7 @@ import {
} from "./secrets"
import { sendProtocolEvent, deliverTo } from "./delivery"
import { sendChatMessage } from "./chat"
import { trackForHmr, trackEffect } from "../lib/hmr"
// ── Local helpers ─────────────────────────────────────────────────────────────
@@ -85,15 +86,20 @@ const [progressMirror, setProgressMirror] = createSignal<Record<string, ReturnTy
const [quoraMirror, setQuoraMirror] = createSignal<unknown>(undefined)
const [shardsMirror, setShardsMirror] = createSignal<unknown>(undefined)
sessionProgress.subscribe(v => setProgressMirror(v))
quora.subscribe(v => setQuoraMirror(v))
shardRecords.subscribe(v => setShardsMirror(v))
// Tear down these app-lifetime subscriptions + the coordinator below on hot reload, so an
// edit doesn't stack a duplicate on the surviving progress/quora/shard/session singletons (a
// duplicate coordinator would double-advance a rotation — e.g. resend round-2 shares).
const track = trackForHmr(import.meta.hot)
track(sessionProgress.subscribe(v => setProgressMirror(v)))
track(quora.subscribe(v => setQuoraMirror(v)))
track(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 ?? ""))
track(pubkey.subscribe(pk => setMePubkey(pk ?? "")))
// ── Derived session list ───────────────────────────────────────────────────────
// Derived lazily (like dkg/signing) rather than via a module-scope effect → signal: a
@@ -286,16 +292,24 @@ export async function proposeResharing(opts: {
)
const proposalId = rumor.id
// Store the proposal in the repository IMMEDIATELY so it appears right away, before any
// network work. (A previous version awaited loadMessagingRelayList for every recipient
// first, which could hang on a brand-new member pubkey and prevent the proposal entirely.)
repository.publish(rumor)
// The initiator implicitly participates in their own rotation: mark consent so the
// coordinator advances their round 1 (if they're a contributor) and finalize (if a new
// member) automatically, without requiring them to click "Participate" on their own proposal.
acceptedSessions.add(proposalId)
setAcceptTick(n => n + 1)
// Recipients = (current new) \ {me}. Contributors run round 1; new members verify+finalize.
const recipients = unique([...currentPubkeys, ...opts.newMembers]).filter(pk => pk !== me)
// Best-effort: warm messaging relay lists so delivery can resolve inbox relays.
await Promise.all(recipients.map(pk => loadMessagingRelayList(pk).catch(() => {})))
// Optimistic local store + best-effort gift-wrapped delivery. Creation must not fail
// just because delivery did — the proposal's presence in the repository is its own guard.
// Best-effort gift-wrapped delivery. Creation already succeeded (stored above), so a
// delivery failure only affects whether peers receive it — never local visibility.
try {
await sendProtocolEvent(rumor, recipients)
await sendProtocolEvent(rumor, recipients, { storeLocal: false })
} catch (e) {
console.error("propose resharing delivery failed", e)
toast.error("Rotation proposed, but delivery to some members failed")
@@ -318,6 +332,7 @@ export async function acceptResharing(proposalId: string, message?: string): Pro
// Mark intent; the coordinator (createEffect) observes this consent and advances the
// session idempotently. We never run crypto here, so accept can't race the coordinator.
acceptedSessions.add(proposalId)
setAcceptTick(n => n + 1)
if (message && s.quorumPubkey) {
// Reach everyone involved in the rotation (old contributors + new members).
@@ -359,6 +374,9 @@ const inFlight = new Set<string>()
// who reloads before finalize simply re-accepts from the inbox. The coordinator never
// reshares a shard or saves a new shard / confirms without consent.
const acceptedSessions = new Set<string>()
// Bumped whenever a session is accepted. The coordinator can't track the plain Set above,
// so it reads this tick to re-run and advance the newly-consented session.
const [acceptTick, setAcceptTick] = createSignal(0)
function isAccepted(s: ResharingSessionView, progress: ReturnType<typeof getProgress>): boolean {
return acceptedSessions.has(s.proposalId) || progress.sentRound1 === true
@@ -370,8 +388,9 @@ export function startResharing(): void {
if (started) { return }
started = true
createEffect(() => {
const me = pubkey.get()
trackEffect(track, () => {
acceptTick() // re-run when a participant accepts (acceptedSessions is not a tracked signal)
const me = mePubkey()
if (!me) { return }
for (const s of resharingSessions()) {
if (s.aborted || s.declined) { continue }
+10 -4
View File
@@ -1,4 +1,4 @@
import { createEffect, createSignal } from "solid-js"
import { createSignal } from "solid-js"
import { toast } from "solid-toast"
import { hexToBytes } from "@noble/curves-v2/utils.js"
import { pubkey, repository, publishThunk, getPubkeyRelays, loadRelayList } from "@welshman/app"
@@ -39,19 +39,25 @@ import {
groupBySession,
latestByAuthor,
} from "./events"
import { trackForHmr, trackEffect } from "../lib/hmr"
// Tear down the app-lifetime subscriptions + coordinator below on hot reload, so an edit
// doesn't stack a duplicate on the surviving session/progress singletons (a duplicate
// coordinator would double-send nonces/shares for an in-flight signature).
const track = trackForHmr(import.meta.hot)
// Active pubkey bridged to a Solid signal so the coordinator and session views re-run
// reactively when the session is restored on login (pubkey.get() alone is not reactive,
// which left the coordinator never advancing a request created before login hydrated).
const [me, setMe] = createSignal("")
pubkey.subscribe(pk => setMe(pk ?? ""))
track(pubkey.subscribe(pk => setMe(pk ?? "")))
// Progress lives in localStorage (a svelte store), not a Solid signal, so a flag change
// (e.g. `finalized` set by aggregation) must explicitly retrigger derivation. Bumping this
// tick on every progress write makes signingSessions() recompute — without it the badge
// stays on "round 2" after the signature is finalized instead of showing "Signed".
const [progressTick, setProgressTick] = createSignal(0)
sessionProgress.subscribe(() => setProgressTick(n => n + 1))
track(sessionProgress.subscribe(() => setProgressTick(n => n + 1)))
// ─── Deterministic signing-set rule S ──────────────────────────────────────────
//
@@ -572,7 +578,7 @@ function advanceSigning(mine: Hex, s: Snapshot, tasks: Array<() => Promise<void>
* never causes a duplicate send.
*/
export function startSigning(): void {
createEffect(() => {
trackEffect(track, () => {
progressTick() // re-advance when a progress flag flips, not only on new events
const mine = me()
if (!mine) { return }
+34
View File
@@ -0,0 +1,34 @@
import { createRoot, createEffect } from "solid-js"
/**
* Tear down module-level subscriptions to app-lifetime singletons (the welshman repository
* and session stores, `window` listeners, etc.) when Vite hot-replaces the module. Without
* this, every edit re-runs the module and stacks a *second* live subscription on the
* surviving singleton — double persistence, double protocol processing, duplicate effects.
*
* `import.meta.hot` is module-scoped, so each caller passes its own (a helper can't read the
* caller's). No-op in production, where Vite substitutes `import.meta.hot` with `undefined`.
*
* Returns a `track` collector — feed it every teardown:
* const track = trackForHmr(import.meta.hot)
* track(store.subscribe(setX)) // an unsubscribe fn
* track(() => sub?.unsubscribe()) // any cleanup closure
* trackEffect(track, () => { ... }) // a module-level reactive effect
*/
export function trackForHmr(hot: ImportMeta["hot"]): (teardown: () => void) => void {
const teardowns: Array<() => void> = []
hot?.dispose(() => { for (const t of teardowns) t() })
return teardown => { teardowns.push(teardown) }
}
/**
* Run a module-level reactive effect inside its own root so it can be disposed on hot reload.
* A bare top-level `createEffect` has no owner, so it both warns and leaks across HMR (the
* stale effect keeps firing against orphaned signals). This owns it and tracks the disposer.
*/
export function trackEffect(track: (teardown: () => void) => void, fn: () => void): void {
track(createRoot(dispose => {
createEffect(fn)
return dispose
}))
}
+7 -1
View File
@@ -1,4 +1,5 @@
import { createSignal } from "solid-js"
import { trackForHmr } from "./hmr"
// Reactive match for Tailwind's `md` breakpoint (≥ 768px). The layout renders both the
// desktop and mobile trees simultaneously (toggled with CSS), so components that behave
@@ -6,6 +7,11 @@ import { createSignal } from "solid-js"
// need this to tell which tree is visible. Module-level singleton, app-lifetime.
const query = window.matchMedia("(min-width: 768px)")
const [isDesktop, setIsDesktop] = createSignal(query.matches)
query.addEventListener("change", e => setIsDesktop(e.matches))
const onChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches)
query.addEventListener("change", onChange)
// Remove the listener on hot reload so an edit doesn't leave a stale handler on the
// app-lifetime MediaQueryList (it would keep firing into the orphaned signal).
trackForHmr(import.meta.hot)(() => query.removeEventListener("change", onChange))
export { isDesktop }
+7 -3
View File
@@ -1,13 +1,17 @@
import { getRelaysFromList } from "@welshman/util"
import { loadMessagingRelayList, getMessagingRelayList, loadProfile, displayProfileByPubkey } from "@welshman/app"
/** Ensure every pubkey has published a kind-10050 messaging relay list with at least one relay. */
/**
* Ensure every pubkey has published a kind-10050 messaging relay list with at least one
* relay. Loader failures are swallowed (resolve to "no list") so this can never throw or
* hang a caller — it's an advisory check, not a gate.
*/
export async function validateMessagingRelays(pubkeys: string[]): Promise<string | null> {
if (!pubkeys.length) return null
await Promise.all(pubkeys.map(pk => loadMessagingRelayList(pk)))
await Promise.all(pubkeys.map(pk => loadMessagingRelayList(pk).catch(() => undefined)))
const missing = pubkeys.filter(pk => getRelaysFromList(getMessagingRelayList(pk)).length === 0)
if (!missing.length) return null
// Prefer the profile name (falling back to a short npub) over a raw pubkey.
await Promise.all(missing.map(pk => loadProfile(pk)))
await Promise.all(missing.map(pk => loadProfile(pk).catch(() => undefined)))
return `No messaging relays found for: ${missing.map(pk => displayProfileByPubkey(pk)).join(", ")}`
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { ago, HOUR } from "@welshman/lib"
import { request } from "@welshman/net"
type Subscription = { unsubscribe(): void }
@@ -17,7 +18,7 @@ export function subscribeInbox(relays: string[], pubkey: string): Subscription {
const ctrl = new AbortController()
request({
relays,
filters: [{ kinds: [1059], "#p": [pubkey] }],
filters: [{ kinds: [1059], "#p": [pubkey], since: ago(48, HOUR) }],
signal: ctrl.signal,
})
return { unsubscribe: () => ctrl.abort() }
+6 -1
View File
@@ -25,7 +25,7 @@ export async function hydrateRepository(): Promise<void> {
/** Flush repository changes (protocol kinds only) to IndexedDB. Call once at startup. */
export function persistRepository(): void {
on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
const unsubscribe = on(repository, "update", batch(3000, async (updates: RepositoryUpdate[]) => {
const db = await dbPromise
const tx = db.transaction(STORE, "readwrite")
for (const { added, removed } of updates) {
@@ -43,4 +43,9 @@ export function persistRepository(): void {
}
await tx.done
}))
// The repository is an app-lifetime singleton that survives HMR, so re-running this module
// on hot reload would stack a second "update" listener and persist every event twice. Drop
// the previous listener before the module is replaced.
import.meta.hot?.dispose(unsubscribe)
}
+20 -12
View File
@@ -1,4 +1,4 @@
import { createSignal, createEffect, createMemo } from "solid-js"
import { createSignal, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { toast } from "solid-toast"
import { getRelaysFromList, getTagValue, getTagValues } from "@welshman/util"
@@ -15,6 +15,7 @@ import {
} from "@welshman/app"
import { subscribeInbox } from "./nostr"
import { assignIndices } from "./protocol"
import { trackForHmr, trackEffect } from "./lib/hmr"
import { quora as completedQuora } from "./engine/secrets"
import type {
QuorumRecord,
@@ -66,22 +67,26 @@ export function setDelivery(inviteId: string, status: "sending" | "saved" | "fai
// completion (7053) carries the resulting quorum pubkey. Because publishThunk writes
// optimistically to the repository, a freshly created quorum appears here instantly
// and survives reloads (storage persists protocol kinds) — even if never delivered.
// Teardown collector for the app-lifetime subscriptions/effects below, run on hot reload so
// they don't stack a duplicate on the surviving repository/session singletons each edit.
const track = trackForHmr(import.meta.hot)
const [me, setMe] = createSignal("")
const [inviteEvents, setInviteEvents] = createSignal<TrustedEvent[]>([])
const [round1Events, setRound1Events] = createSignal<TrustedEvent[]>([])
const [declineEvents, setDeclineEvents] = createSignal<TrustedEvent[]>([])
const [completeEvents, setCompleteEvents] = createSignal<TrustedEvent[]>([])
deriveEvents({ repository, filters: [{ kinds: [7050] }] }).subscribe(setInviteEvents)
deriveEvents({ repository, filters: [{ kinds: [7051] }] }).subscribe(setRound1Events)
deriveEvents({ repository, filters: [{ kinds: [7061] }] }).subscribe(setDeclineEvents)
deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents)
track(deriveEvents({ repository, filters: [{ kinds: [7050] }] }).subscribe(setInviteEvents))
track(deriveEvents({ repository, filters: [{ kinds: [7051] }] }).subscribe(setRound1Events))
track(deriveEvents({ repository, filters: [{ kinds: [7061] }] }).subscribe(setDeclineEvents))
track(deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents))
// Finalized quora (carry our shard) persisted by the engine once a DKG or resharing
// completes. Merged into displayedQuora below so a quorum keeps showing after its
// invite events are pruned, and so quora we joined without holding the invite appear.
const [finishedQuora, setFinishedQuora] = createSignal<QuorumRecord[]>([])
completedQuora.subscribe(setFinishedQuora)
track(completedQuora.subscribe(setFinishedQuora))
function authorsByInviteId(events: TrustedEvent[]): Map<string, Set<string>> {
const m = new Map<string, Set<string>>()
@@ -193,7 +198,7 @@ export const activeQuorum = createMemo<DisplayedQuorum | undefined>(() => {
// Land on the first quorum instead of the empty home view — e.g. once quora hydrate on
// load. Only fires while on "home", so it never overrides explicit navigation to a quorum
// or the account page. When there are no quora, "home" stays (and shows the hero/CTA).
createEffect(() => {
trackEffect(track, () => {
const list = displayedQuora()
if (view() === "home" && list.length > 0) {
openQuorum(list[0].id)
@@ -221,7 +226,7 @@ function restartInbox(): void {
}
}
pubkey.subscribe($pk => {
track(pubkey.subscribe($pk => {
setMe($pk ?? "")
if ($pk === inboxPk) { return }
inboxPk = $pk
@@ -231,18 +236,21 @@ pubkey.subscribe($pk => {
loadRelayList($pk)
}
restartInbox()
})
}))
userMessagingRelayList.subscribe($list => {
track(userMessagingRelayList.subscribe($list => {
const relays = getRelaysFromList($list)
if (JSON.stringify(relays) === JSON.stringify(inboxRelays)) { return }
inboxRelays = relays
restartInbox()
})
}))
// Close the live inbox subscription itself on hot reload (it's restarted, not store-backed).
track(() => { inboxSub?.unsubscribe(); inboxSub = null })
// Eagerly fetch relay lists and profiles for every member of every visible quorum,
// so their names render in the list and detail views.
createEffect(() => {
trackEffect(track, () => {
const list = displayedQuora()
const pubkeys = new Set(list.flatMap(q => q.members.map(m => m.pubkey)))
// Include each established quorum's OWN pubkey so its profile + relay lists load in the