Unsubscribe on hmr
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ Each protocol flow is initiated by a single event (the invite, resharing proposa
|
|||||||
|
|
||||||
## Event Kinds
|
## 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
|
## Quorum Creation
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { JSX } from "solid-js"
|
||||||
import toast from "solid-toast"
|
import toast from "solid-toast"
|
||||||
import { Pubkey } from "@welshman/util"
|
import { Pubkey } from "@welshman/util"
|
||||||
@@ -15,7 +15,7 @@ import { resharingSessions, acceptResharing, declineResharing } from "../engine/
|
|||||||
import {
|
import {
|
||||||
signingSessions, acceptSign, declineSign, isSigningAborted, publishedSignature,
|
signingSessions, acceptSign, declineSign, isSigningAborted, publishedSignature,
|
||||||
} from "../engine/signing"
|
} from "../engine/signing"
|
||||||
import { signRequestEvents } from "../engine/events"
|
import { signRequestEvents, reshareProposalEvents } from "../engine/events"
|
||||||
import { sessionProgress, getProgress, getQuorumRecord } from "../engine/secrets"
|
import { sessionProgress, getProgress, getQuorumRecord } from "../engine/secrets"
|
||||||
import type { DisplayedQuorum, DkgSession, SigningSession } from "../models"
|
import type { DisplayedQuorum, DkgSession, SigningSession } from "../models"
|
||||||
import type { ResharingSessionView } from "../engine/resharing"
|
import type { ResharingSessionView } from "../engine/resharing"
|
||||||
@@ -46,6 +46,15 @@ const phaseLabels: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
const phaseLabel = (phase: string) => phaseLabels[phase] ?? phase
|
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 {
|
function signKindLabel(kind: number): string {
|
||||||
if (kind === 1) { return "Public note" }
|
if (kind === 1) { return "Public note" }
|
||||||
if (kind === 0) { return "Profile" }
|
if (kind === 0) { return "Profile" }
|
||||||
@@ -78,6 +87,8 @@ type SessionDetail = {
|
|||||||
statusKind: StatusKind
|
statusKind: StatusKind
|
||||||
members: string[]
|
members: string[]
|
||||||
participationLabel: 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 ──
|
// ── Generic session card: title + status badge, optional body, optional action ──
|
||||||
@@ -204,11 +215,20 @@ function ResharingSessionCard(props: { session: ResharingSessionView; onShowDeta
|
|||||||
const me = useActivePubkey()
|
const me = useActivePubkey()
|
||||||
const progress = useReadable(sessionProgress)
|
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 =>
|
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 = () => {
|
const statusLabel = () => {
|
||||||
if (props.session.aborted) { return "Aborted" }
|
if (props.session.aborted) { return "Aborted" }
|
||||||
|
if (declinedOut()) { return "Declined" }
|
||||||
if (props.session.phase === "complete") { return "Complete" }
|
if (props.session.phase === "complete") { return "Complete" }
|
||||||
return `${phaseLabel(props.session.phase)} · ${props.session.round1Count}/${props.session.contributors.length} contributed`
|
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 => {
|
const action = (): ActionDef | undefined => {
|
||||||
progress()
|
progress()
|
||||||
const mine = me()
|
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 }
|
if (!props.session.iAmContributor && !props.session.iAmNewMember) { return undefined }
|
||||||
const p = getProgress(props.session.proposalId)
|
const p = getProgress(props.session.proposalId)
|
||||||
if (p.sentRound1 || p.finalized || p.declined) { return undefined }
|
if (p.sentRound1 || p.finalized || p.declined) { return undefined }
|
||||||
@@ -243,6 +263,10 @@ function ResharingSessionCard(props: { session: ResharingSessionView; onShowDeta
|
|||||||
statusKind: statusKind(),
|
statusKind: statusKind(),
|
||||||
members: [...new Set([...props.session.contributors, ...props.session.newMembers])],
|
members: [...new Set([...props.session.contributors, ...props.session.newMembers])],
|
||||||
participationLabel: "Contributed",
|
participationLabel: "Contributed",
|
||||||
|
membership: {
|
||||||
|
before: Object.keys(props.session.dkgCommitsByPk),
|
||||||
|
after: props.session.newMembers,
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -256,9 +280,12 @@ function SigningSessionCard(props: {
|
|||||||
}) {
|
}) {
|
||||||
const me = useActivePubkey()
|
const me = useActivePubkey()
|
||||||
const progress = useReadable(sessionProgress)
|
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 collected = () => Object.keys(props.session.round1).length
|
||||||
const aborted = () => isSigningAborted(props.session.requestId)
|
const aborted = () => isSigningAborted(props.session.requestId)
|
||||||
|
const declinedOut = () =>
|
||||||
|
isOverDeclined(Object.keys(props.session.declines).length, quorumRecord()?.members.length ?? 0, threshold())
|
||||||
|
|
||||||
const kind = () => {
|
const kind = () => {
|
||||||
try {
|
try {
|
||||||
@@ -269,10 +296,11 @@ function SigningSessionCard(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusKind = (): StatusKind =>
|
const statusKind = (): StatusKind =>
|
||||||
aborted() ? "failed" : props.session.phase === "complete" ? "complete" : "active"
|
aborted() || declinedOut() ? "failed" : props.session.phase === "complete" ? "complete" : "active"
|
||||||
|
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
if (aborted()) { return "Aborted" }
|
if (aborted()) { return "Aborted" }
|
||||||
|
if (declinedOut()) { return "Declined" }
|
||||||
if (props.session.phase === "complete") { return "Signed" }
|
if (props.session.phase === "complete") { return "Signed" }
|
||||||
return `${phaseLabel(props.session.phase)} · ${collected()}/${threshold()} signing`
|
return `${phaseLabel(props.session.phase)} · ${collected()}/${threshold()} signing`
|
||||||
}
|
}
|
||||||
@@ -280,7 +308,7 @@ function SigningSessionCard(props: {
|
|||||||
const action = (): ActionDef | undefined => {
|
const action = (): ActionDef | undefined => {
|
||||||
progress()
|
progress()
|
||||||
const mine = me()
|
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)
|
const p = getProgress(props.session.requestId)
|
||||||
if (p.sentRound1 || p.declined) { return undefined }
|
if (p.sentRound1 || p.declined) { return undefined }
|
||||||
return {
|
return {
|
||||||
@@ -425,6 +453,17 @@ function SessionDetailModal(props: { detail: SessionDetail; onClose: () => void
|
|||||||
|
|
||||||
<span class="text-gray-500 dark:text-neutral-400">Awaiting</span>
|
<span class="text-gray-500 dark:text-neutral-400">Awaiting</span>
|
||||||
<NameList pubkeys={awaiting()} />
|
<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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<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 }) {
|
export default function QuorumSessions(props: { quorum: DisplayedQuorum }) {
|
||||||
const [detail, setDetail] = createSignal<SessionDetail | null>(null)
|
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) }
|
for (const e of signRequestEvents()) { m.set(e.id, e) }
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
|
const proposalById = createMemo(() => {
|
||||||
const dkg = createMemo<DkgSession | undefined>(() =>
|
const m = new Map<string, TrustedEvent>()
|
||||||
dkgSessions().find(s => s.inviteId === props.quorum.inviteId))
|
for (const e of reshareProposalEvents()) { m.set(e.id, e) }
|
||||||
|
return m
|
||||||
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 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
|
const pk = props.quorum.quorumPubkey
|
||||||
if (!pk) { return [] }
|
const list: SessionItem[] = []
|
||||||
return resharingSessions().filter(s => s.quorumPubkey === pk)
|
|
||||||
})
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show
|
<ErrorBoundary
|
||||||
when={!isEmpty()}
|
fallback={err =>
|
||||||
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
|
<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">
|
<Show
|
||||||
<For each={signing()}>
|
when={items().length > 0}
|
||||||
{session => (
|
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No sessions yet.</p>}
|
||||||
<SigningSessionCard
|
>
|
||||||
session={session}
|
<div class="flex flex-col gap-3">
|
||||||
request={requestById().get(session.requestId)}
|
<For each={items()}>
|
||||||
onShowDetail={setDetail}
|
{item => {
|
||||||
/>
|
if (item.kind === "signing") {
|
||||||
)}
|
return <SigningSessionCard session={item.session} request={item.request} onShowDetail={setDetail} />
|
||||||
</For>
|
}
|
||||||
<For each={resharing()}>
|
if (item.kind === "resharing") {
|
||||||
{session => <ResharingSessionCard session={session} onShowDetail={setDetail} />}
|
return <ResharingSessionCard session={item.session} onShowDetail={setDetail} />
|
||||||
</For>
|
}
|
||||||
<Show when={dkg()}>
|
return <DkgSessionCard session={item.session} onShowDetail={setDetail} />
|
||||||
{session => <DkgSessionCard session={session()} onShowDetail={setDetail} />}
|
}}
|
||||||
</Show>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
<Show when={detail()}>
|
<Show when={detail()}>
|
||||||
{d => (
|
{d => (
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { proposeResharing, acceptResharing, declineResharing } from "../../engin
|
|||||||
import { getQuorumRecord } from "../../engine/secrets"
|
import { getQuorumRecord } from "../../engine/secrets"
|
||||||
|
|
||||||
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
|
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 [newThreshold, setNewThreshold] = useAutoThreshold(newMembers)
|
||||||
const [error, setError] = createSignal("")
|
const [error, setError] = createSignal("")
|
||||||
const [submitting, setSubmitting] = createSignal(false)
|
const [submitting, setSubmitting] = createSignal(false)
|
||||||
@@ -40,13 +43,9 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
// Optimistic: a missing messaging relay is surfaced as a warning, but never blocks
|
// Create the proposal FIRST — it's stored locally and delivered best-effort, so it
|
||||||
// the rotation — the proposal is stored locally and delivered best-effort.
|
// must never be gated behind the relay check below (which fetches relay lists for
|
||||||
const relayError = await validateMessagingRelays(members)
|
// brand-new member pubkeys and can hang or reject, preventing the proposal entirely).
|
||||||
if (relayError) {
|
|
||||||
toast(relayError)
|
|
||||||
}
|
|
||||||
|
|
||||||
await proposeResharing({
|
await proposeResharing({
|
||||||
quorumPubkey: props.quorumPubkey,
|
quorumPubkey: props.quorumPubkey,
|
||||||
newMembers: members,
|
newMembers: members,
|
||||||
@@ -59,6 +58,11 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
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 (
|
return (
|
||||||
|
|||||||
+3
-1
@@ -12,6 +12,7 @@ import { deriveEvents } from "@welshman/store"
|
|||||||
import { makeEvent, prep, getRelaysFromList, getTagValue } from "@welshman/util"
|
import { makeEvent, prep, getRelaysFromList, getTagValue } from "@welshman/util"
|
||||||
import type { TrustedEvent, HashedEvent } from "@welshman/util"
|
import type { TrustedEvent, HashedEvent } from "@welshman/util"
|
||||||
import { Router } from "@welshman/router"
|
import { Router } from "@welshman/router"
|
||||||
|
import { trackForHmr } from "../lib/hmr"
|
||||||
|
|
||||||
// ── NIP-17 group chat among quorum members ─────────────────────────────────────
|
// ── NIP-17 group chat among quorum members ─────────────────────────────────────
|
||||||
// Members exchange kind-14 chat messages under their OWN pubkeys, gift-wrapped per
|
// 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 +
|
// One app-lifetime subscription over all kind-14 events (inbound unwrapped rumors +
|
||||||
// our own local copies); chatMessages filters to the requested thread on read.
|
// our own local copies); chatMessages filters to the requested thread on read.
|
||||||
const [allChat, setAllChat] = createSignal<TrustedEvent[]>([])
|
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
|
* Kind-14 messages for a quorum, sorted ascending by created_at. Pass every id the thread
|
||||||
|
|||||||
+10
-4
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { toast } from "solid-toast"
|
import { toast } from "solid-toast"
|
||||||
import { pubkey, signer, loadMessagingRelayList } from "@welshman/app"
|
import { pubkey, signer, loadMessagingRelayList } from "@welshman/app"
|
||||||
import { getTagValue } from "@welshman/util"
|
import { getTagValue } from "@welshman/util"
|
||||||
@@ -41,18 +41,24 @@ import {
|
|||||||
sessionProgress,
|
sessionProgress,
|
||||||
} from "./secrets"
|
} from "./secrets"
|
||||||
import { openQuorum, setDelivery } from "../store"
|
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 ───────────────────────────────────
|
// ── Active pubkey, bridged to a Solid signal ───────────────────────────────────
|
||||||
// Mirrors the store.ts pattern: module-scope signals fed by welshman stores so the
|
// Mirrors the store.ts pattern: module-scope signals fed by welshman stores so the
|
||||||
// derivation re-runs reactively as the session/identity changes.
|
// derivation re-runs reactively as the session/identity changes.
|
||||||
const [me, setMe] = createSignal("")
|
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
|
// 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
|
// 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).
|
// makes dkgSessions() recompute when a flag flips (e.g. after a guarded send).
|
||||||
const [progressTick, setProgressTick] = createSignal(0)
|
const [progressTick, setProgressTick] = createSignal(0)
|
||||||
sessionProgress.subscribe(() => setProgressTick(n => n + 1))
|
track(sessionProgress.subscribe(() => setProgressTick(n => n + 1)))
|
||||||
|
|
||||||
// ── Session-state derivation ───────────────────────────────────────────────────
|
// ── Session-state derivation ───────────────────────────────────────────────────
|
||||||
// A DKG session is a pure function of the 7050 invite, the 7051/7052/7053/7061 events
|
// 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>()
|
const inFlight = new Set<string>()
|
||||||
|
|
||||||
export function startDkg(): void {
|
export function startDkg(): void {
|
||||||
createEffect(() => {
|
trackEffect(track, () => {
|
||||||
for (const s of dkgSessions()) {
|
for (const s of dkgSessions()) {
|
||||||
void advanceDkg(s) // fire-and-forget; each step is guarded
|
void advanceDkg(s) // fire-and-forget; each step is guarded
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import { repository } from "@welshman/app"
|
|||||||
import { getTagValue, getTagValues } from "@welshman/util"
|
import { getTagValue, getTagValues } from "@welshman/util"
|
||||||
import type { TrustedEvent } from "@welshman/util"
|
import type { TrustedEvent } from "@welshman/util"
|
||||||
import type { Hex } from "../protocol"
|
import type { Hex } from "../protocol"
|
||||||
|
import { trackForHmr } from "../lib/hmr"
|
||||||
|
|
||||||
// ── Per-kind repository-derived signals ───────────────────────────────────────
|
// ── Per-kind repository-derived signals ───────────────────────────────────────
|
||||||
// One subscription per protocol kind, alive for the app lifetime — the same
|
// 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
|
// 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).
|
// 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[] {
|
function kindSignal(kind: number): () => TrustedEvent[] {
|
||||||
const [get, set] = createSignal<TrustedEvent[]>([])
|
const [get, set] = createSignal<TrustedEvent[]>([])
|
||||||
deriveEvents({ repository, filters: [{ kinds: [kind] }] }).subscribe(set)
|
track(deriveEvents({ repository, filters: [{ kinds: [kind] }] }).subscribe(set))
|
||||||
return get
|
return get
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getTagValue } from "@welshman/util"
|
|||||||
import type { TrustedEvent } from "@welshman/util"
|
import type { TrustedEvent } from "@welshman/util"
|
||||||
import { PROTOCOL_KINDS } from "../protocol"
|
import { PROTOCOL_KINDS } from "../protocol"
|
||||||
import type { DisplayedQuorum } from "../models"
|
import type { DisplayedQuorum } from "../models"
|
||||||
|
import { trackForHmr } from "../lib/hmr"
|
||||||
|
|
||||||
// ── Notification checkpoints ────────────────────────────────────────────────────
|
// ── Notification checkpoints ────────────────────────────────────────────────────
|
||||||
// A "checkpoint" is the timestamp up to which a quorum's tab has been read. A tab has
|
// 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
|
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())
|
const [state, setState] = createSignal<NotificationState>(read())
|
||||||
notifications.subscribe(setState)
|
track(notifications.subscribe(setState))
|
||||||
|
|
||||||
const [me, setMe] = createSignal("")
|
const [me, setMe] = createSignal("")
|
||||||
pubkey.subscribe(pk => {
|
track(pubkey.subscribe(pk => {
|
||||||
setMe(pk ?? "")
|
setMe(pk ?? "")
|
||||||
// First session ever (e.g. a restored login predating this feature): seed the baseline
|
// 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().
|
// so existing history isn't all flagged as new. Explicit logins reset it via onLogin().
|
||||||
if (pk && read().baseline === 0) {
|
if (pk && read().baseline === 0) {
|
||||||
notifications.set({ ...read(), baseline: now() - WEEK })
|
notifications.set({ ...read(), baseline: now() - WEEK })
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
// Account-scoped so two local accounts in the same quorum don't share read state.
|
// 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}`
|
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 ──────────────────────────────────────────────────────
|
// ── Reactive event streams ──────────────────────────────────────────────────────
|
||||||
const [allProtocol, setAllProtocol] = createSignal<TrustedEvent[]>([])
|
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[]>([])
|
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),
|
// 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).
|
// and once established anything carrying its quorum tag (confirmations, resharing, signing).
|
||||||
|
|||||||
+32
-13
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { toast } from "solid-toast"
|
import { toast } from "solid-toast"
|
||||||
import { pubkey, signer, repository, loadMessagingRelayList } from "@welshman/app"
|
import { pubkey, signer, repository, loadMessagingRelayList } from "@welshman/app"
|
||||||
import { getTagValue } from "@welshman/util"
|
import { getTagValue } from "@welshman/util"
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
} from "./secrets"
|
} from "./secrets"
|
||||||
import { sendProtocolEvent, deliverTo } from "./delivery"
|
import { sendProtocolEvent, deliverTo } from "./delivery"
|
||||||
import { sendChatMessage } from "./chat"
|
import { sendChatMessage } from "./chat"
|
||||||
|
import { trackForHmr, trackEffect } from "../lib/hmr"
|
||||||
|
|
||||||
// ── Local helpers ─────────────────────────────────────────────────────────────
|
// ── Local helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -85,15 +86,20 @@ const [progressMirror, setProgressMirror] = createSignal<Record<string, ReturnTy
|
|||||||
const [quoraMirror, setQuoraMirror] = createSignal<unknown>(undefined)
|
const [quoraMirror, setQuoraMirror] = createSignal<unknown>(undefined)
|
||||||
const [shardsMirror, setShardsMirror] = createSignal<unknown>(undefined)
|
const [shardsMirror, setShardsMirror] = createSignal<unknown>(undefined)
|
||||||
|
|
||||||
sessionProgress.subscribe(v => setProgressMirror(v))
|
// Tear down these app-lifetime subscriptions + the coordinator below on hot reload, so an
|
||||||
quora.subscribe(v => setQuoraMirror(v))
|
// edit doesn't stack a duplicate on the surviving progress/quora/shard/session singletons (a
|
||||||
shardRecords.subscribe(v => setShardsMirror(v))
|
// 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
|
// 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
|
// 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.
|
// pubkey, which left the proposal list empty until some other tracked signal changed.
|
||||||
const [mePubkey, setMePubkey] = createSignal("")
|
const [mePubkey, setMePubkey] = createSignal("")
|
||||||
pubkey.subscribe(pk => setMePubkey(pk ?? ""))
|
track(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
|
// 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
|
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.
|
// Recipients = (current ∪ new) \ {me}. Contributors run round 1; new members verify+finalize.
|
||||||
const recipients = unique([...currentPubkeys, ...opts.newMembers]).filter(pk => pk !== me)
|
const recipients = unique([...currentPubkeys, ...opts.newMembers]).filter(pk => pk !== me)
|
||||||
|
|
||||||
// Best-effort: warm messaging relay lists so delivery can resolve inbox relays.
|
// Best-effort gift-wrapped delivery. Creation already succeeded (stored above), so a
|
||||||
await Promise.all(recipients.map(pk => loadMessagingRelayList(pk).catch(() => {})))
|
// delivery failure only affects whether peers receive it — never local visibility.
|
||||||
|
|
||||||
// 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.
|
|
||||||
try {
|
try {
|
||||||
await sendProtocolEvent(rumor, recipients)
|
await sendProtocolEvent(rumor, recipients, { storeLocal: false })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("propose resharing delivery failed", e)
|
console.error("propose resharing delivery failed", e)
|
||||||
toast.error("Rotation proposed, but delivery to some members failed")
|
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
|
// 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.
|
// session idempotently. We never run crypto here, so accept can't race the coordinator.
|
||||||
acceptedSessions.add(proposalId)
|
acceptedSessions.add(proposalId)
|
||||||
|
setAcceptTick(n => n + 1)
|
||||||
|
|
||||||
if (message && s.quorumPubkey) {
|
if (message && s.quorumPubkey) {
|
||||||
// Reach everyone involved in the rotation (old contributors + new members).
|
// 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
|
// who reloads before finalize simply re-accepts from the inbox. The coordinator never
|
||||||
// reshares a shard or saves a new shard / confirms without consent.
|
// reshares a shard or saves a new shard / confirms without consent.
|
||||||
const acceptedSessions = new Set<string>()
|
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 {
|
function isAccepted(s: ResharingSessionView, progress: ReturnType<typeof getProgress>): boolean {
|
||||||
return acceptedSessions.has(s.proposalId) || progress.sentRound1 === true
|
return acceptedSessions.has(s.proposalId) || progress.sentRound1 === true
|
||||||
@@ -370,8 +388,9 @@ export function startResharing(): void {
|
|||||||
if (started) { return }
|
if (started) { return }
|
||||||
started = true
|
started = true
|
||||||
|
|
||||||
createEffect(() => {
|
trackEffect(track, () => {
|
||||||
const me = pubkey.get()
|
acceptTick() // re-run when a participant accepts (acceptedSessions is not a tracked signal)
|
||||||
|
const me = mePubkey()
|
||||||
if (!me) { return }
|
if (!me) { return }
|
||||||
for (const s of resharingSessions()) {
|
for (const s of resharingSessions()) {
|
||||||
if (s.aborted || s.declined) { continue }
|
if (s.aborted || s.declined) { continue }
|
||||||
|
|||||||
+10
-4
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { toast } from "solid-toast"
|
import { toast } from "solid-toast"
|
||||||
import { hexToBytes } from "@noble/curves-v2/utils.js"
|
import { hexToBytes } from "@noble/curves-v2/utils.js"
|
||||||
import { pubkey, repository, publishThunk, getPubkeyRelays, loadRelayList } from "@welshman/app"
|
import { pubkey, repository, publishThunk, getPubkeyRelays, loadRelayList } from "@welshman/app"
|
||||||
@@ -39,19 +39,25 @@ import {
|
|||||||
groupBySession,
|
groupBySession,
|
||||||
latestByAuthor,
|
latestByAuthor,
|
||||||
} from "./events"
|
} 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
|
// 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,
|
// 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).
|
// which left the coordinator never advancing a request created before login hydrated).
|
||||||
const [me, setMe] = createSignal("")
|
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
|
// 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
|
// (e.g. `finalized` set by aggregation) must explicitly retrigger derivation. Bumping this
|
||||||
// tick on every progress write makes signingSessions() recompute — without it the badge
|
// 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".
|
// stays on "round 2" after the signature is finalized instead of showing "Signed".
|
||||||
const [progressTick, setProgressTick] = createSignal(0)
|
const [progressTick, setProgressTick] = createSignal(0)
|
||||||
sessionProgress.subscribe(() => setProgressTick(n => n + 1))
|
track(sessionProgress.subscribe(() => setProgressTick(n => n + 1)))
|
||||||
|
|
||||||
// ─── Deterministic signing-set rule S ──────────────────────────────────────────
|
// ─── Deterministic signing-set rule S ──────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -572,7 +578,7 @@ function advanceSigning(mine: Hex, s: Snapshot, tasks: Array<() => Promise<void>
|
|||||||
* never causes a duplicate send.
|
* never causes a duplicate send.
|
||||||
*/
|
*/
|
||||||
export function startSigning(): void {
|
export function startSigning(): void {
|
||||||
createEffect(() => {
|
trackEffect(track, () => {
|
||||||
progressTick() // re-advance when a progress flag flips, not only on new events
|
progressTick() // re-advance when a progress flag flips, not only on new events
|
||||||
const mine = me()
|
const mine = me()
|
||||||
if (!mine) { return }
|
if (!mine) { return }
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
|
import { trackForHmr } from "./hmr"
|
||||||
|
|
||||||
// Reactive match for Tailwind's `md` breakpoint (≥ 768px). The layout renders both the
|
// 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
|
// 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.
|
// need this to tell which tree is visible. Module-level singleton, app-lifetime.
|
||||||
const query = window.matchMedia("(min-width: 768px)")
|
const query = window.matchMedia("(min-width: 768px)")
|
||||||
const [isDesktop, setIsDesktop] = createSignal(query.matches)
|
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 }
|
export { isDesktop }
|
||||||
|
|||||||
+7
-3
@@ -1,13 +1,17 @@
|
|||||||
import { getRelaysFromList } from "@welshman/util"
|
import { getRelaysFromList } from "@welshman/util"
|
||||||
import { loadMessagingRelayList, getMessagingRelayList, loadProfile, displayProfileByPubkey } from "@welshman/app"
|
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> {
|
export async function validateMessagingRelays(pubkeys: string[]): Promise<string | null> {
|
||||||
if (!pubkeys.length) return 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)
|
const missing = pubkeys.filter(pk => getRelaysFromList(getMessagingRelayList(pk)).length === 0)
|
||||||
if (!missing.length) return null
|
if (!missing.length) return null
|
||||||
// Prefer the profile name (falling back to a short npub) over a raw pubkey.
|
// 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(", ")}`
|
return `No messaging relays found for: ${missing.map(pk => displayProfileByPubkey(pk)).join(", ")}`
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { ago, HOUR } from "@welshman/lib"
|
||||||
import { request } from "@welshman/net"
|
import { request } from "@welshman/net"
|
||||||
|
|
||||||
type Subscription = { unsubscribe(): void }
|
type Subscription = { unsubscribe(): void }
|
||||||
@@ -17,7 +18,7 @@ export function subscribeInbox(relays: string[], pubkey: string): Subscription {
|
|||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
request({
|
request({
|
||||||
relays,
|
relays,
|
||||||
filters: [{ kinds: [1059], "#p": [pubkey] }],
|
filters: [{ kinds: [1059], "#p": [pubkey], since: ago(48, HOUR) }],
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
})
|
})
|
||||||
return { unsubscribe: () => ctrl.abort() }
|
return { unsubscribe: () => ctrl.abort() }
|
||||||
|
|||||||
+6
-1
@@ -25,7 +25,7 @@ export async function hydrateRepository(): Promise<void> {
|
|||||||
|
|
||||||
/** Flush repository changes (protocol kinds only) to IndexedDB. Call once at startup. */
|
/** Flush repository changes (protocol kinds only) to IndexedDB. Call once at startup. */
|
||||||
export function persistRepository(): void {
|
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 db = await dbPromise
|
||||||
const tx = db.transaction(STORE, "readwrite")
|
const tx = db.transaction(STORE, "readwrite")
|
||||||
for (const { added, removed } of updates) {
|
for (const { added, removed } of updates) {
|
||||||
@@ -43,4 +43,9 @@ export function persistRepository(): void {
|
|||||||
}
|
}
|
||||||
await tx.done
|
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
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, createEffect, createMemo } from "solid-js"
|
import { createSignal, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { toast } from "solid-toast"
|
import { toast } from "solid-toast"
|
||||||
import { getRelaysFromList, getTagValue, getTagValues } from "@welshman/util"
|
import { getRelaysFromList, getTagValue, getTagValues } from "@welshman/util"
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import { subscribeInbox } from "./nostr"
|
import { subscribeInbox } from "./nostr"
|
||||||
import { assignIndices } from "./protocol"
|
import { assignIndices } from "./protocol"
|
||||||
|
import { trackForHmr, trackEffect } from "./lib/hmr"
|
||||||
import { quora as completedQuora } from "./engine/secrets"
|
import { quora as completedQuora } from "./engine/secrets"
|
||||||
import type {
|
import type {
|
||||||
QuorumRecord,
|
QuorumRecord,
|
||||||
@@ -66,22 +67,26 @@ export function setDelivery(inviteId: string, status: "sending" | "saved" | "fai
|
|||||||
// completion (7053) carries the resulting quorum pubkey. Because publishThunk writes
|
// completion (7053) carries the resulting quorum pubkey. Because publishThunk writes
|
||||||
// optimistically to the repository, a freshly created quorum appears here instantly
|
// optimistically to the repository, a freshly created quorum appears here instantly
|
||||||
// and survives reloads (storage persists protocol kinds) — even if never delivered.
|
// 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 [me, setMe] = createSignal("")
|
||||||
const [inviteEvents, setInviteEvents] = createSignal<TrustedEvent[]>([])
|
const [inviteEvents, setInviteEvents] = createSignal<TrustedEvent[]>([])
|
||||||
const [round1Events, setRound1Events] = createSignal<TrustedEvent[]>([])
|
const [round1Events, setRound1Events] = createSignal<TrustedEvent[]>([])
|
||||||
const [declineEvents, setDeclineEvents] = createSignal<TrustedEvent[]>([])
|
const [declineEvents, setDeclineEvents] = createSignal<TrustedEvent[]>([])
|
||||||
const [completeEvents, setCompleteEvents] = createSignal<TrustedEvent[]>([])
|
const [completeEvents, setCompleteEvents] = createSignal<TrustedEvent[]>([])
|
||||||
|
|
||||||
deriveEvents({ repository, filters: [{ kinds: [7050] }] }).subscribe(setInviteEvents)
|
track(deriveEvents({ repository, filters: [{ kinds: [7050] }] }).subscribe(setInviteEvents))
|
||||||
deriveEvents({ repository, filters: [{ kinds: [7051] }] }).subscribe(setRound1Events)
|
track(deriveEvents({ repository, filters: [{ kinds: [7051] }] }).subscribe(setRound1Events))
|
||||||
deriveEvents({ repository, filters: [{ kinds: [7061] }] }).subscribe(setDeclineEvents)
|
track(deriveEvents({ repository, filters: [{ kinds: [7061] }] }).subscribe(setDeclineEvents))
|
||||||
deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents)
|
track(deriveEvents({ repository, filters: [{ kinds: [7053] }] }).subscribe(setCompleteEvents))
|
||||||
|
|
||||||
// Finalized quora (carry our shard) persisted by the engine once a DKG or resharing
|
// 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
|
// 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.
|
// invite events are pruned, and so quora we joined without holding the invite appear.
|
||||||
const [finishedQuora, setFinishedQuora] = createSignal<QuorumRecord[]>([])
|
const [finishedQuora, setFinishedQuora] = createSignal<QuorumRecord[]>([])
|
||||||
completedQuora.subscribe(setFinishedQuora)
|
track(completedQuora.subscribe(setFinishedQuora))
|
||||||
|
|
||||||
function authorsByInviteId(events: TrustedEvent[]): Map<string, Set<string>> {
|
function authorsByInviteId(events: TrustedEvent[]): Map<string, Set<string>> {
|
||||||
const m = new 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
|
// 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
|
// 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).
|
// or the account page. When there are no quora, "home" stays (and shows the hero/CTA).
|
||||||
createEffect(() => {
|
trackEffect(track, () => {
|
||||||
const list = displayedQuora()
|
const list = displayedQuora()
|
||||||
if (view() === "home" && list.length > 0) {
|
if (view() === "home" && list.length > 0) {
|
||||||
openQuorum(list[0].id)
|
openQuorum(list[0].id)
|
||||||
@@ -221,7 +226,7 @@ function restartInbox(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pubkey.subscribe($pk => {
|
track(pubkey.subscribe($pk => {
|
||||||
setMe($pk ?? "")
|
setMe($pk ?? "")
|
||||||
if ($pk === inboxPk) { return }
|
if ($pk === inboxPk) { return }
|
||||||
inboxPk = $pk
|
inboxPk = $pk
|
||||||
@@ -231,18 +236,21 @@ pubkey.subscribe($pk => {
|
|||||||
loadRelayList($pk)
|
loadRelayList($pk)
|
||||||
}
|
}
|
||||||
restartInbox()
|
restartInbox()
|
||||||
})
|
}))
|
||||||
|
|
||||||
userMessagingRelayList.subscribe($list => {
|
track(userMessagingRelayList.subscribe($list => {
|
||||||
const relays = getRelaysFromList($list)
|
const relays = getRelaysFromList($list)
|
||||||
if (JSON.stringify(relays) === JSON.stringify(inboxRelays)) { return }
|
if (JSON.stringify(relays) === JSON.stringify(inboxRelays)) { return }
|
||||||
inboxRelays = relays
|
inboxRelays = relays
|
||||||
restartInbox()
|
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,
|
// Eagerly fetch relay lists and profiles for every member of every visible quorum,
|
||||||
// so their names render in the list and detail views.
|
// so their names render in the list and detail views.
|
||||||
createEffect(() => {
|
trackEffect(track, () => {
|
||||||
const list = displayedQuora()
|
const list = displayedQuora()
|
||||||
const pubkeys = new Set(list.flatMap(q => q.members.map(m => m.pubkey)))
|
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
|
// Include each established quorum's OWN pubkey so its profile + relay lists load in the
|
||||||
|
|||||||
Reference in New Issue
Block a user