Try to make applesauce work

This commit is contained in:
Jon Staab
2026-06-09 10:52:57 -07:00
parent 7897631733
commit 483141f847
14 changed files with 146 additions and 187 deletions
+2
View File
@@ -15,12 +15,14 @@
"@welshman/util": "^0.8.16",
"applesauce-accounts": "^6.0.0",
"applesauce-actions": "^6.1.0",
"applesauce-common": "^6.1.0",
"applesauce-core": "^6.1.0",
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^6.0.3",
"applesauce-signers": "^6.0.1",
"applesauce-solidjs": "^4.0.0",
"idb": "^8.0.3",
"rxjs": "^7.8.2",
"solid-js": "^1.9.13",
"solid-toast": "^0.5.0"
},
+6
View File
@@ -26,6 +26,9 @@ importers:
applesauce-actions:
specifier: ^6.1.0
version: 6.1.0(typescript@6.0.3)
applesauce-common:
specifier: ^6.1.0
version: 6.1.0(typescript@6.0.3)
applesauce-core:
specifier: ^6.1.0
version: 6.1.0(typescript@6.0.3)
@@ -44,6 +47,9 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.3
rxjs:
specifier: ^7.8.2
version: 7.8.2
solid-js:
specifier: ^1.9.13
version: 1.9.13
+3 -8
View File
@@ -8,17 +8,12 @@ import { ProposeQuorum } from "./components/forms/DkgForms"
import { ProposeResharing } from "./components/forms/ResharingForms"
import { ProposeSign } from "./components/forms/SigningForms"
import Avatar from "./components/Avatar"
import { fetchProfile, getDisplayName, useProfileEvent } from "./lib/profiles"
import { getDisplayName } from "./lib/profiles"
import { useActivePubkey, useProfileEvent } from "./hooks"
function SidebarContent(props: { onNew: () => void }) {
const pubkey = () => account()?.pubkey ?? ""
const pubkey = useActivePubkey()
const shortKey = () => `${pubkey().slice(0, 8)}${pubkey().slice(-4)}`
createEffect(() => {
const pk = account()?.pubkey
if (pk) { fetchProfile(pk) }
})
const profileEvent = useProfileEvent(pubkey)
const displayName = () => getDisplayName(profileEvent(), shortKey())
+16 -48
View File
@@ -1,50 +1,27 @@
import { createSignal, createEffect, onCleanup, For, Show } from "solid-js"
import { createSignal, For, Show } from "solid-js"
import { now } from "@welshman/lib"
import { publish, eventStore } from "../nostr"
import { account } from "../store"
import {
parseRelayList,
parseOutboxRelays,
INDEXER_RELAYS,
} from "../lib/relays"
import { useProfileEvent, getDisplayName } from "../lib/profiles"
import { INDEXER_RELAYS } from "../lib/relays"
import { getDisplayName } from "../lib/profiles"
import { useActivePubkey, useProfileEvent, useMessagingRelays, useOutboxRelays } from "../hooks"
import Avatar from "./Avatar"
import type { NostrEvent } from "applesauce-core/helpers/event"
export default function AccountPage() {
const pubkey = () => account()?.pubkey ?? ""
const pubkey = useActivePubkey()
const profileEvent = useProfileEvent(pubkey)
const messagingRelays = useMessagingRelays(pubkey)
const outboxRelays = useOutboxRelays(pubkey)
const displayName = () => getDisplayName(profileEvent(), "")
const [relays, setRelays] = createSignal<string[]>([])
const [outboxEvent, setOutboxEvent] = createSignal<NostrEvent | undefined>()
const [newRelay, setNewRelay] = createSignal("")
const [publishing, setPublishing] = createSignal(false)
const [copied, setCopied] = createSignal(false)
const [addError, setAddError] = createSignal("")
// Sync relay list from kind 10050 in EventStore
createEffect(() => {
const pk = pubkey()
if (!pk) { setRelays([]); return }
const sub = eventStore.replaceable(10050, pk).subscribe(e => {
if (e) setRelays(parseRelayList(e))
})
onCleanup(() => sub.unsubscribe())
})
// Track kind 10002 (outbox relay list) for publishing target
createEffect(() => {
const pk = pubkey()
if (!pk) { setOutboxEvent(undefined); return }
const sub = eventStore.replaceable(10002, pk).subscribe(e => setOutboxEvent(e ?? undefined))
onCleanup(() => sub.unsubscribe())
})
async function publishRelays(updated: string[]): Promise<void> {
const acc = account()
if (!acc) return
setPublishing(true)
try {
const signed = await acc.signEvent({
@@ -53,11 +30,8 @@ export default function AccountPage() {
tags: updated.map(r => ["relay", r]),
created_at: now(),
})
const outbox = outboxEvent()
const targetRelays = outbox ? parseOutboxRelays(outbox) : INDEXER_RELAYS
await publish(targetRelays, signed)
const targets = outboxRelays().length ? outboxRelays() : INDEXER_RELAYS
await publish(targets, signed)
eventStore.add(signed)
} finally {
setPublishing(false)
@@ -67,27 +41,21 @@ export default function AccountPage() {
async function addRelay() {
setAddError("")
const relay = newRelay().trim()
if (!relay) return
if (!relay.startsWith("wss://") && !relay.startsWith("ws://")) {
setAddError("Relay URL must start with wss:// or ws://")
return
}
if (relays().includes(relay)) {
if (messagingRelays().includes(relay)) {
setAddError("Already in your relay list")
return
}
const updated = [...relays(), relay]
setRelays(updated)
setNewRelay("")
await publishRelays(updated)
await publishRelays([...messagingRelays(), relay])
}
async function removeRelay(relay: string) {
const updated = relays().filter(r => r !== relay)
setRelays(updated)
await publishRelays(updated)
await publishRelays(messagingRelays().filter(r => r !== relay))
}
async function copyPubkey() {
@@ -136,18 +104,18 @@ export default function AccountPage() {
{/* Inbox relay list */}
<div class="flex flex-col gap-3">
<h2 class="text-sm font-semibold text-gray-700 dark:text-neutral-300">Inbox Relays (kind 10050)</h2>
<h2 class="text-sm font-semibold text-gray-700 dark:text-neutral-300">Messaging Relays (kind 10050)</h2>
<Show
when={relays().length > 0}
when={messagingRelays().length > 0}
fallback={
<p class="text-sm text-gray-400 dark:text-neutral-500 italic">
No inbox relays published yet.
No messaging relays published yet.
</p>
}
>
<ul class="flex flex-col divide-y divide-gray-100 dark:divide-neutral-700 border border-gray-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<For each={relays()}>
<For each={messagingRelays()}>
{(relay) => (
<li class="flex items-center gap-2 px-3 py-2.5 bg-white dark:bg-neutral-800">
<span class="flex-1 text-sm font-mono text-gray-800 dark:text-neutral-200 truncate">
+2 -1
View File
@@ -1,5 +1,6 @@
import { createSignal, Show, createEffect } from "solid-js"
import { getDisplayName, getProfilePicture, useProfileEvent } from "../lib/profiles"
import { getDisplayName, getProfilePicture } from "../lib/profiles"
import { useProfileEvent } from "../hooks"
export default function Avatar(props: { pubkey: string; size?: string }) {
const [imgError, setImgError] = createSignal(false)
+2 -1
View File
@@ -1,7 +1,8 @@
import { createSignal, createEffect, For, Show, onMount, onCleanup } from "solid-js"
import { normalizeToPubkey } from "applesauce-core/helpers"
import type { NostrEvent } from "applesauce-core/helpers/event"
import { fetchProfile, searchProfiles, getDisplayName, useProfileEvent } from "../lib/profiles"
import { fetchProfile, searchProfiles, getDisplayName } from "../lib/profiles"
import { useProfileEvent } from "../hooks"
import Avatar from "./Avatar"
interface Props {
+6 -10
View File
@@ -1,8 +1,8 @@
import { createSignal, createEffect, Show } from "solid-js"
import { createSignal, Show } from "solid-js"
import type { DkgSession } from "../../models"
import PubkeyInput from "../PubkeyInput"
import { account } from "../../store"
import { validateInboxRelays } from "../../lib/relays"
import { validateMessagingRelays } from "../../lib/relays"
import { useActivePubkey, useAutoThreshold } from "../../hooks"
const inputClass =
"w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -14,13 +14,9 @@ const secondaryButtonClass =
"px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
export function ProposeQuorum(props: { onClose: () => void }) {
const selfPubkey = () => account()?.pubkey ?? ""
const selfPubkey = useActivePubkey()
const [members, setMembers] = createSignal<string[]>(selfPubkey() ? [selfPubkey()] : [])
const [threshold, setThreshold] = createSignal(1)
createEffect(() => {
setThreshold(Math.max(1, Math.floor(members().length * 2 / 3)))
})
const [threshold, setThreshold] = useAutoThreshold(members)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -41,7 +37,7 @@ export function ProposeQuorum(props: { onClose: () => void }) {
setError("")
setSubmitting(true)
const relayError = await validateInboxRelays(pubkeys)
const relayError = await validateMessagingRelays(pubkeys)
setSubmitting(false)
if (relayError) {
+5 -8
View File
@@ -1,15 +1,12 @@
import { createSignal, createEffect, Show } from "solid-js"
import { createSignal, Show } from "solid-js"
import type { ResharingSession } from "../../models"
import PubkeyInput from "../PubkeyInput"
import { validateInboxRelays } from "../../lib/relays"
import { validateMessagingRelays } from "../../lib/relays"
import { useAutoThreshold } from "../../hooks"
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
const [newMembers, setNewMembers] = createSignal<string[]>([])
const [newThreshold, setNewThreshold] = createSignal(0)
createEffect(() => {
setNewThreshold(Math.max(1, Math.floor(newMembers().length * 2 / 3)))
})
const [newThreshold, setNewThreshold] = useAutoThreshold(newMembers)
const [error, setError] = createSignal("")
const [submitting, setSubmitting] = createSignal(false)
@@ -30,7 +27,7 @@ export function ProposeResharing(props: { quorumPubkey: string; onClose: () => v
}
setSubmitting(true)
const relayError = await validateInboxRelays(members)
const relayError = await validateMessagingRelays(members)
setSubmitting(false)
if (relayError) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js"
import type { SigningSession } from "../../models"
import { quora } from "../../store"
import { validateInboxRelays } from "../../lib/relays"
import { validateMessagingRelays } from "../../lib/relays"
export function ProposeSign(props: { quorumPubkey: string; onClose: () => void }) {
const [message, setMessage] = createSignal("")
@@ -19,7 +19,7 @@ export function ProposeSign(props: { quorumPubkey: string; onClose: () => void }
setError("")
setSubmitting(true)
const relayError = await validateInboxRelays(memberPubkeys())
const relayError = await validateMessagingRelays(memberPubkeys())
setSubmitting(false)
if (relayError) {
+40
View File
@@ -0,0 +1,40 @@
import { createSignal, createEffect, onCleanup } from "solid-js"
import type { NostrEvent } from "applesauce-core/helpers/event"
import { account } from "./store"
import { eventStore } from "./nostr"
import { parseRelayList, parseOutboxRelays } from "./lib/relays"
export function useActivePubkey(): () => string {
return () => account()?.pubkey ?? ""
}
export function useAutoThreshold(members: () => string[]): [() => number, (n: number) => void] {
const [threshold, setThreshold] = createSignal(1)
createEffect(() => setThreshold(Math.max(1, Math.floor(members().length * 2 / 3))))
return [threshold, setThreshold]
}
function useReplaceable(kind: number, pubkey: () => string): () => NostrEvent | undefined {
const [event, setEvent] = createSignal<NostrEvent | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) { setEvent(undefined); return }
const sub = eventStore.replaceable(kind, pk).subscribe(e => setEvent(e ?? undefined))
onCleanup(() => sub.unsubscribe())
})
return event
}
export function useProfileEvent(pubkey: () => string): () => NostrEvent | undefined {
return useReplaceable(0, pubkey)
}
export function useMessagingRelays(pubkey: () => string): () => string[] {
const event = useReplaceable(10050, pubkey)
return () => { const e = event(); return e ? parseRelayList(e) : [] }
}
export function useOutboxRelays(pubkey: () => string): () => string[] {
const event = useReplaceable(10002, pubkey)
return () => { const e = event(); return e ? parseOutboxRelays(e) : [] }
}
+3 -16
View File
@@ -1,16 +1,14 @@
import { createSignal, createEffect, onCleanup } from "solid-js"
import { getDisplayName, getProfilePicture } from "applesauce-core/helpers"
import type { NostrEvent } from "applesauce-core/helpers/event"
import { completeOnEose, onlyEvents, storeEvents } from "applesauce-relay"
import { pool, eventStore } from "../nostr"
import { SEARCH_RELAYS, getOutboxRelays } from "./relays"
import { SEARCH_RELAYS, loadOutboxRelays } from "./relays"
export { getDisplayName, getProfilePicture }
export function fetchProfile(pubkey: string): void {
if (!pubkey || eventStore.hasReplaceable(0, pubkey)) return
getOutboxRelays(pubkey).then(relays => {
if (!pubkey) return
loadOutboxRelays(pubkey).then(relays => {
if (!relays.length) return
pool.subscription(relays, { kinds: [0], authors: [pubkey], limit: 1 })
.pipe(storeEvents(eventStore), completeOnEose())
@@ -29,14 +27,3 @@ export function searchProfiles(query: string, onResult: (event: NostrEvent) => v
const timeout = setTimeout(() => sub.unsubscribe(), 4000)
return () => { clearTimeout(timeout); sub.unsubscribe() }
}
export function useProfileEvent(pubkey: () => string): () => NostrEvent | undefined {
const [event, setEvent] = createSignal<NostrEvent | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) { setEvent(undefined); return }
const sub = eventStore.replaceable(0, pk).subscribe(e => setEvent(e ?? undefined))
onCleanup(() => sub.unsubscribe())
})
return event
}
+31 -36
View File
@@ -1,8 +1,11 @@
import { completeOnEose, onlyEvents, storeEvents } from "applesauce-relay"
import { lastValueFrom } from "applesauce-core/observable"
import { timer, takeUntil } from "rxjs"
import { pool, eventStore } from "../nostr"
import type { NostrEvent } from "applesauce-core/helpers/event"
const RELAY_TIMEOUT_MS = 10_000
export const SEARCH_RELAYS: string[] = (import.meta.env.VITE_SEARCH_RELAYS ?? "")
.split(",")
.map((r: string) => r.trim())
@@ -24,32 +27,29 @@ export function parseOutboxRelays(event: NostrEvent): string[] {
.filter(Boolean)
}
export async function getOutboxRelays(pubkey: string): Promise<string[]> {
if (!eventStore.hasReplaceable(10002, pubkey) && INDEXER_RELAYS.length) {
await lastValueFrom(
pool.subscription(INDEXER_RELAYS, { kinds: [10002], authors: [pubkey], limit: 1 })
.pipe(storeEvents(eventStore), completeOnEose()),
).catch(() => {})
}
const event = eventStore.getReplaceable(10002, pubkey)
// Fetch kind 10002 from the indexer for a single pubkey and return its write relays.
export async function loadOutboxRelays(pubkey: string): Promise<string[]> {
if (!INDEXER_RELAYS.length) return []
const event = await lastValueFrom<NostrEvent>(
pool.subscription(INDEXER_RELAYS, { kinds: [10002], authors: [pubkey], limit: 1 })
.pipe(storeEvents(eventStore), completeOnEose(), onlyEvents(), takeUntil(timer(RELAY_TIMEOUT_MS))) as any,
).catch((): NostrEvent | undefined => undefined)
return event ? parseOutboxRelays(event) : []
}
export function fetchRelayLists(pubkeys: string[]): void {
const toFetch = pubkeys.filter(pk => pk && !eventStore.hasReplaceable(10002, pk))
if (!toFetch.length || !INDEXER_RELAYS.length) return
pool.subscription(INDEXER_RELAYS, { kinds: [10002], authors: toFetch })
// Batch-fetch kind 10002 for multiple pubkeys (fire-and-forget).
export function loadRelayLists(pubkeys: string[]): void {
if (!pubkeys.length || !INDEXER_RELAYS.length) return
pool.subscription(INDEXER_RELAYS, { kinds: [10002], authors: pubkeys })
.pipe(storeEvents(eventStore), completeOnEose())
.subscribe()
}
export function fetchInboxLists(pubkeys: string[]): void {
const toFetch = pubkeys.filter(pk => pk && !eventStore.hasReplaceable(10050, pk))
if (!toFetch.length) return
for (const pubkey of toFetch) {
getOutboxRelays(pubkey).then(relays => {
// Batch-fetch kind 10050 for multiple pubkeys via their outbox relays (fire-and-forget).
export function loadMessagingLists(pubkeys: string[]): void {
if (!pubkeys.length) return
for (const pubkey of pubkeys) {
loadOutboxRelays(pubkey).then(relays => {
if (!relays.length) return
pool.subscription(relays, { kinds: [10050], authors: [pubkey], limit: 1 })
.pipe(storeEvents(eventStore), completeOnEose())
@@ -58,29 +58,24 @@ export function fetchInboxLists(pubkeys: string[]): void {
}
}
export async function validateInboxRelays(pubkeys: string[]): Promise<string | null> {
// Validate that all pubkeys have a published kind 10050 with at least one relay.
export async function validateMessagingRelays(pubkeys: string[]): Promise<string | null> {
if (!pubkeys.length) return null
if (!INDEXER_RELAYS.length) {
return "No indexer relays configured — cannot verify member inbox relay lists"
return "No indexer relays configured — cannot verify member messaging relay lists"
}
await Promise.all(pubkeys.map(async pubkey => {
if (eventStore.hasReplaceable(10050, pubkey)) return
const relays = await getOutboxRelays(pubkey)
if (!relays.length) return
await lastValueFrom(
const results = await Promise.all(pubkeys.map(async pubkey => {
const relays = await loadOutboxRelays(pubkey)
if (!relays.length) return false
const event = await lastValueFrom<NostrEvent>(
pool.subscription(relays, { kinds: [10050], authors: [pubkey], limit: 1 })
.pipe(storeEvents(eventStore), completeOnEose()),
).catch(() => {})
.pipe(storeEvents(eventStore), completeOnEose(), onlyEvents(), takeUntil(timer(RELAY_TIMEOUT_MS))) as any,
).catch((): NostrEvent | undefined => undefined)
return !!event && parseRelayList(event).length > 0
}))
const missing = pubkeys.filter(pk => {
const event = eventStore.getReplaceable(10050, pk)
return !event || !parseRelayList(event).length
})
const missing = pubkeys.filter((_, i) => !results[i])
if (!missing.length) return null
return `No inbox relays found for: ${missing.map(pk => pk.slice(0, 8) + "…").join(", ")}`
return `No messaging relays found for: ${missing.map(pk => pk.slice(0, 8) + "…").join(", ")}`
}
+21 -50
View File
@@ -1,37 +1,21 @@
import { RelayPool, onlyEvents } from "applesauce-relay"
import { EventStore } from "applesauce-core"
import type { NostrEvent } from "applesauce-core/helpers/event"
import { unlockGiftWrap } from "applesauce-common/helpers/gift-wrap"
import type { ISigner } from "applesauce-signers"
import { parseJson } from "@welshman/lib"
import { storeEvent, getAllEvents, demoteOldEvents } from "./storage"
import { demoteOldEvents, storeEvent } from "./storage"
export const eventStore = new EventStore()
// Rumors are unsigned inner events — disable sig verification so they store cleanly.
eventStore.verifyEvent = undefined
// Maintain storage health on startup (fire-and-forget)
demoteOldEvents()
// On startup: run demotion pass then load all stored protocol events into memory.
;(async () => {
await demoteOldEvents()
const events = await getAllEvents()
for (const event of events) {
eventStore.add(event)
}
})()
export function addEvent(event: NostrEvent): void {
eventStore.add(event)
storeEvent(event)
}
// Shape of the object returned by Observable.subscribe()
type Subscription = { unsubscribe(): void }
export const pool = new RelayPool()
export const INBOX_RELAYS: string[] = []
export const MESSAGING_RELAYS: string[] = []
// Protocol event kinds produced by protocol.ts (inner rumor kinds)
const PROTOCOL_KIND_MIN = 7050
const PROTOCOL_KIND_MAX = 7061
@@ -44,44 +28,31 @@ export function subscribeInbox(
throw new Error("Signer does not support NIP-44 encryption")
}
const subscription = pool
return pool
.subscription(relays, { kinds: [1059], "#p": [pubkey], "#t": ["b7ed"] })
.pipe(onlyEvents())
.subscribe({
next: async (wrap: NostrEvent) => {
// Gift wraps are properly signed — add to EventStore as-is.
eventStore.add(wrap)
let rumor
try {
const sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content)
const seal = parseJson<NostrEvent>(sealJson)
if (!seal) {
return
}
const rumorJson = await signer.nip44!.decrypt(seal.pubkey, seal.content)
const rumor = parseJson<NostrEvent>(rumorJson)
if (!rumor) {
return
}
// NIP-59: the rumor's claimed author must match the seal's authenticated author.
// The seal is MAC-authenticated; the rumor's pubkey field is untrusted plaintext.
// Without this check a malicious sealer can forge any rumor.pubkey,
// which would let them impersonate a FROST participant.
if (seal.pubkey !== rumor.pubkey) {
return
}
if (rumor.kind < PROTOCOL_KIND_MIN || rumor.kind > PROTOCOL_KIND_MAX) {
return
}
addEvent(rumor as NostrEvent)
// NIP-59 unwrap: decrypts seal → rumor, verifies seal.pubkey === rumor.pubkey.
// Seal and rumor are cached in applesauce's internal gift-wrap memory,
// intentionally isolated from the main EventStore.
rumor = await unlockGiftWrap(wrap, signer)
} catch {
// Silently skip events that fail to decrypt or parse
return
}
if (rumor.kind < PROTOCOL_KIND_MIN || rumor.kind > PROTOCOL_KIND_MAX) return
// Persist the rumor for log display and session rebuilding.
// Rumors are unsigned (no sig); storage serialises to JSON and never verifies.
storeEvent(rumor as unknown as NostrEvent)
},
})
return subscription
}
export async function publish(relays: string[], event: NostrEvent): Promise<void> {
+7 -7
View File
@@ -5,9 +5,9 @@ import { registerCommonAccountTypes } from "applesauce-accounts/accounts/common"
import type { IAccount } from "applesauce-accounts"
import { parseJson } from "@welshman/lib"
import { toast } from "solid-toast"
import { subscribeInbox, INBOX_RELAYS } from "./nostr"
import { subscribeInbox, MESSAGING_RELAYS } from "./nostr"
import type { QuorumRecord, DkgSession, ResharingSession, SigningSession } from "./models"
import { fetchRelayLists, fetchInboxLists } from "./lib/relays"
import { loadRelayLists, loadMessagingLists } from "./lib/relays"
import { fetchProfile } from "./lib/profiles"
// Shape of the object returned by Observable.subscribe()
@@ -98,7 +98,7 @@ createEffect(() => {
}
try {
inboxSub = subscribeInbox(INBOX_RELAYS, acc.pubkey, acc)
inboxSub = subscribeInbox(MESSAGING_RELAYS, acc.pubkey, acc)
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to subscribe to inbox")
}
@@ -108,8 +108,8 @@ createEffect(() => {
createEffect(() => {
const pk = account()?.pubkey
if (pk) {
fetchRelayLists([pk])
fetchInboxLists([pk])
loadRelayLists([pk])
loadMessagingLists([pk])
fetchProfile(pk)
}
})
@@ -118,8 +118,8 @@ createEffect(() => {
createEffect(() => {
const pubkeys = quora.flatMap(q => q.members.map(m => m.pubkey))
if (pubkeys.length > 0) {
fetchRelayLists(pubkeys)
fetchInboxLists(pubkeys)
loadRelayLists(pubkeys)
loadMessagingLists(pubkeys)
for (const pk of pubkeys) { fetchProfile(pk) }
}
})