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.
 
 
 
 

357 lines
10 KiB

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)
}