Show longform image tags on posts, use shortened npubs for author names, and paginate the homepage meetups section with responsive load more. Co-authored-by: Cursor <cursoragent@cursor.com>
669 lines
22 KiB
TypeScript
669 lines
22 KiB
TypeScript
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
|
|
import { nip19 } from "nostr-tools";
|
|
import { readCache, writeCache } from "./browserCache";
|
|
|
|
// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs:
|
|
// these survive reloads, where the whole point is to avoid re-querying relays.
|
|
// Profiles and immutable events change rarely; addressable (replaceable)
|
|
// long-form events use a shorter window so edits are picked up reasonably soon.
|
|
const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
|
const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable)
|
|
const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable)
|
|
|
|
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
|
|
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
|
|
export function toHexPubkey(pubkey: string | null | undefined): string | null {
|
|
if (!pubkey) return null;
|
|
const trimmed = pubkey.trim();
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
|
|
try {
|
|
const decoded = nip19.decode(trimmed);
|
|
if (decoded.type === "npub") return decoded.data as string;
|
|
if (decoded.type === "nprofile") return (decoded.data as { pubkey: string }).pubkey;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Relays that specialize in (or broadly aggregate) kind:0 profile metadata.
|
|
// Included alongside the site relays so profiles are found even when a user's
|
|
// metadata was never published to the site's configured relay set.
|
|
const PROFILE_METADATA_RELAYS = [
|
|
"wss://purplepag.es",
|
|
"wss://relay.nostr.band",
|
|
];
|
|
|
|
declare global {
|
|
interface Window {
|
|
nostr?: {
|
|
getPublicKey(): Promise<string>;
|
|
signEvent(event: any): Promise<any>;
|
|
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>;
|
|
};
|
|
}
|
|
}
|
|
|
|
export type BunkerSignerInterface = {
|
|
getPublicKey(): Promise<string>;
|
|
signEvent(event: any): Promise<any>;
|
|
close(): Promise<void>;
|
|
};
|
|
|
|
export function hasNostrExtension(): boolean {
|
|
return typeof window !== "undefined" && !!window.nostr;
|
|
}
|
|
|
|
export async function getPublicKey(): Promise<string> {
|
|
if (!window.nostr) throw new Error("No Nostr extension found");
|
|
return window.nostr.getPublicKey();
|
|
}
|
|
|
|
export async function signEvent(event: any): Promise<any> {
|
|
if (!window.nostr) throw new Error("No Nostr extension found");
|
|
return window.nostr.signEvent(event);
|
|
}
|
|
|
|
export function createAuthEvent(pubkey: string, challenge: string) {
|
|
return {
|
|
kind: 22242,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["relay", ""],
|
|
["challenge", challenge],
|
|
],
|
|
content: "",
|
|
pubkey,
|
|
};
|
|
}
|
|
|
|
export function shortenPubkey(pubkey: string): string {
|
|
if (!pubkey) return "";
|
|
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
|
}
|
|
|
|
// Shortened npub for display when no profile is available. Accepts a hex pubkey
|
|
// or an existing npub/nprofile and always renders bech32 (never raw hex).
|
|
export function shortenNpub(pubkeyOrBech32: string): string {
|
|
if (!pubkeyOrBech32) return "";
|
|
try {
|
|
const npub = pubkeyOrBech32.startsWith("npub")
|
|
? pubkeyOrBech32
|
|
: nip19.npubEncode(toHexPubkey(pubkeyOrBech32) || pubkeyOrBech32);
|
|
return shortenPubkey(npub);
|
|
} catch {
|
|
return shortenPubkey(pubkeyOrBech32);
|
|
}
|
|
}
|
|
|
|
export interface NostrProfile {
|
|
name?: string;
|
|
picture?: string;
|
|
about?: string;
|
|
nip05?: string;
|
|
displayName?: string;
|
|
}
|
|
|
|
const FALLBACK_RELAYS = [
|
|
"wss://relay.damus.io",
|
|
"wss://nos.lol",
|
|
"wss://relay.nostr.band",
|
|
];
|
|
|
|
// Only secure WebSocket (wss://) relays may be dialed: the site is served over
|
|
// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed
|
|
// content and must never be connected to. Relay hints reach us from untrusted
|
|
// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter
|
|
// at every connection point.
|
|
export function isSecureRelay(url: unknown): url is string {
|
|
return typeof url === "string" && /^wss:\/\//i.test(url.trim());
|
|
}
|
|
|
|
export function filterSecureRelays(urls: Iterable<string>): string[] {
|
|
const out: string[] = [];
|
|
for (const url of urls) {
|
|
if (isSecureRelay(url)) out.push(url.trim());
|
|
}
|
|
return [...new Set(out)];
|
|
}
|
|
|
|
// How long a single relay query may run before we give up on it. Relays go
|
|
// dead or stall mid-subscription without ever sending EOSE, which is what makes
|
|
// blog loads occasionally hang; bounding every query keeps the page responsive.
|
|
const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait`
|
|
const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup
|
|
|
|
// A single shared read pool for the whole app. Reusing one pool keeps relay
|
|
// websockets warm and—crucially—avoids opening several simultaneous
|
|
// connections to the same relays (author profile + article body + NIP-65 all at
|
|
// once on a blog open), which relays throttle/drop and which caused the
|
|
// intermittent "couldn't fetch" failure on first load. Never closed: the pool
|
|
// manages and reuses its own connections for the SPA's lifetime.
|
|
let _readPool: any = null;
|
|
async function getReadPool(): Promise<any> {
|
|
if (!_readPool) {
|
|
const { SimplePool } = await import("nostr-tools/pool");
|
|
_readPool = new SimplePool();
|
|
}
|
|
return _readPool;
|
|
}
|
|
|
|
// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a
|
|
// hard ceiling around relay queries so a connection that never opens (or never
|
|
// closes) can't block rendering.
|
|
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => resolve(fallback), ms);
|
|
promise.then(
|
|
(v) => { clearTimeout(timer); resolve(v); },
|
|
() => { clearTimeout(timer); resolve(fallback); },
|
|
);
|
|
});
|
|
}
|
|
|
|
// A single bounded query across `relays` (which are dialed in parallel by the
|
|
// pool). Returns null on timeout, empty relay set, or error.
|
|
async function getEventBounded(
|
|
pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise<any> },
|
|
relays: string[],
|
|
filter: any,
|
|
): Promise<any | null> {
|
|
if (relays.length === 0) return null;
|
|
return withTimeout(
|
|
pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }),
|
|
RELAY_HARD_TIMEOUT,
|
|
null,
|
|
);
|
|
}
|
|
|
|
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
|
|
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
export async function getSiteRelays(): Promise<string[]> {
|
|
if (_siteRelaysCache && Date.now() - _siteRelaysCache.fetchedAt < SITE_RELAY_TTL) {
|
|
return _siteRelaysCache.relays;
|
|
}
|
|
try {
|
|
const res = await fetch("/api/relays/public");
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : [];
|
|
if (secure.length > 0) {
|
|
_siteRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
|
return secure;
|
|
}
|
|
} catch {}
|
|
return FALLBACK_RELAYS;
|
|
}
|
|
|
|
export interface Nip65RelayList {
|
|
write: string[];
|
|
read: string[];
|
|
all: string[];
|
|
}
|
|
|
|
const _nip65Cache = new Map<string, { data: Nip65RelayList; fetchedAt: number }>();
|
|
const NIP65_TTL = 10 * 60 * 1000; // 10 minutes
|
|
|
|
export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayList> {
|
|
const cached = _nip65Cache.get(pubkey);
|
|
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
|
|
|
|
const siteRelays = await getSiteRelays();
|
|
const pool = await getReadPool();
|
|
|
|
try {
|
|
const event = await getEventBounded(pool, siteRelays, {
|
|
kinds: [10002],
|
|
authors: [pubkey],
|
|
});
|
|
|
|
const result: Nip65RelayList = { write: [], read: [], all: [] };
|
|
if (!event) {
|
|
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
|
|
return result;
|
|
}
|
|
|
|
for (const tag of event.tags) {
|
|
if (tag[0] !== "r" || !tag[1]) continue;
|
|
const url = tag[1];
|
|
if (!isSecureRelay(url)) continue;
|
|
const marker = tag[2];
|
|
result.all.push(url);
|
|
if (marker === "write") {
|
|
result.write.push(url);
|
|
} else if (marker === "read") {
|
|
result.read.push(url);
|
|
} else {
|
|
result.write.push(url);
|
|
result.read.push(url);
|
|
}
|
|
}
|
|
|
|
_nip65Cache.set(pubkey, { data: result, fetchedAt: Date.now() });
|
|
return result;
|
|
} catch {
|
|
return { write: [], read: [], all: [] };
|
|
}
|
|
}
|
|
|
|
let _userRelaysCache: { relays: { url: string; read: boolean; write: boolean }[]; fetchedAt: number } | null = null;
|
|
const USER_RELAY_TTL = 2 * 60 * 1000; // 2 minutes
|
|
|
|
export async function getUserStoredRelays(): Promise<{ url: string; read: boolean; write: boolean }[]> {
|
|
if (_userRelaysCache && Date.now() - _userRelaysCache.fetchedAt < USER_RELAY_TTL) {
|
|
return _userRelaysCache.relays;
|
|
}
|
|
try {
|
|
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
|
|
if (!token) return [];
|
|
const res = await fetch("/api/user-relays", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
if (Array.isArray(data)) {
|
|
const secure = data.filter((r) => isSecureRelay(r?.url));
|
|
_userRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
|
return secure;
|
|
}
|
|
} catch {}
|
|
return [];
|
|
}
|
|
|
|
export function clearUserRelaysCache(): void {
|
|
_userRelaysCache = null;
|
|
}
|
|
|
|
export async function publishEvent(signedEvent: any): Promise<void> {
|
|
const { SimplePool } = await import("nostr-tools/pool");
|
|
const siteRelays = await getSiteRelays();
|
|
const allRelays = new Set<string>(siteRelays);
|
|
|
|
// Merge user's stored write relays
|
|
try {
|
|
const stored = await getUserStoredRelays();
|
|
stored
|
|
.filter((r) => r.write)
|
|
.forEach((r) => allRelays.add(r.url));
|
|
} catch {}
|
|
|
|
// Merge NIP-65 write relays for the event author
|
|
if (signedEvent.pubkey) {
|
|
try {
|
|
const nip65 = await fetchNip65RelayList(signedEvent.pubkey);
|
|
nip65.write.forEach((url) => allRelays.add(url));
|
|
} catch {}
|
|
}
|
|
|
|
// Merge extension relays
|
|
try {
|
|
if (window.nostr?.getRelays) {
|
|
const ext = await window.nostr.getRelays();
|
|
Object.entries(ext)
|
|
.filter(([, p]) => (p as any).write)
|
|
.forEach(([url]) => allRelays.add(url));
|
|
}
|
|
} catch {}
|
|
|
|
const relayUrls = filterSecureRelays(allRelays);
|
|
const pool = new SimplePool();
|
|
try {
|
|
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
|
} finally {
|
|
pool.close(relayUrls);
|
|
}
|
|
}
|
|
|
|
export async function fetchNostrProfile(
|
|
pubkey: string,
|
|
relayUrls?: string[]
|
|
): Promise<NostrProfile> {
|
|
const hex = toHexPubkey(pubkey);
|
|
if (!hex) return {};
|
|
|
|
const cached = readProfileCache(hex, Date.now());
|
|
if (cached) return cached;
|
|
|
|
const filter = { kinds: [0], authors: [hex] };
|
|
|
|
// Overlap the NIP-65 lookup with the first query rather than waiting on it.
|
|
const nip65Promise = fetchNip65RelayList(hex).catch(
|
|
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
|
);
|
|
|
|
const pool = await getReadPool();
|
|
const tried = new Set<string>();
|
|
try {
|
|
// Phase 1: provided relays (or site relays) + profile aggregators.
|
|
const phase1 = filterSecureRelays([
|
|
...(relayUrls || await getSiteRelays()),
|
|
...PROFILE_METADATA_RELAYS,
|
|
]);
|
|
phase1.forEach((u) => tried.add(u));
|
|
let event = await getEventBounded(pool, phase1, filter);
|
|
|
|
// Phase 2: author's NIP-65 write relays if not found yet.
|
|
if (!event?.content) {
|
|
const nip65 = await nip65Promise;
|
|
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
|
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
|
}
|
|
|
|
if (!event?.content) return {};
|
|
const profile = parseProfileContent(event.content);
|
|
writeProfileCache(hex, profile, Date.now());
|
|
return profile;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function parseProfileContent(content: string): NostrProfile {
|
|
const meta = JSON.parse(content);
|
|
return {
|
|
name: meta.name || meta.display_name,
|
|
displayName: meta.display_name,
|
|
picture: meta.picture,
|
|
about: meta.about,
|
|
nip05: meta.nip05,
|
|
};
|
|
}
|
|
|
|
const _profileCache = new Map<string, { profile: NostrProfile; fetchedAt: number; empty: boolean }>();
|
|
const PROFILE_TTL = 5 * 60 * 1000; // 5 minutes for resolved profiles
|
|
const PROFILE_EMPTY_TTL = 30 * 1000; // retry misses sooner
|
|
|
|
function isProfileEmpty(p: NostrProfile): boolean {
|
|
return !p.name && !p.displayName && !p.picture && !p.about && !p.nip05;
|
|
}
|
|
|
|
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
|
|
const cached = _profileCache.get(pubkey);
|
|
if (cached) {
|
|
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
|
if (now - cached.fetchedAt < ttl) return cached.profile;
|
|
}
|
|
// Fall back to the persistent browser cache (survives reloads/navigation).
|
|
// Only resolved profiles are persisted, so a hit here is always non-empty.
|
|
const persisted = readCache<NostrProfile>(`profile:${pubkey}`, PROFILE_PERSIST_TTL);
|
|
if (persisted) {
|
|
_profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false });
|
|
return persisted;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
|
|
const empty = isProfileEmpty(profile);
|
|
_profileCache.set(pubkey, { profile, fetchedAt: now, empty });
|
|
// Persist resolved profiles only; skip empties so a reload can retry the miss.
|
|
if (!empty) writeCache(`profile:${pubkey}`, profile);
|
|
}
|
|
|
|
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
|
|
// single SimplePool and one query, instead of opening a pool (plus a NIP-65
|
|
// lookup pool) per pubkey. Querying many profiles individually opens dozens of
|
|
// simultaneous websocket connections to the same relays, which get
|
|
// throttled/dropped and return empty results. Pubkeys are normalized to hex
|
|
// (relays reject npub/nprofile in `authors`).
|
|
export async function fetchNostrProfiles(
|
|
pubkeys: string[],
|
|
relayUrls?: string[]
|
|
): Promise<Record<string, NostrProfile>> {
|
|
const result: Record<string, NostrProfile> = {};
|
|
const now = Date.now();
|
|
|
|
// Map each requested key to its hex form. Skip cached and un-decodable keys.
|
|
const hexByKey = new Map<string, string>();
|
|
for (const pk of pubkeys) {
|
|
const cached = readProfileCache(pk, now);
|
|
if (cached) {
|
|
result[pk] = cached;
|
|
continue;
|
|
}
|
|
const hex = toHexPubkey(pk);
|
|
if (!hex) {
|
|
result[pk] = {};
|
|
continue;
|
|
}
|
|
hexByKey.set(pk, hex);
|
|
}
|
|
|
|
if (hexByKey.size === 0) return result;
|
|
|
|
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
|
|
const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]);
|
|
const pool = await getReadPool();
|
|
|
|
try {
|
|
const authors = [...new Set(hexByKey.values())];
|
|
const events = await pool.querySync(
|
|
urls,
|
|
{ kinds: [0], authors },
|
|
{ maxWait: 6000 }
|
|
);
|
|
|
|
// Keep only the most recent kind:0 event per hex author.
|
|
const latest = new Map<string, { created_at: number; content: string }>();
|
|
for (const event of events) {
|
|
const prev = latest.get(event.pubkey);
|
|
if (!prev || event.created_at > prev.created_at) {
|
|
latest.set(event.pubkey, { created_at: event.created_at, content: event.content });
|
|
}
|
|
}
|
|
|
|
for (const [key, hex] of hexByKey) {
|
|
const ev = latest.get(hex);
|
|
let profile: NostrProfile = {};
|
|
if (ev?.content) {
|
|
try {
|
|
profile = parseProfileContent(ev.content);
|
|
} catch {
|
|
profile = {};
|
|
}
|
|
}
|
|
result[key] = profile;
|
|
writeProfileCache(key, profile, now);
|
|
}
|
|
|
|
return result;
|
|
} catch {
|
|
for (const key of hexByKey.keys()) {
|
|
if (!(key in result)) result[key] = {};
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// DataLoader-style batching for single-pubkey requests. Component instances
|
|
// (e.g. <NostrAvatar />) each ask for one pubkey; this coalesces all requests
|
|
// made within a short window into a single batched relay query.
|
|
let _batchQueue = new Set<string>();
|
|
let _batchResolvers = new Map<string, Array<(p: NostrProfile) => void>>();
|
|
let _batchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const BATCH_WINDOW_MS = 60;
|
|
|
|
async function flushProfileBatch(): Promise<void> {
|
|
const pubkeys = [..._batchQueue];
|
|
const resolvers = _batchResolvers;
|
|
_batchQueue = new Set();
|
|
_batchResolvers = new Map();
|
|
_batchTimer = null;
|
|
|
|
let profiles: Record<string, NostrProfile> = {};
|
|
try {
|
|
profiles = await fetchNostrProfiles(pubkeys);
|
|
} catch {
|
|
profiles = {};
|
|
}
|
|
for (const pk of pubkeys) {
|
|
const profile = profiles[pk] ?? {};
|
|
resolvers.get(pk)?.forEach((resolve) => resolve(profile));
|
|
}
|
|
}
|
|
|
|
// Resolve a single pubkey's profile, batching concurrent calls and reusing the
|
|
// shared profile cache.
|
|
export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
|
|
const cached = readProfileCache(pubkey, Date.now());
|
|
if (cached) {
|
|
return Promise.resolve(cached);
|
|
}
|
|
return new Promise((resolve) => {
|
|
const existing = _batchResolvers.get(pubkey);
|
|
if (existing) {
|
|
existing.push(resolve);
|
|
} else {
|
|
_batchResolvers.set(pubkey, [resolve]);
|
|
}
|
|
_batchQueue.add(pubkey);
|
|
if (_batchTimer === null) {
|
|
_batchTimer = setTimeout(() => void flushProfileBatch(), BATCH_WINDOW_MS);
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
|
const cacheKey = `event:${eventId}`;
|
|
const cached = readCache<any>(cacheKey, EVENT_PERSIST_TTL);
|
|
if (cached) return cached;
|
|
|
|
const siteRelays = await getSiteRelays();
|
|
const pool = await getReadPool();
|
|
|
|
try {
|
|
const event = await getEventBounded(pool, siteRelays, { ids: [eventId] });
|
|
if (event) writeCache(cacheKey, event);
|
|
return event || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
|
const cacheKey = `longform:${naddrStr}`;
|
|
const cached = readCache<any>(cacheKey, LONGFORM_PERSIST_TTL);
|
|
if (cached) return cached;
|
|
|
|
const { nip19 } = await import("nostr-tools");
|
|
|
|
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
|
|
try {
|
|
const result = nip19.decode(naddrStr);
|
|
if (result.type !== "naddr") return null;
|
|
decoded = result.data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const siteRelays = await getSiteRelays();
|
|
const naddrRelays = filterSecureRelays(decoded.relays || []);
|
|
const filter = {
|
|
kinds: [decoded.kind],
|
|
authors: [decoded.pubkey],
|
|
"#d": [decoded.identifier],
|
|
};
|
|
|
|
// Kick off the author's NIP-65 lookup immediately so it overlaps the first
|
|
// query instead of adding a serial round-trip before it.
|
|
const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch(
|
|
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
|
);
|
|
|
|
const pool = await getReadPool();
|
|
const tried = new Set<string>();
|
|
// Phase 1: naddr relay hints + site relays, all dialed in parallel. The
|
|
// hints usually point at the author's own relay, so this is the fast path.
|
|
const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]);
|
|
phase1.forEach((u) => tried.add(u));
|
|
let event = await getEventBounded(pool, phase1, filter);
|
|
|
|
// Phase 2: fall back to the author's NIP-65 write relays only if needed.
|
|
if (!event) {
|
|
const nip65 = await nip65Promise;
|
|
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
|
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
|
}
|
|
|
|
if (event) writeCache(cacheKey, event);
|
|
return event || null;
|
|
}
|
|
|
|
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
|
|
// event id to its underlying Nostr event by querying relays. Lets the blog page
|
|
// render a long-form note straight from a shared link even when it was never
|
|
// indexed by the backend. Returns null if it can't be decoded or found.
|
|
export async function resolveEventFromRelays(identifier: string): Promise<any | null> {
|
|
const trimmed = identifier.trim();
|
|
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
return fetchEventFromRelays(trimmed.toLowerCase());
|
|
}
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = nip19.decode(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
switch (decoded.type) {
|
|
case "naddr":
|
|
return fetchLongformFromRelays(trimmed);
|
|
case "nevent":
|
|
return fetchEventFromRelays((decoded.data as { id: string }).id);
|
|
case "note":
|
|
return fetchEventFromRelays(decoded.data as string);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// NIP-46: External signer (bunker:// URI)
|
|
export async function createBunkerSigner(
|
|
input: string
|
|
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
|
|
const { BunkerSigner, parseBunkerInput } = await import("nostr-tools/nip46");
|
|
const bp = await parseBunkerInput(input);
|
|
if (!bp) throw new Error("Invalid bunker URI or NIP-05 identifier");
|
|
const clientSecretKey = generateSecretKey();
|
|
const signer = BunkerSigner.fromBunker(clientSecretKey, bp);
|
|
await signer.connect();
|
|
const pubkey = await signer.getPublicKey();
|
|
return { signer, pubkey };
|
|
}
|
|
|
|
// NIP-46: Generate a nostrconnect:// URI for QR display
|
|
export async function generateNostrConnectSetup(
|
|
relayUrls?: string[]
|
|
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
|
|
if (!relayUrls) relayUrls = (await getSiteRelays()).slice(0, 2);
|
|
const { createNostrConnectURI } = await import("nostr-tools/nip46");
|
|
const clientSecretKey = generateSecretKey();
|
|
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
|
|
const secretBytes = crypto.getRandomValues(new Uint8Array(8));
|
|
const secret = Array.from(secretBytes)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
const uri = createNostrConnectURI({
|
|
clientPubkey,
|
|
relays: relayUrls,
|
|
secret,
|
|
name: "Belgian Bitcoin Embassy",
|
|
});
|
|
return { uri, clientSecretKey };
|
|
}
|
|
|
|
// NIP-46: Wait for a remote signer to connect via nostrconnect:// URI
|
|
export async function waitForNostrConnectSigner(
|
|
clientSecretKey: Uint8Array,
|
|
uri: string,
|
|
signal?: AbortSignal
|
|
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
|
|
const { BunkerSigner } = await import("nostr-tools/nip46");
|
|
const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, signal);
|
|
const pubkey = await signer.getPublicKey();
|
|
return { signer, pubkey };
|
|
}
|