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.
681 lines
23 KiB
681 lines
23 KiB
import { get } from "svelte/store"; |
|
import { nip19 } from "nostr-tools"; |
|
import { unifiedProfileCache } 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, |
|
localRelays, |
|
} 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 = |
|
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>'; |
|
|
|
const graduationCapSvg = |
|
'<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M12.4472 4.10557c-.2815-.14076-.6129-.14076-.8944 0L2.76981 8.49706l9.21949 4.39024L21 8.38195l-8.5528-4.27638Z"/><path d="M5 17.2222v-5.448l6.5701 3.1286c.278.1325.6016.1293.8771-.0084L19 11.618v5.6042c0 .2857-.1229.5583-.3364.7481l-.0025.0022-.0041.0036-.0103.009-.0119.0101-.0181.0152c-.024.02-.0562.0462-.0965.0776-.0807.0627-.1942.1465-.3405.2441-.2926.195-.7171.4455-1.2736.6928C15.7905 19.5208 14.1527 20 12 20c-2.15265 0-3.79045-.4792-4.90614-.9751-.5565-.2473-.98098-.4978-1.27356-.6928-.14631-.0976-.2598-.1814-.34049-.2441-.04036-.0314-.07254-.0576-.09656-.0776-.01201-.01-.02198-.0185-.02991-.0253l-.01038-.009-.00404-.0036-.00174-.0015-.0008-.0007s-.00004 0 .00978-.0112l-.00009-.0012-.01043.0117C5.12215 17.7799 5 17.5079 5 17.2222Zm-3-6.8765 2 .9523V17c0 .5523-.44772 1-1 1s-1-.4477-1-1v-6.6543Z"/></svg>'; |
|
|
|
// 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; |
|
|
|
export interface NostrProfile { |
|
name?: string; |
|
displayName?: string; |
|
nip05?: string; |
|
picture?: string; |
|
about?: string; |
|
banner?: string; |
|
website?: string; |
|
lud16?: string; |
|
} |
|
|
|
/** |
|
* HTML escape a string |
|
*/ |
|
function escapeHtml(text: string): string { |
|
const htmlEscapes: { [key: string]: string } = { |
|
"&": "&", |
|
"<": "<", |
|
">": ">", |
|
'"': """, |
|
"'": "'", |
|
}; |
|
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<NostrProfile> { |
|
// Use the unified profile cache which handles all relay searching and caching |
|
return unifiedProfileCache.getProfile(identifier, ndk, force); |
|
} |
|
|
|
/** |
|
* 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 `<a href="/events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`; |
|
} |
|
|
|
/** |
|
* Create a profile link element with a NIP-05 verification indicator. |
|
*/ |
|
export async function createProfileLinkWithVerification( |
|
identifier: string, |
|
displayText: string | undefined, |
|
ndk?: NDK, |
|
): Promise<string> { |
|
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 `<span class="npub-badge"><a href="/events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`; |
|
case "standard": |
|
return `<span class="npub-badge"><a href="/events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`; |
|
} |
|
} |
|
/** |
|
* 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">${escapedText}</a>`; |
|
} |
|
|
|
/** |
|
* Process Nostr identifiers in text |
|
*/ |
|
export async function processNostrIdentifiers( |
|
content: string, |
|
ndk: NDK, |
|
): 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, 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<string | null> { |
|
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<T>( |
|
thisOrPromise: Promise<T> | number, |
|
timeoutMsOrPromise?: number | Promise<T>, |
|
): Promise<T> { |
|
// Handle method-style call (promise.withTimeout(5000)) |
|
if (typeof thisOrPromise === "number") { |
|
const timeoutMs = thisOrPromise; |
|
const promise = timeoutMsOrPromise as Promise<T>; |
|
return Promise.race([ |
|
promise, |
|
new Promise<T>((_, 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<T>((_, reject) => |
|
setTimeout(() => reject(new Error("Timeout")), timeoutMs) |
|
), |
|
]); |
|
} |
|
|
|
// Add the method to Promise prototype |
|
declare global { |
|
interface Promise<T> { |
|
withTimeout(timeoutMs: number): Promise<T>; |
|
} |
|
} |
|
|
|
Promise.prototype.withTimeout = function <T>( |
|
this: Promise<T>, |
|
timeoutMs: number, |
|
): Promise<T> { |
|
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 = 10000, |
|
): Promise<NDKEvent | null> { |
|
// 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 |
|
// AI-NOTE: 2025-01-24 - Include ALL available relays for comprehensive event discovery |
|
// This ensures we don't miss events that might be on any available relay |
|
allRelays = [ |
|
...secondaryRelays, |
|
...searchRelays, |
|
...anonymousRelays, |
|
...inboxRelays, // Include user's inbox relays |
|
...outboxRelays, // Include user's outbox relays |
|
]; |
|
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<string> { |
|
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}`; |
|
}); |
|
}
|
|
|