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.
386 lines
12 KiB
386 lines
12 KiB
/** |
|
* Fetch meme templates from Nostr kind 1063 (NIP-94) with hashtag `memeamigo` only. |
|
* |
|
* Unlike GIFs (where `.gif` in a note is a strong signal), arbitrary JPEG/PNG links in kind 1/1111 are |
|
* usually normal photos, so we do not scrape notes for the meme picker. |
|
* |
|
* @see https://github.com/happylemonprogramming/gifbuddy — nip98.decentralizeGifUrl adds `t` memeamigo for non-GIF URLs. |
|
*/ |
|
|
|
import { ExtendedKind, FAST_READ_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' |
|
import { normalizeUrl } from '@/lib/url' |
|
import type { Event as NEvent } from 'nostr-tools' |
|
import { queryService } from './client.service' |
|
import indexedDb from './indexed-db.service' |
|
|
|
export interface MemeMetadata { |
|
url: string |
|
fallbackUrl?: string |
|
sha256?: string |
|
mimeType?: string |
|
width?: number |
|
height?: number |
|
eventId: string |
|
pubkey: string |
|
createdAt: number |
|
} |
|
|
|
const STATIC_IMAGE_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp']) |
|
|
|
/** Mirrors gif.service `isGif` so note parsing stays parallel (GIF picker vs meme picker). */ |
|
function isGifLike(mimeType: string | undefined, url: string): boolean { |
|
const urlLower = url.toLowerCase() |
|
return ( |
|
mimeType === 'image/gif' || |
|
urlLower.endsWith('.gif') || |
|
urlLower.includes('.gif?') || |
|
urlLower.includes('/gif') || |
|
urlLower.includes('gif') |
|
) |
|
} |
|
|
|
function inferStaticMimeFromUrl(url: string): string { |
|
const lower = url.toLowerCase() |
|
if (lower.includes('.png')) return 'image/png' |
|
if (lower.includes('.webp')) return 'image/webp' |
|
return 'image/jpeg' |
|
} |
|
|
|
function isStaticMemeUrl(mimeType: string | undefined, url: string): boolean { |
|
if (isGifLike(mimeType, url)) return false |
|
if (mimeType && STATIC_IMAGE_MIMES.has(mimeType.toLowerCase())) return true |
|
return /\.(jpe?g|png|webp)(\?|$)/i.test(url) |
|
} |
|
|
|
/** Own templates first, then newest first (picker grid). */ |
|
export function sortMemesForPicker(memes: MemeMetadata[], userPubkey: string | null): MemeMetadata[] { |
|
const u = userPubkey?.toLowerCase() ?? '' |
|
return [...memes].sort((a, b) => { |
|
if (u) { |
|
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0 |
|
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0 |
|
if (aOwn !== bOwn) return bOwn - aOwn |
|
} |
|
return b.createdAt - a.createdAt |
|
}) |
|
} |
|
|
|
function normalizeMemeUrl(url: string): string { |
|
try { |
|
const withoutFragment = url.split('#')[0].trim() |
|
const withoutQuery = withoutFragment.split('?')[0].trim() |
|
const lower = withoutQuery.toLowerCase() |
|
return lower || url |
|
} catch { |
|
return url |
|
} |
|
} |
|
|
|
const MEME_PRIORITY = { |
|
OWN_EVENT: 2, |
|
OTHER_EVENT: 1, |
|
NON_EVENT: 0 |
|
} as const |
|
|
|
function eventHasMemeamigoTag(event: NEvent): boolean { |
|
return event.tags.some((t) => t[0] === 't' && t[1] === 'memeamigo') |
|
} |
|
|
|
function parseDim(tagVal: string | undefined): { width?: number; height?: number } { |
|
if (!tagVal) return {} |
|
const dims = tagVal.split('x') |
|
if (dims.length < 2) return {} |
|
const width = parseInt(dims[0], 10) |
|
const height = parseInt(dims[1], 10) |
|
return { |
|
width: Number.isFinite(width) ? width : undefined, |
|
height: Number.isFinite(height) ? height : undefined |
|
} |
|
} |
|
|
|
/** Pull main file URL + mime from kind 1063 / imeta (same shape as gif.service, without requiring .gif). */ |
|
function parseMemeFileUrlFromEvent(event: NEvent): { |
|
url?: string |
|
mimeType?: string |
|
width?: number |
|
height?: number |
|
} { |
|
let url: string | undefined |
|
let mimeType: string | undefined |
|
let width: number | undefined |
|
let height: number | undefined |
|
|
|
const imetaTags = event.tags.filter((t) => t[0] === 'imeta') |
|
for (const imetaTag of imetaTags) { |
|
const mimeField = imetaTag.find((f) => f?.startsWith('m ')) |
|
const imetaMime = mimeField?.substring(2).trim().toLowerCase() |
|
const isStatic = imetaMime && STATIC_IMAGE_MIMES.has(imetaMime) |
|
for (let i = 1; i < imetaTag.length; i++) { |
|
const field = imetaTag[i] |
|
if (field?.startsWith('url ')) { |
|
const candidateUrl = field.substring(4).trim() |
|
if (!candidateUrl) continue |
|
if (isStatic || /\.(jpe?g|png|webp)(\?|$)/i.test(candidateUrl)) { |
|
url = candidateUrl |
|
if (mimeField) mimeType = imetaMime |
|
const dimField = imetaTag.find((f) => f?.startsWith('dim ')) |
|
const d = parseDim(dimField?.substring(4).trim()) |
|
width = d.width |
|
height = d.height |
|
break |
|
} |
|
} |
|
} |
|
if (url) break |
|
} |
|
|
|
if (!url) { |
|
const fileTags = event.tags.filter((t) => t[0] === 'file' && t[1]) |
|
for (const fileTag of fileTags) { |
|
const candidateUrl = fileTag[1] |
|
const candidateMime = fileTag[2]?.toLowerCase() |
|
if ( |
|
candidateUrl && |
|
candidateMime && |
|
STATIC_IMAGE_MIMES.has(candidateMime) && |
|
candidateMime !== 'image/gif' |
|
) { |
|
url = candidateUrl |
|
mimeType = candidateMime |
|
break |
|
} |
|
} |
|
} |
|
|
|
if (!url) { |
|
const imageTags = event.tags.filter((t) => t[0] === 'image' && t[1]) |
|
for (const imageTag of imageTags) { |
|
const candidateUrl = imageTag[1] |
|
if (candidateUrl && /\.(jpe?g|png|webp)(\?|$)/i.test(candidateUrl)) { |
|
url = candidateUrl |
|
break |
|
} |
|
} |
|
} |
|
|
|
if (!url) { |
|
const urlTag = event.tags.find((t) => t[0] === 'url' && t[1]) |
|
if (urlTag?.[1]) { |
|
url = urlTag[1] |
|
const mTag = event.tags.find((t) => t[0] === 'm' && t[1]) |
|
if (mTag?.[1]) mimeType = mTag[1] |
|
} |
|
} |
|
|
|
if (!url) { |
|
const md = event.content.match( |
|
/!\[[^\]]*\]\((https?:\/\/[^\s<>"')]+\.(?:jpe?g|png|webp)[^\s<>"')]*)\)/i |
|
) |
|
if (md) url = md[1] |
|
else { |
|
const plain = event.content.match(/https?:\/\/[^\s<>"']+\.(?:jpe?g|png|webp)(\?[^\s<>"']*)?/i) |
|
if (plain) url = plain[0] |
|
} |
|
} |
|
|
|
if (!url || !/^https?:\/\//i.test(url)) return {} |
|
|
|
if (!mimeType) { |
|
const mTag = event.tags.find((t) => t[0] === 'm' && t[1]) |
|
mimeType = mTag?.[1] |
|
} |
|
|
|
if (!width || !height) { |
|
const dimTag = event.tags.find((t) => t[0] === 'dim' && t[1]) |
|
const d = parseDim(dimTag?.[1]) |
|
width = width ?? d.width |
|
height = height ?? d.height |
|
} |
|
|
|
return { url, mimeType, width, height } |
|
} |
|
|
|
function parseMemeFrom1063(event: NEvent): MemeMetadata | null { |
|
if (!eventHasMemeamigoTag(event)) return null |
|
|
|
const { url, mimeType: parsedMime, width, height } = parseMemeFileUrlFromEvent(event) |
|
if (!url) return null |
|
|
|
let mimeType = parsedMime?.toLowerCase() |
|
if (!mimeType || mimeType === 'application/octet-stream') { |
|
mimeType = inferStaticMimeFromUrl(url) |
|
} |
|
|
|
if (!isStaticMemeUrl(mimeType, url)) return null |
|
|
|
const sha256Tag = event.tags.find((t) => t[0] === 'x' && t[1]) |
|
const fallbackTag = event.tags.find((t) => t[0] === 'fallback' && t[1]) |
|
|
|
return { |
|
url, |
|
fallbackUrl: fallbackTag?.[1], |
|
sha256: sha256Tag?.[1], |
|
mimeType, |
|
width, |
|
height, |
|
eventId: event.id, |
|
pubkey: event.pubkey, |
|
createdAt: event.created_at |
|
} |
|
} |
|
|
|
export function memeMetadataFrom1063Event(event: NEvent): MemeMetadata | null { |
|
if (event.kind !== ExtendedKind.FILE_METADATA) return null |
|
return parseMemeFrom1063(event) |
|
} |
|
|
|
/** Keep offline / flaky-relay grids usable: cache is valid for days, not minutes. */ |
|
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 |
|
/** Single-item lists (e.g. only your template) still cache and hydrate the picker. */ |
|
const MIN_MEME_CACHE_ENTRIES = 1 |
|
const MEME_CACHE_CAP = 200 |
|
|
|
/** |
|
* Merge new memes into IndexedDB (dedupe by normalized URL, newest wins). Call after publishing 1063. |
|
*/ |
|
export async function mergeMemesIntoIdbCache(incoming: MemeMetadata[]): Promise<void> { |
|
if (incoming.length === 0) return |
|
const row = await indexedDb.getMemeCache() |
|
const byKey = new Map<string, MemeMetadata>() |
|
for (const m of row?.memes ?? []) { |
|
const meta = m as MemeMetadata |
|
if (meta?.url) byKey.set(normalizeMemeUrl(meta.url), meta) |
|
} |
|
for (const m of incoming) { |
|
if (m.url) byKey.set(normalizeMemeUrl(m.url), m) |
|
} |
|
const merged = [...byKey.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, MEME_CACHE_CAP) |
|
await indexedDb.setMemeCache(merged, Date.now()) |
|
} |
|
|
|
const THECITADEL_FOR_FILE_METADATA = |
|
normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' |
|
|
|
export async function fetchMemes( |
|
searchQuery?: string, |
|
limit: number = 50, |
|
forceRefresh: boolean = false, |
|
extraReadRelayUrls: string[] = [], |
|
userPubkey: string | null = null |
|
): Promise<MemeMetadata[]> { |
|
if (!forceRefresh && !searchQuery) { |
|
const cached = await indexedDb.getMemeCache() |
|
if ( |
|
cached && |
|
cached.memes.length >= MIN_MEME_CACHE_ENTRIES && |
|
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS |
|
) { |
|
return sortMemesForPicker(cached.memes as MemeMetadata[], userPubkey).slice(0, limit) |
|
} |
|
} |
|
|
|
let staleFallback: MemeMetadata[] | null = null |
|
if (!searchQuery) { |
|
const row = await indexedDb.getMemeCache() |
|
if (row?.memes?.length) { |
|
staleFallback = row.memes as MemeMetadata[] |
|
} |
|
} |
|
|
|
const readUrls = [ |
|
...GIF_RELAY_URLS, |
|
...FAST_READ_RELAY_URLS, |
|
...extraReadRelayUrls.map((u) => normalizeUrl(u)).filter((u): u is string => !!u) |
|
] |
|
const seen = new Set<string>() |
|
const dedupedUrls = readUrls |
|
.map((u) => normalizeUrl(u) || u) |
|
.filter(Boolean) |
|
.filter((u) => { |
|
const n = u.toLowerCase() |
|
if (seen.has(n)) return false |
|
seen.add(n) |
|
return true |
|
}) |
|
|
|
const fetchOpts = { eoseTimeout: 20000, globalTimeout: 28000 } |
|
const limit1063 = Math.max(limit * 15, 400) |
|
|
|
const relays1063 = dedupedUrls.some( |
|
(u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_FILE_METADATA.toLowerCase() |
|
) |
|
? dedupedUrls |
|
: [...dedupedUrls, THECITADEL_FOR_FILE_METADATA] |
|
|
|
let events: NEvent[] = [] |
|
try { |
|
events = await queryService.fetchEvents( |
|
relays1063, |
|
{ kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 }, |
|
fetchOpts |
|
) |
|
} catch (err) { |
|
if (!searchQuery && staleFallback?.length) { |
|
return sortMemesForPicker(staleFallback as MemeMetadata[], userPubkey).slice(0, limit) |
|
} |
|
throw err |
|
} |
|
const byUrl = new Map<string, { meme: MemeMetadata; priority: number }>() |
|
|
|
for (const event of events) { |
|
const meme = memeMetadataFrom1063Event(event) |
|
if (!meme) continue |
|
|
|
if (searchQuery) { |
|
const q = searchQuery.toLowerCase().trim() |
|
const content = event.content.toLowerCase() |
|
const tags = event.tags.flat().join(' ').toLowerCase() |
|
if (!content.includes(q) && !tags.includes(q)) continue |
|
} |
|
|
|
const key = normalizeMemeUrl(meme.url) |
|
const priority = |
|
userPubkey && event.pubkey === userPubkey ? MEME_PRIORITY.OWN_EVENT : MEME_PRIORITY.OTHER_EVENT |
|
const existing = byUrl.get(key) |
|
if (!existing || priority > existing.priority) { |
|
byUrl.set(key, { meme, priority }) |
|
} |
|
} |
|
|
|
const memes = Array.from(byUrl.values()).map((v) => v.meme) |
|
let result = sortMemesForPicker(memes, userPubkey).slice(0, limit) |
|
|
|
if (!searchQuery && result.length === 0 && staleFallback?.length) { |
|
result = sortMemesForPicker(staleFallback as MemeMetadata[], userPubkey).slice(0, limit) |
|
} |
|
|
|
if (result.length > 0 && !searchQuery) { |
|
await mergeMemesIntoIdbCache(result) |
|
} |
|
|
|
return result |
|
} |
|
|
|
/** |
|
* Return whatever is currently in the IndexedDB meme cache without fetching from relays. |
|
* Used to seed the picker immediately on open; the caller can then trigger a background refresh. |
|
*/ |
|
export async function getCachedMemes(userPubkey: string | null = null): Promise<MemeMetadata[]> { |
|
try { |
|
const cached = await indexedDb.getMemeCache() |
|
if (!cached?.memes?.length) return [] |
|
return sortMemesForPicker(cached.memes as MemeMetadata[], userPubkey).slice(0, 50) |
|
} catch { |
|
return [] |
|
} |
|
} |
|
|
|
export async function searchMemes( |
|
query: string, |
|
limit: number = 50, |
|
forceRefresh: boolean = false, |
|
extraReadRelayUrls: string[] = [], |
|
userPubkey: string | null = null |
|
): Promise<MemeMetadata[]> { |
|
return fetchMemes(query, limit, forceRefresh, extraReadRelayUrls, userPubkey) |
|
}
|
|
|