import { get } from "svelte/store"; import { nip19 } from "nostr-tools"; import { npubCache } from "./npubCache.ts"; import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk"; import type { NostrEvent } from "@nostr-dev-kit/ndk"; import type { Filter } from "./search_types.ts"; import { anonymousRelays, communityRelays, searchRelays, secondaryRelays, } from "../consts.ts"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; import { sha256 } from "@noble/hashes/sha2.js"; import { schnorr } from "@noble/curves/secp256k1"; import { bytesToHex } from "@noble/hashes/utils"; import { wellKnownUrl } from "./search_utility.ts"; import { VALIDATION } from "./search_constants.ts"; 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]); } /** * Escape regex special characters */ function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Get user metadata for a nostr identifier (npub or nprofile) */ export async function getUserMetadata( identifier: string, ndk: NDK, force = false, ): Promise { // Remove nostr: prefix if present const cleanId = identifier.replace(/^nostr:/, ""); console.log( "getUserMetadata called with identifier:", identifier, "force:", force, ); if (!force && npubCache.has(cleanId)) { const cached = npubCache.get(cleanId)!; console.log("getUserMetadata returning cached profile:", cached); return cached; } const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; try { if (!ndk) { console.warn("getUserMetadata: No NDK instance available"); npubCache.set(cleanId, fallback); return fallback; } const decoded = nip19.decode(cleanId); if (!decoded) { console.warn("getUserMetadata: Failed to decode identifier:", cleanId); 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 { console.warn( "getUserMetadata: Unsupported identifier type:", decoded.type, ); npubCache.set(cleanId, fallback); return fallback; } console.log("getUserMetadata: Fetching profile for pubkey:", pubkey); const profileEvent = await fetchEventWithFallback(ndk, { kinds: [0], authors: [pubkey], }); console.log("getUserMetadata: Profile event found:", profileEvent); const profile = profileEvent && profileEvent.content ? JSON.parse(profileEvent.content) : null; console.log("getUserMetadata: Parsed profile:", profile); const metadata: NostrProfile = { name: profile?.name || fallback.name, displayName: profile?.displayName || profile?.display_name, nip05: profile?.nip05, picture: profile?.picture || profile?.image, about: profile?.about, banner: profile?.banner, website: profile?.website, lud16: profile?.lud16, }; console.log("getUserMetadata: Final metadata:", metadata); npubCache.set(cleanId, metadata); return metadata; } catch (e) { console.error("getUserMetadata: Error fetching profile:", 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); // Remove target="_blank" for internal navigation return `@${escapedText}`; } /** * Create a profile link element with a NIP-05 verification indicator. */ export async function createProfileLinkWithVerification( identifier: string, displayText: string | undefined, ndk: NDK, ): Promise { 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, ); // Filter out problematic relays const filterProblematicRelays = (relays: string[]) => { return relays.filter((relay) => { if (relay.includes("gitcitadel.nostr1.com")) { console.info( `[nostrUtils.ts] Filtering out problematic relay: ${relay}`, ); return false; } return true; }); }; const allRelays = [ ...searchRelays, // Include search relays for profile searches ...communityRelays, ...userRelays, ...secondaryRelays, ].filter((url, idx, arr) => arr.indexOf(url) === idx); const filteredRelays = filterProblematicRelays(allRelays); const relaySet = NDKRelaySetFromNDK.fromRelayUrls(filteredRelays, 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, ndk: NDK, ): 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, ndk); const displayText = metadata.displayName || metadata.name; const link = createProfileLink(identifier, displayText); // Replace all occurrences of this exact match processedContent = processedContent.replace( new RegExp(escapeRegExp(fullMatch), "g"), 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); // Replace all occurrences of this exact match processedContent = processedContent.replace( new RegExp(escapeRegExp(fullMatch), "g"), link, ); } return processedContent; } export async function getNpubFromNip05(nip05: string): Promise { try { // Parse the NIP-05 address const [name, domain] = nip05.split("@"); if (!name || !domain) { console.error("[getNpubFromNip05] Invalid NIP-05 format:", nip05); return null; } // Fetch the well-known.json file with timeout and CORS handling const url = wellKnownUrl(domain, name); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout try { const response = await fetch(url, { signal: controller.signal, mode: "cors", headers: { Accept: "application/json", }, }); clearTimeout(timeoutId); if (!response.ok) { console.error( "[getNpubFromNip05] HTTP error:", response.status, response.statusText, ); return null; } const data = await response.json(); // Try exact match first let pubkey = data.names?.[name]; // If not found, try case-insensitive search if (!pubkey && data.names) { const names = Object.keys(data.names); const matchingName = names.find( (n) => n.toLowerCase() === name.toLowerCase(), ); if (matchingName) { pubkey = data.names[matchingName]; console.log( `[getNpubFromNip05] Found case-insensitive match: ${name} -> ${matchingName}`, ); } } if (!pubkey) { console.error("[getNpubFromNip05] No pubkey found for name:", name); return null; } // Convert pubkey to npub const npub = nip19.npubEncode(pubkey); return npub; } catch (fetchError: unknown) { clearTimeout(timeoutId); if (fetchError instanceof Error && fetchError.name === "AbortError") { console.warn("[getNpubFromNip05] Request timeout for:", url); } else { console.warn("[getNpubFromNip05] CORS or network error for:", url); } return null; } } catch (error) { console.error("[getNpubFromNip05] 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(TIMEOUTS.GENERAL) * 2. Function style: withTimeout(promise, TIMEOUTS.GENERAL) * * @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); }; // TODO: Implement fetch for no-auth relays using the WebSocketPool and raw WebSockets. // This fetch function will be used for server-side loading. /** * 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 | Filter, timeoutMs: number = 3000, ): Promise { // AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive event discovery // This ensures we don't miss events that might be on any available relay // Get all relays from NDK pool first (most comprehensive) const poolRelays = Array.from(ndk.pool.relays.values()).map((r: any) => r.url ); const inboxRelays = get(activeInboxRelays); const outboxRelays = get(activeOutboxRelays); // Combine all available relays, prioritizing pool relays let allRelays = [ ...new Set([...poolRelays, ...inboxRelays, ...outboxRelays]), ]; console.log("fetchEventWithFallback: Using pool relays:", poolRelays); console.log("fetchEventWithFallback: Using inbox relays:", inboxRelays); console.log("fetchEventWithFallback: Using outbox relays:", outboxRelays); console.log("fetchEventWithFallback: Total unique relays:", allRelays.length); // Check if we have any relays available if (allRelays.length === 0) { console.warn( "fetchEventWithFallback: No relays available for event fetch, using fallback relays", ); // Use fallback relays when no relays are available allRelays = [...secondaryRelays, ...searchRelays, ...anonymousRelays]; console.log("fetchEventWithFallback: Using fallback relays:", allRelays); } // Create relay set from all available relays const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk); try { if (relaySet.relays.size === 0) { console.warn( "fetchEventWithFallback: No relays in relay set for event fetch", ); return null; } console.log( "fetchEventWithFallback: Relay set size:", relaySet.relays.size, ); console.log("fetchEventWithFallback: Filter:", filterOrId); console.log( "fetchEventWithFallback: Relay URLs:", Array.from(relaySet.relays).map((r) => r.url), ); let found: NDKEvent | null = null; if ( typeof filterOrId === "string" && new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, "i").test(filterOrId) ) { found = 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); found = results instanceof Set ? (Array.from(results)[0] as NDKEvent) : null; } if (!found) { const timeoutSeconds = timeoutMs / 1000; const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join( ", ", ); console.warn( `fetchEventWithFallback: Event not found after ${timeoutSeconds}s timeout. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`, ); return null; } console.log("fetchEventWithFallback: Found event:", found.id); // Always wrap as NDKEvent return found instanceof NDKEvent ? found : new NDKEvent(ndk, found); } catch (err) { if (err instanceof Error && err.message === "Timeout") { const timeoutSeconds = timeoutMs / 1000; const relayUrls = Array.from(relaySet.relays).map((r) => r.url).join( ", ", ); console.warn( `fetchEventWithFallback: Event fetch timed out after ${timeoutSeconds}s. Tried inbox relays: ${relayUrls}. Some relays may be offline or slow.`, ); } else { console.error( "fetchEventWithFallback: Error in fetchEventWithFallback:", err, ); } return null; } } /** * Converts various Nostr identifiers to npub format. * Handles hex pubkeys, npub strings, and nprofile strings. */ export function toNpub(pubkey: string | undefined): string | null { if (!pubkey) return null; try { // If it's already an npub, return it if (pubkey.startsWith("npub")) return pubkey; // If it's a hex pubkey, convert to npub if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(pubkey)) { return nip19.npubEncode(pubkey); } // If it's an nprofile, decode and extract npub if (pubkey.startsWith("nprofile")) { const decoded = nip19.decode(pubkey); if (decoded.type === "nprofile") { return decoded.data.pubkey ? nip19.npubEncode(decoded.data.pubkey) : null; } } 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: NDKEvent | NostrEvent | undefined, ) { 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); } /** * Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:" * if they are not already prefixed and are not part of a hyperlink */ export function prefixNostrAddresses(content: string): string { // Regex to match Nostr addresses that are not already prefixed with "nostr:" // and are not part of a markdown link or HTML link // Must be followed by at least 20 alphanumeric characters to be considered an address const nostrAddressPattern = /\b(npub|nprofile|nevent|naddr|note)[a-zA-Z0-9]{20,}\b/g; return content.replace(nostrAddressPattern, (match, offset) => { // Check if this match is part of a markdown link [text](url) const beforeMatch = content.substring(0, offset); const afterMatch = content.substring(offset + match.length); // Check if it's part of a markdown link const beforeBrackets = beforeMatch.lastIndexOf("["); const afterParens = afterMatch.indexOf(")"); if (beforeBrackets !== -1 && afterParens !== -1) { const textBeforeBrackets = beforeMatch.substring(0, beforeBrackets); const lastOpenBracket = textBeforeBrackets.lastIndexOf("["); const lastCloseBracket = textBeforeBrackets.lastIndexOf("]"); // If we have [text] before this, it might be a markdown link if (lastOpenBracket !== -1 && lastCloseBracket > lastOpenBracket) { return match; // Don't prefix if it's part of a markdown link } } // Check if it's part of an HTML link const beforeHref = beforeMatch.lastIndexOf("href="); if (beforeHref !== -1) { const afterHref = afterMatch.indexOf('"'); if (afterHref !== -1) { return match; // Don't prefix if it's part of an HTML link } } // Check if it's already prefixed with "nostr:" const beforeNostr = beforeMatch.lastIndexOf("nostr:"); if (beforeNostr !== -1) { const textAfterNostr = beforeMatch.substring(beforeNostr + 6); if (!textAfterNostr.includes(" ")) { return match; // Already prefixed } } // Additional check: ensure it's actually a valid Nostr address format // The part after the prefix should be a valid bech32 string const addressPart = match.substring(4); // Remove npub, nprofile, etc. if (addressPart.length < 20) { return match; // Too short to be a valid address } // Check if it looks like a valid bech32 string (alphanumeric, no special chars) if (!/^[a-zA-Z0-9]+$/.test(addressPart)) { return match; // Not a valid bech32 format } // Additional check: ensure the word before is not a common word that would indicate // this is just a general reference, not an actual address const wordBefore = beforeMatch.match(/\b(\w+)\s*$/); if (wordBefore) { const beforeWord = wordBefore[1].toLowerCase(); const commonWords = [ "the", "a", "an", "this", "that", "my", "your", "his", "her", "their", "our", ]; if (commonWords.includes(beforeWord)) { return match; // Likely just a general reference, not an actual address } } // Prefix with "nostr:" return `nostr:${match}`; }); }