import { TEmoji, TImetaInfo } from '@/types' import { cleanUrl, isImage, isMedia, isBlossomBudBlobUrl } from './url' import { isBlurhashValid } from 'blurhash' import { nip19 } from 'nostr-tools' import { isValidPubkey } from './pubkey' import { normalizeHttpUrl } from './url' export function isSameTag(tag1: string[], tag2: string[]) { if (tag1.length !== tag2.length) return false for (let i = 0; i < tag1.length; i++) { if (tag1[i] !== tag2[i]) return false } return true } export function tagNameEquals(tagName: string) { return (tag: string[]) => tag[0] === tagName } const NOTE_HEX_ID_RE = /^[0-9a-f]{64}$/i /** * Some clients publish non-NIP-94 `m` values, e.g. `gif(694866 bytes)`. * Map common image tokens to a proper MIME type for routing and media extraction. */ function normalizeImetaMimeField(raw: string): string { const s = raw.trim() if (/^(image|video|audio)\//i.test(s)) return s const m = s.match(/^(gif|jpe?g|png|webp|avif|heic|svg)\b/i) if (m) { const k = m[1].toLowerCase() if (k === 'jpeg' || k === 'jpg') return 'image/jpeg' if (k === 'svg') return 'image/svg+xml' if (k === 'gif') return 'image/gif' if (k === 'png') return 'image/png' if (k === 'webp') return 'image/webp' if (k === 'avif') return 'image/avif' if (k === 'heic') return 'image/heic' } return s } /** First hex event id on an `e` / `E` tag (reactions, reposts, replies). */ export function getFirstHexEventIdFromETags(tags: string[][]): string | undefined { for (const t of tags) { if (t[0] !== 'e' && t[0] !== 'E') continue const id = t[1] if (id && NOTE_HEX_ID_RE.test(id)) return id } return undefined } /** * NIP-25 kind-7 target note id: prefer `e`/`E` with marker `reply` (reacted-to note in a thread). * If several `e` tags have no markers, use the last hex id (common order: root, then reply). */ export function getNip25ReactionTargetHexFromTags(tags: string[][]): string | undefined { const eRows: { id: string; marker?: string }[] = [] for (const t of tags) { if (t[0] !== 'e' && t[0] !== 'E') continue const id = t[1] if (!id || !NOTE_HEX_ID_RE.test(id)) continue const marker = typeof t[3] === 'string' ? t[3].toLowerCase() : undefined if (marker === 'reply') return id.toLowerCase() eRows.push({ id: id.toLowerCase(), marker }) } if (eRows.length === 1) return eRows[0].id if (eRows.length > 1) return eRows[eRows.length - 1].id return undefined } export function generateBech32IdFromETag(tag: string[]) { try { const [, id, relay, markerOrPubkey, pubkey] = tag let author: string | undefined if (markerOrPubkey && isValidPubkey(markerOrPubkey)) { author = markerOrPubkey } else if (pubkey && isValidPubkey(pubkey)) { author = pubkey } return nip19.neventEncode({ id, relays: relay ? [relay] : undefined, author }) } catch { return undefined } } export function generateBech32IdFromATag(tag: string[]) { try { const [, coordinate, relay] = tag const [kind, pubkey, identifier] = coordinate.split(':') return nip19.naddrEncode({ kind: Number(kind), pubkey, identifier, relays: relay ? [relay] : undefined }) } catch { return undefined } } export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImetaInfo | null { if (tag[0] !== 'imeta') return null // Handle different imeta tag structures: // Structure 1: ["imeta", "url https://example.com/image.jpg", "alt text", ...] // Structure 2: ["imeta", "url", "https://example.com/image.jpg", "alt", "text", ...] let url: string | undefined // First try the space-separated format const urlItem = tag.find((item) => item.startsWith('url ')) if (urlItem) { url = urlItem.slice(4) } else { // Try the separate element format const urlIndex = tag.findIndex((item) => item === 'url') if (urlIndex !== -1 && urlIndex + 1 < tag.length) { url = tag[urlIndex + 1] } } // Some publishers use a bare https URL as a tag value (e.g. ["imeta", "https://…"]) without `url `. if (!url) { const spaceMime = tag.find((item) => typeof item === 'string' && item.startsWith('m '))?.slice(2) const mIdx = tag.findIndex((item) => item === 'm') const sepMime = mIdx !== -1 && mIdx + 1 < tag.length && typeof tag[mIdx + 1] === 'string' ? tag[mIdx + 1] : undefined const mimeHint = spaceMime || sepMime for (let i = 1; i < tag.length; i++) { const item = tag[i] if (typeof item !== 'string') continue const t = item.trim() if (!/^https?:\/\//i.test(t)) continue if ( isImage(t) || isMedia(t) || isBlossomBudBlobUrl(t) || (mimeHint && (mimeHint.startsWith('image/') || mimeHint.startsWith('video/') || mimeHint.startsWith('audio/'))) ) { url = t break } } } if (!url) return null // Clean the URL to remove tracking parameters const cleanedUrl = cleanUrl(url) const imeta: TImetaInfo = { url: cleanedUrl, pubkey } // Parse blurhash (`blurhash …` NIP-94; some publishers use `bh …` only) const blurHashItem = tag.find((item) => item.startsWith('blurhash ')) const blurHashFromTag = blurHashItem?.slice(9)?.trim() if (blurHashFromTag) { const validRes = isBlurhashValid(blurHashFromTag) if (validRes.result) { imeta.blurHash = blurHashFromTag } } if (!imeta.blurHash) { const bhItem = tag.find((item) => item.startsWith('bh ')) const bh = bhItem?.slice(3)?.trim() if (bh) { const validRes = isBlurhashValid(bh) if (validRes.result) { imeta.blurHash = bh } } } // Parse dimensions const dimItem = tag.find((item) => item.startsWith('dim ')) const dim = dimItem?.slice(4) if (dim) { const [width, height] = dim.split('x').map(Number) if (width && height) { imeta.dim = { width, height } } } // Parse MIME type let mimeType: string | undefined // First try the space-separated format const mItem = tag.find((item) => item.startsWith('m ')) if (mItem) { mimeType = mItem.slice(2) } else { // Try the separate element format const mIndex = tag.findIndex((item) => item === 'm') if (mIndex !== -1 && mIndex + 1 < tag.length) { mimeType = tag[mIndex + 1] } } if (mimeType) { imeta.m = normalizeImetaMimeField(mimeType) } // Parse alt text let altText: string | undefined // First try the space-separated format const altItem = tag.find((item) => item.startsWith('alt ')) if (altItem) { altText = altItem.slice(4) } else { // Try the separate element format const altIndex = tag.findIndex((item) => item === 'alt') if (altIndex !== -1 && altIndex + 1 < tag.length) { altText = tag[altIndex + 1] } } if (altText) { imeta.alt = altText } // Parse SHA256 hash let hash: string | undefined // First try the space-separated format const xItem = tag.find((item) => item.startsWith('x ')) if (xItem) { hash = xItem.slice(2) } else { // Try the separate element format const xIndex = tag.findIndex((item) => item === 'x') if (xIndex !== -1 && xIndex + 1 < tag.length) { hash = tag[xIndex + 1] } } if (hash) { imeta.x = hash } // Parse fallback URLs const fallbackUrls: string[] = [] // First try the space-separated format const fallbackItems = tag.filter((item) => item.startsWith('fallback ')) fallbackItems.forEach((item) => { const url = item.slice(9) if (url) fallbackUrls.push(cleanUrl(url)) }) // Also try the separate element format let fallbackIndex = 0 while (fallbackIndex < tag.length) { const index = tag.findIndex((item, i) => i >= fallbackIndex && item === 'fallback') if (index === -1 || index + 1 >= tag.length) break const url = tag[index + 1] if (url) { const cleanedUrl = cleanUrl(url) if (!fallbackUrls.includes(cleanedUrl)) { fallbackUrls.push(cleanedUrl) } } fallbackIndex = index + 1 } if (fallbackUrls.length > 0) { imeta.fallback = fallbackUrls } // Parse image/poster URL (for videos) let imageUrl: string | undefined // First try the space-separated format const imageItem = tag.find((item) => item.startsWith('image ')) if (imageItem) { imageUrl = imageItem.slice(6) } else { // Try the separate element format const imageIndex = tag.findIndex((item) => item === 'image') if (imageIndex !== -1 && imageIndex + 1 < tag.length) { imageUrl = tag[imageIndex + 1] } } if (imageUrl) { imeta.image = cleanUrl(imageUrl) } // Parse thumbnail URL (for images) let thumbUrl: string | undefined // First try the space-separated format const thumbItem = tag.find((item) => item.startsWith('thumb ')) if (thumbItem) { thumbUrl = thumbItem.slice(6) } else { // Try the separate element format const thumbIndex = tag.findIndex((item) => item === 'thumb') if (thumbIndex !== -1 && thumbIndex + 1 < tag.length) { thumbUrl = tag[thumbIndex + 1] } } if (thumbUrl) { imeta.thumb = cleanUrl(thumbUrl) } // Parse file size (bytes) let fileSize: number | undefined const sizeItem = tag.find((item) => item.startsWith('size ')) if (sizeItem) { fileSize = parseInt(sizeItem.slice(5), 10) } else { const sizeIndex = tag.findIndex((item) => item === 'size') if (sizeIndex !== -1 && sizeIndex + 1 < tag.length) { fileSize = parseInt(tag[sizeIndex + 1], 10) } } if (fileSize && !isNaN(fileSize)) { imeta.size = fileSize } return imeta } export function getPubkeysFromPTags(tags: string[][]) { return Array.from( new Set( tags .filter(tagNameEquals('p')) .map(([, pubkey]) => (pubkey ? pubkey.trim().toLowerCase() : '')) .filter((pubkey) => !!pubkey && isValidPubkey(pubkey)) .reverse() ) ) } export function getEmojiInfosFromEmojiTags(tags: string[][] = []) { return tags .map((tag) => { if (tag.length < 3 || tag[0] !== 'emoji') return null return { shortcode: tag[1], url: tag[2] } }) .filter(Boolean) as TEmoji[] } export function getServersFromServerTags(tags: string[][] = []) { return tags .filter(tagNameEquals('server')) .map(([, url]) => (url ? normalizeHttpUrl(url) : '')) .filter(Boolean) }