You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
7.7 KiB
244 lines
7.7 KiB
import { get } from 'svelte/store'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { ndkInstance } from '$lib/ndk'; |
|
import { npubCache } from './npubCache'; |
|
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; |
|
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; |
|
import { standardRelays, bootstrapRelays } from "$lib/consts"; |
|
|
|
// Regular expressions for Nostr identifiers - match the entire identifier including any prefix |
|
export const NOSTR_PROFILE_REGEX = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; |
|
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; |
|
|
|
/** |
|
* HTML escape a string |
|
*/ |
|
function escapeHtml(text: string): string { |
|
const htmlEscapes: { [key: string]: string } = { |
|
'&': '&', |
|
'<': '<', |
|
'>': '>', |
|
'"': '"', |
|
"'": ''' |
|
}; |
|
return text.replace(/[&<>"']/g, char => htmlEscapes[char]); |
|
} |
|
|
|
/** |
|
* Get user metadata for a nostr identifier (npub or nprofile) |
|
*/ |
|
export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string, nip05?: string}> { |
|
// 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 user = ndk.getUser({ pubkey: pubkey }); |
|
if (!user) { |
|
npubCache.set(cleanId, fallback); |
|
return fallback; |
|
} |
|
|
|
try { |
|
const profile = await user.fetchProfile(); |
|
if (!profile) { |
|
npubCache.set(cleanId, fallback); |
|
return fallback; |
|
} |
|
|
|
const metadata = { |
|
name: profile.name || fallback.name, |
|
displayName: profile.displayName, |
|
nip05: profile.nip05 |
|
}; |
|
|
|
npubCache.set(cleanId, metadata); |
|
return metadata; |
|
} catch (e) { |
|
npubCache.set(cleanId, fallback); |
|
return fallback; |
|
} |
|
} catch (e) { |
|
npubCache.set(cleanId, fallback); |
|
return fallback; |
|
} |
|
} |
|
|
|
/** |
|
* Create a profile link element |
|
*/ |
|
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 `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`; |
|
} |
|
|
|
/** |
|
* 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 `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; |
|
} |
|
|
|
/** |
|
* Process Nostr identifiers in text |
|
*/ |
|
export async function processNostrIdentifiers(content: string): Promise<string> { |
|
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<string | null> { |
|
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; |
|
} |
|
} |
|
|
|
/** |
|
* 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<NDKKind>, |
|
timeoutMs: number = 3000 |
|
): Promise<NDKEvent | null> { |
|
const allRelays = Array.from(new Set([...standardRelays, ...bootstrapRelays])); |
|
const relaySets = [ |
|
NDKRelaySet.fromRelayUrls(standardRelays, ndk), |
|
NDKRelaySet.fromRelayUrls(allRelays, ndk) |
|
]; |
|
|
|
async function withTimeout<T>(promise: Promise<T>): Promise<T> { |
|
return Promise.race([ |
|
promise, |
|
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeoutMs)) |
|
]); |
|
} |
|
|
|
try { |
|
let found: NDKEvent | null = null; |
|
|
|
// Try standard relays first |
|
if (typeof filterOrId === 'string' && /^[0-9a-f]{64}$/i.test(filterOrId)) { |
|
found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[0])); |
|
if (!found) { |
|
// Fallback to all relays |
|
found = await withTimeout(ndk.fetchEvent({ ids: [filterOrId] }, undefined, relaySets[1])); |
|
} |
|
} else { |
|
const filter = typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId; |
|
const results = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[0])); |
|
found = results instanceof Set ? Array.from(results)[0] as NDKEvent : null; |
|
if (!found) { |
|
// Fallback to all relays |
|
const fallbackResults = await withTimeout(ndk.fetchEvents(filter, undefined, relaySets[1])); |
|
found = fallbackResults instanceof Set ? Array.from(fallbackResults)[0] as NDKEvent : null; |
|
} |
|
} |
|
|
|
if (!found) { |
|
console.warn('Event not found after timeout. 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; |
|
} |
|
}
|