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

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