import { get } from 'svelte/store'; import { nip19 } from 'nostr-tools'; import { ndkInstance } from '$lib/ndk'; import { npubCache } from './npubCache'; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; import { standardRelays, fallbackRelays, anonymousRelays } from "$lib/consts"; import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk'; import { sha256 } from '@noble/hashes/sha256'; import { schnorr } from '@noble/curves/secp256k1'; import { bytesToHex } from '@noble/hashes/utils'; const badgeCheckSvg = '' const graduationCapSvg = ''; // Regular expressions for Nostr identifiers - match the entire identifier including any prefix export const NOSTR_PROFILE_REGEX = /(?': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, char => htmlEscapes[char]); } /** * Get user metadata for a nostr identifier (npub or nprofile) */ export async function getUserMetadata(identifier: string): Promise { // Remove nostr: prefix if present const cleanId = identifier.replace(/^nostr:/, ''); if (npubCache.has(cleanId)) { return npubCache.get(cleanId)!; } const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; try { const ndk = get(ndkInstance); if (!ndk) { npubCache.set(cleanId, fallback); return fallback; } const decoded = nip19.decode(cleanId); if (!decoded) { npubCache.set(cleanId, fallback); return fallback; } // Handle different identifier types let pubkey: string; if (decoded.type === 'npub') { pubkey = decoded.data; } else if (decoded.type === 'nprofile') { pubkey = decoded.data.pubkey; } else { npubCache.set(cleanId, fallback); return fallback; } const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey] }); const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null; const metadata: NostrProfile = { name: profile?.name || fallback.name, displayName: profile?.displayName || profile?.display_name, nip05: profile?.nip05, picture: profile?.image, about: profile?.about, banner: profile?.banner, website: profile?.website, lud16: profile?.lud16 }; npubCache.set(cleanId, metadata); return metadata; } catch (e) { npubCache.set(cleanId, fallback); return fallback; } } /** * Create a profile link element */ export function createProfileLink(identifier: string, displayText: string | undefined): string { const cleanId = identifier.replace(/^nostr:/, ''); const escapedId = escapeHtml(cleanId); const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const escapedText = escapeHtml(displayText || defaultText); return `@${escapedText}`; } /** * Create a profile link element with a NIP-05 verification indicator. */ export async function createProfileLinkWithVerification(identifier: string, displayText: string | undefined): Promise { const ndk = get(ndkInstance) as NDK; if (!ndk) { return createProfileLink(identifier, displayText); } const cleanId = identifier.replace(/^nostr:/, ''); const escapedId = escapeHtml(cleanId); const isNpub = cleanId.startsWith('npub'); let user: NDKUser; if (isNpub) { user = ndk.getUser({ npub: cleanId }); } else { user = ndk.getUser({ pubkey: cleanId }); } const userRelays = Array.from(ndk.pool?.relays.values() || []).map(r => r.url); const allRelays = [ ...standardRelays, ...userRelays, ...fallbackRelays ].filter((url, idx, arr) => arr.indexOf(url) === idx); const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk); const profileEvent = await ndk.fetchEvent( { kinds: [0], authors: [user.pubkey] }, undefined, relaySet ); const profile = profileEvent?.content ? JSON.parse(profileEvent.content) : null; const nip05 = profile?.nip05; if (!nip05) { return createProfileLink(identifier, displayText); } const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; const escapedText = escapeHtml(displayText || defaultText); const displayIdentifier = profile?.displayName ?? profile?.display_name ?? profile?.name ?? escapedText; const isVerified = await user.validateNip05(nip05); if (!isVerified) { return createProfileLink(identifier, displayText); } // TODO: Make this work with an enum in case we add more types. const type = nip05.endsWith('edu') ? 'edu' : 'standard'; switch (type) { case 'edu': return `@${displayIdentifier}${graduationCapSvg}`; case 'standard': return `@${displayIdentifier}${badgeCheckSvg}`; } } /** * Create a note link element */ function createNoteLink(identifier: string): string { const cleanId = identifier.replace(/^nostr:/, ''); const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; const escapedId = escapeHtml(cleanId); const escapedText = escapeHtml(shortId); return `${escapedText}`; } /** * Process Nostr identifiers in text */ export async function processNostrIdentifiers(content: string): Promise { let processedContent = content; // Helper to check if a match is part of a URL function isPartOfUrl(text: string, index: number): boolean { // Look for http(s):// or www. before the match const before = text.slice(Math.max(0, index - 12), index); return /https?:\/\/$|www\.$/i.test(before); } // Process profiles (npub and nprofile) const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX)); for (const match of profileMatches) { const [fullMatch] = match; const matchIndex = match.index ?? 0; if (isPartOfUrl(content, matchIndex)) { continue; // skip if part of a URL } let identifier = fullMatch; if (!identifier.startsWith('nostr:')) { identifier = 'nostr:' + identifier; } const metadata = await getUserMetadata(identifier); const displayText = metadata.displayName || metadata.name; const link = createProfileLink(identifier, displayText); processedContent = processedContent.replace(fullMatch, link); } // Process notes (nevent, note, naddr) const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX)); for (const match of noteMatches) { const [fullMatch] = match; const matchIndex = match.index ?? 0; if (isPartOfUrl(processedContent, matchIndex)) { continue; // skip if part of a URL } let identifier = fullMatch; if (!identifier.startsWith('nostr:')) { identifier = 'nostr:' + identifier; } const link = createNoteLink(identifier); processedContent = processedContent.replace(fullMatch, link); } return processedContent; } export async function getNpubFromNip05(nip05: string): Promise { try { const ndk = get(ndkInstance); if (!ndk) { console.error('NDK not initialized'); return null; } const user = await ndk.getUser({ nip05 }); if (!user || !user.npub) { return null; } return user.npub; } catch (error) { console.error('Error getting npub from nip05:', error); return null; } } /** * Generic utility function to add a timeout to any promise * Can be used in two ways: * 1. Method style: promise.withTimeout(5000) * 2. Function style: withTimeout(promise, 5000) * * @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style) * @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style) * @returns The promise result if completed before timeout, otherwise throws an error * @throws Error with message 'Timeout' if the promise doesn't resolve within timeoutMs */ export function withTimeout( thisOrPromise: Promise | number, timeoutMsOrPromise?: number | Promise ): Promise { // Handle method-style call (promise.withTimeout(5000)) if (typeof thisOrPromise === 'number') { const timeoutMs = thisOrPromise; const promise = timeoutMsOrPromise as Promise; return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs) ) ]); } // Handle function-style call (withTimeout(promise, 5000)) const promise = thisOrPromise; const timeoutMs = timeoutMsOrPromise as number; return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs) ) ]); } // Add the method to Promise prototype declare global { interface Promise { withTimeout(timeoutMs: number): Promise; } } Promise.prototype.withTimeout = function(this: Promise, timeoutMs: number): Promise { return withTimeout(timeoutMs, this); }; /** * Fetches an event using a two-step relay strategy: * 1. First tries standard relays with timeout * 2. Falls back to all relays if not found * Always wraps result as NDKEvent */ export async function fetchEventWithFallback( ndk: NDK, filterOrId: string | NDKFilter, timeoutMs: number = 3000 ): Promise { // Get user relays if logged in const userRelays = ndk.activeUser ? Array.from(ndk.pool?.relays.values() || []) .filter(r => r.status === 1) // Only use connected relays .map(r => r.url) : []; // Determine which relays to use based on user authentication status const isSignedIn = ndk.signer && ndk.activeUser; const primaryRelays = isSignedIn ? standardRelays : anonymousRelays; // Create three relay sets in priority order const relaySets = [ NDKRelaySetFromNDK.fromRelayUrls(primaryRelays, ndk), // 1. Primary relays (auth or anonymous) NDKRelaySetFromNDK.fromRelayUrls(userRelays, ndk), // 2. User relays (if logged in) NDKRelaySetFromNDK.fromRelayUrls(fallbackRelays, ndk) // 3. fallback relays (last resort) ]; try { let found: NDKEvent | null = null; const triedRelaySets: string[] = []; // Helper function to try fetching from a relay set async function tryFetchFromRelaySet(relaySet: NDKRelaySetFromNDK, setName: string): Promise { if (relaySet.relays.size === 0) return null; triedRelaySets.push(setName); if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { return await ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySet).withTimeout(timeoutMs); } else { const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; const results = await ndk.fetchEvents(filter, undefined, relaySet).withTimeout(timeoutMs); return results instanceof Set ? Array.from(results)[0] as NDKEvent : null; } } // Try each relay set in order for (const [index, relaySet] of relaySets.entries()) { const setName = index === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') : index === 1 ? 'user relays' : 'fallback relays'; found = await tryFetchFromRelaySet(relaySet, setName); if (found) break; } if (!found) { const timeoutSeconds = timeoutMs / 1000; const relayUrls = relaySets.map((set, i) => { const setName = i === 0 ? (isSignedIn ? 'standard relays' : 'anonymous relays') : i === 1 ? 'user relays' : 'fallback relays'; const urls = Array.from(set.relays).map(r => r.url); return urls.length > 0 ? `${setName} (${urls.join(', ')})` : null; }).filter(Boolean).join(', then '); console.warn(`Event not found after ${timeoutSeconds}s timeout. Tried ${relayUrls}. Some relays may be offline or slow.`); return null; } // Always wrap as NDKEvent return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); } catch (err) { console.error('Error in fetchEventWithFallback:', err); return null; } } /** * Converts a hex pubkey to npub, or returns npub if already encoded. */ export function toNpub(pubkey: string | undefined): string | null { if (!pubkey) return null; try { if (/^[a-f0-9]{64}$/i.test(pubkey)) { return nip19.npubEncode(pubkey); } if (pubkey.startsWith('npub1')) return pubkey; return null; } catch { return null; } } export type { NDKEvent, NDKRelaySet, NDKUser }; export { NDKRelaySetFromNDK }; export { nip19 }; export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) { return NDKRelaySetFromNDK.fromRelayUrls(relayUrls, ndk); } export function createNDKEvent(ndk: NDK, rawEvent: any) { return new NDKEvent(ndk, rawEvent); } /** * Returns all tags from the event that match the given tag name. * @param event The NDKEvent object. * @param tagName The tag name to match (e.g., 'a', 'd', 'title'). * @returns An array of matching tags. */ export function getMatchingTags(event: NDKEvent, tagName: string): string[][] { return event.tags.filter((tag: string[]) => tag[0] === tagName); } export function getEventHash(event: { kind: number; created_at: number; tags: string[][]; content: string; pubkey: string; }): string { const serialized = JSON.stringify([ 0, event.pubkey, event.created_at, event.kind, event.tags, event.content ]); return bytesToHex(sha256(serialized)); } export async function signEvent(event: { kind: number; created_at: number; tags: string[][]; content: string; pubkey: string; }): Promise { const id = getEventHash(event); const sig = await schnorr.sign(id, event.pubkey); return bytesToHex(sig); }