Browse Source

add meme picker

imwald
Silberengel 1 month ago
parent
commit
588424953d
  1. 236
      src/components/GifPicker/index.tsx
  2. 430
      src/components/MemePicker/index.tsx
  3. 31
      src/components/PostEditor/PostContent.tsx
  4. 23
      src/i18n/locales/en.ts
  5. 11
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  6. 28
      src/services/gif.service.ts
  7. 92
      src/services/indexed-db.service.ts
  8. 319
      src/services/meme.service.ts

236
src/components/GifPicker/index.tsx

@ -11,12 +11,19 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -11,12 +11,19 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants'
import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url'
import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service'
import {
fetchGifs,
searchGifs,
gifShouldOfferNip94Archive,
type GifMetadata
} from '@/services/gif.service'
import mediaUpload from '@/services/media-upload.service'
import { ExternalLink, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Download, ExternalLink, X } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const GIFBUDDY_URL = 'https://www.gifbuddy.lol/'
@ -47,6 +54,7 @@ export default function GifPicker({ @@ -47,6 +54,7 @@ export default function GifPicker({
const [uploadError, setUploadError] = useState<string | null>(null)
const [pasteUrl, setPasteUrl] = useState('')
const [publishingPaste, setPublishingPaste] = useState(false)
const [archivingEventId, setArchivingEventId] = useState<string | null>(null)
const [publishDescription, setPublishDescription] = useState('')
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
@ -55,6 +63,35 @@ export default function GifPicker({ @@ -55,6 +63,35 @@ export default function GifPicker({
const userReadRelays = relayList?.read ?? []
const userWriteRelays = relayList?.write ?? []
/** Paste / upload: GIF discovery relays + user writes (unchanged). */
const gifPublishRelayUrls = useMemo(() => {
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
return writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Grid pick / archive: user write relays first, then fast write relays as fallback. */
const gifSelectPublishRelayUrls = useMemo(() => {
const primary =
userWriteRelays.length > 0 ? userWriteRelays : [...FAST_WRITE_RELAY_URLS]
const extra = userWriteRelays.length > 0 ? FAST_WRITE_RELAY_URLS : []
const seen = new Set<string>()
return [...primary, ...extra]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.filter((u) => {
const n = u.toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
const loadGifs = useCallback(async (q: string, forceRefresh = false) => {
setError(null)
setLoading(true)
@ -94,11 +131,30 @@ export default function GifPicker({ @@ -94,11 +131,30 @@ export default function GifPicker({
}
}, [searchInput, open])
const handleSelect = (gif: GifMetadata) => {
const url = gif.fallbackUrl || gif.url
onSelect?.(url)
setOpen(false)
}
const handleSelect = useCallback(
(gif: GifMetadata) => {
const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url) return
onSelect?.(url)
setOpen(false)
if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish(
{
kind: ExtendedKind.FILE_METADATA,
content: '',
tags: [
['url', url],
['m', 'image/gif'],
['t', 'gif']
],
created_at: Math.floor(Date.now() / 1000)
},
{ specifiedRelayUrls: gifSelectPublishRelayUrls }
).catch(() => {})
},
[pubkey, onSelect, publish, gifSelectPublishRelayUrls]
)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@ -122,15 +178,7 @@ export default function GifPicker({ @@ -122,15 +178,7 @@ export default function GifPicker({
],
created_at: Math.floor(Date.now() / 1000)
}
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
const specifiedRelayUrls = writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
await publish(draft, { specifiedRelayUrls })
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls })
setPublishDescription('')
setQuery('')
await loadGifs('', true)
@ -195,15 +243,7 @@ export default function GifPicker({ @@ -195,15 +243,7 @@ export default function GifPicker({
],
created_at: Math.floor(Date.now() / 1000)
}
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
const specifiedRelayUrls = writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
await publish(draft, { specifiedRelayUrls })
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls })
setPublishDescription('')
} catch {
// ignore; URL was still inserted
@ -211,7 +251,67 @@ export default function GifPicker({ @@ -211,7 +251,67 @@ export default function GifPicker({
setPublishingPaste(false)
}
}
}, [pasteUrl, pubkey, onSelect, publish, userWriteRelays, descriptionForPublish])
}, [pasteUrl, pubkey, onSelect, publish, gifPublishRelayUrls, descriptionForPublish])
/** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */
const handleArchiveAndInsert = useCallback(
(e: React.MouseEvent, gif: GifMetadata) => {
e.preventDefault()
e.stopPropagation()
if (!pubkey) return
const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url || !/^https?:\/\//i.test(url)) return
setArchivingEventId(gif.eventId)
onSelect?.(url)
setOpen(false)
void loadGifs(query, true)
void publish(
{
kind: ExtendedKind.FILE_METADATA,
content: '',
tags: [
['url', url],
['m', 'image/gif'],
['t', 'gif']
],
created_at: Math.floor(Date.now() / 1000)
},
{ specifiedRelayUrls: gifSelectPublishRelayUrls }
)
.catch(() => {})
.finally(() => setArchivingEventId(null))
},
[pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, query]
)
const gifSourceKindTitle = useCallback(
(gif: GifMetadata) => {
if (gif.sourceKind === ExtendedKind.FILE_METADATA) {
return t(
'This GIF comes from kind 1063 (NIP-94 file metadata). Choosing it still publishes your own kind 1063 to your write relays (and fast write relays as fallback) so your relays index the URL.'
)
}
if (gif.sourceKind === kinds.ShortTextNote) {
return t(
'This GIF was found in a kind 1 note. Notes are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.'
)
}
if (gif.sourceKind === ExtendedKind.COMMENT) {
return t(
'This GIF was found in a kind 1111 comment. Comments are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.'
)
}
return t('This GIF was found in a Nostr event of kind {{kind}}.', { kind: gif.sourceKind })
},
[t]
)
const gifSourceKindShortLabel = (gif: GifMetadata) => {
if (gif.sourceKind === ExtendedKind.FILE_METADATA) return '1063'
if (gif.sourceKind === kinds.ShortTextNote) return '1'
if (gif.sourceKind === ExtendedKind.COMMENT) return '1111'
return String(gif.sourceKind)
}
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen
@ -264,31 +364,61 @@ export default function GifPicker({ @@ -264,31 +364,61 @@ export default function GifPicker({
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => (
<button
key={gif.eventId}
type="button"
className="rounded overflow-hidden border border-transparent hover:border-primary focus:border-primary focus:outline-none aspect-square"
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt="GIF"
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
))}
{gifs.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<button
type="button"
className={cn(
'absolute inset-0 z-0 rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt=""
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
<span
className="absolute top-1 left-1 z-10 max-w-[calc(100%-2.5rem)] truncate rounded border border-border/80 bg-background/90 px-1 py-px text-[10px] font-medium tabular-nums text-foreground backdrop-blur-sm pointer-events-none shadow-sm"
title={gifSourceKindTitle(gif)}
>
{gifSourceKindShortLabel(gif)}
</span>
{showArchive && (
<Button
type="button"
variant="secondary"
size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md"
disabled={archivingEventId === gif.eventId}
title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
aria-label={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
onClick={(e) => handleArchiveAndInsert(e, gif)}
>
<Download className="size-3.5" />
</Button>
)}
</div>
)
})}
</div>
)}
</ScrollArea>

430
src/components/MemePicker/index.tsx

@ -0,0 +1,430 @@ @@ -0,0 +1,430 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { fetchMemes, searchMemes, type MemeMetadata } from '@/services/meme.service'
import mediaUpload from '@/services/media-upload.service'
import { ExternalLink, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const MEMEAMIGO_URL = 'https://www.memeamigo.lol/'
const MEMEAMIGO_SEARCH_URL = (q: string) =>
q.trim() ? `${MEMEAMIGO_URL}?q=${encodeURIComponent(q.trim())}` : MEMEAMIGO_URL
function mimeFromImageUrl(url: string): string {
const lower = url.toLowerCase().split('?')[0] ?? ''
if (lower.endsWith('.png')) return 'image/png'
if (lower.endsWith('.webp')) return 'image/webp'
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
return 'image/jpeg'
}
function isStaticImageFile(file: File): boolean {
const n = file.name.toLowerCase()
const t = file.type.toLowerCase()
return (
t === 'image/jpeg' ||
t === 'image/png' ||
t === 'image/webp' ||
n.endsWith('.jpg') ||
n.endsWith('.jpeg') ||
n.endsWith('.png') ||
n.endsWith('.webp')
)
}
export default function MemePicker({
children,
onSelect,
portalContainer
}: {
children: React.ReactNode
onSelect?: (imageUrl: string) => void
portalContainer?: HTMLElement | null
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish, pubkey, relayList } = useNostr()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
const [memes, setMemes] = useState<MemeMetadata[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [pasteUrl, setPasteUrl] = useState('')
const [publishingPaste, setPublishingPaste] = useState(false)
const [publishDescription, setPublishDescription] = useState('')
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const memeamigoPopupRef = useRef<Window | null>(null)
const userReadRelays = relayList?.read ?? []
const userWriteRelays = relayList?.write ?? []
const loadMemes = useCallback(
async (q: string, forceRefresh = false) => {
setError(null)
setLoading(true)
try {
const results = q.trim()
? await searchMemes(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null)
: await fetchMemes(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null)
setMemes(results)
if (results.length === 0 && !q.trim()) {
setError(
t(
'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).'
)
)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load memes')
setMemes([])
} finally {
setLoading(false)
}
},
[t, userReadRelays, pubkey]
)
useEffect(() => {
if (!open) return
loadMemes(query)
}, [open, query, loadMemes])
useEffect(() => {
if (!open) return
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
searchTimeoutRef.current = setTimeout(() => {
setQuery(searchInput)
}, 300)
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
}
}, [searchInput, open])
const handleSelect = (meme: MemeMetadata) => {
const url = meme.fallbackUrl || meme.url
onSelect?.(url)
setOpen(false)
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !pubkey) return
setUploadError(null)
setUploading(true)
try {
if (!isStaticImageFile(file)) {
setUploadError(t('{{name}} is not a JPEG, PNG, or WebP file', { name: file.name }))
return
}
const { url } = await mediaUpload.upload(file)
const mime = file.type || mimeFromImageUrl(url)
const draft = {
kind: ExtendedKind.FILE_METADATA,
content: publishDescription.trim(),
tags: [
['file', url, mime, `size ${file.size}`],
['url', url],
['m', mime],
['t', 'memeamigo']
],
created_at: Math.floor(Date.now() / 1000)
}
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
const specifiedRelayUrls = writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
await publish(draft, { specifiedRelayUrls })
setPublishDescription('')
setQuery('')
await loadMemes('', true)
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const triggerFileUpload = () => fileInputRef.current?.click()
const isLoggedIn = !!pubkey
const openMemeAmigoSearch = useCallback(() => {
const url = MEMEAMIGO_SEARCH_URL(searchInput)
const w = window.open(url, '_blank', 'noopener,noreferrer')
memeamigoPopupRef.current = w ?? null
const handler = (event: MessageEvent) => {
if (
event.origin !== 'https://www.memeamigo.lol' &&
event.origin !== 'https://memeamigo.lol'
) {
return
}
const data = event.data
const urlToInsert =
typeof data === 'string' && (data.startsWith('http://') || data.startsWith('https://'))
? data
: data?.url ?? data?.imageUrl
if (urlToInsert && typeof urlToInsert === 'string') {
window.removeEventListener('message', handler)
memeamigoPopupRef.current = null
onSelect?.(urlToInsert)
setOpen(false)
}
}
window.addEventListener('message', handler)
const timer = setTimeout(() => {
window.removeEventListener('message', handler)
memeamigoPopupRef.current = null
}, 10 * 60 * 1000)
if (w)
w.addEventListener('beforeunload', () => {
clearTimeout(timer)
window.removeEventListener('message', handler)
})
}, [searchInput, onSelect])
const descriptionForPublish = publishDescription.trim()
const handlePasteUrlInsert = useCallback(async () => {
const url = pasteUrl.trim()
if (!url || !/^https?:\/\//i.test(url)) return
onSelect?.(url)
setPasteUrl('')
setOpen(false)
if (pubkey) {
setPublishingPaste(true)
try {
const mime = mimeFromImageUrl(url)
const draft = {
kind: ExtendedKind.FILE_METADATA,
content: descriptionForPublish,
tags: [
['url', url],
['m', mime],
['t', 'memeamigo']
],
created_at: Math.floor(Date.now() / 1000)
}
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
const specifiedRelayUrls = writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
await publish(draft, { specifiedRelayUrls })
setPublishDescription('')
} catch {
// ignore; URL was still inserted
} finally {
setPublishingPaste(false)
}
}
}, [pasteUrl, pubkey, onSelect, publish, userWriteRelays, descriptionForPublish])
const isDrawer = isSmallScreen
const content = (
<div
className={`flex flex-col gap-2 p-2 ${isDrawer ? 'w-full h-[70vh] max-h-[70vh] overflow-hidden' : 'min-w-[280px] max-w-[360px]'}`}
>
<div className="flex items-center gap-1 shrink-0">
<Input
placeholder={t('Search memes')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 size-8"
onClick={() => setOpen(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div>
{error && <p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>}
<div
className={isDrawer ? 'flex-1 min-h-0 flex flex-col' : undefined}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border'
}
>
{loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{memes.map((meme) => (
<button
key={meme.eventId}
type="button"
className="rounded overflow-hidden border border-transparent hover:border-primary focus:border-primary focus:outline-none aspect-square"
onClick={() => handleSelect(meme)}
>
<img
src={meme.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = meme.fallbackUrl?.trim()
if (fallback && el.dataset.memeFallbackTried !== '1') {
el.dataset.memeFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
))}
</div>
)}
</ScrollArea>
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={openMemeAmigoSearch}
>
<ExternalLink className="size-3.5 mr-1.5" />
{t('Search on Meme Amigo')}
</Button>
<p className="text-xs text-muted-foreground">
{t(
'Opens in a new tab. Copy an image URL there, then paste below. If this picker closed, click “Insert meme” again to paste.'
)}
</p>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Paste URL of a meme image')}
</Label>
<div className="flex gap-1">
<Input
placeholder="https://..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
className="flex-1 min-w-0"
/>
<Button
type="button"
size="sm"
disabled={!pasteUrl.trim() || publishingPaste}
onClick={handlePasteUrlInsert}
title={t(
'Insert URL into your post and publish kind 1063 (NIP-94) with hashtag memeamigo for discoverability.'
)}
>
{publishingPaste ? t('Adding…') : t('Insert')}
</Button>
</div>
</div>
</div>
{isLoggedIn && (
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Description (optional, for search)')}
</Label>
<Input
placeholder={t('e.g. drake, distracted boyfriend')}
value={publishDescription}
onChange={(e) => setPublishDescription(e.target.value)}
className="min-w-0"
/>
</div>
)}
{isLoggedIn && (
<>
<input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="w-full"
disabled={uploading}
onClick={triggerFileUpload}
>
{uploading ? t('Uploading...') : t('Add your own meme templates')}
</Button>
{uploadError && (
<p className="text-xs text-destructive text-center">{uploadError}</p>
)}
</>
)}
</div>
</div>
)
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent portalContainer={portalContainer}>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a meme')}</DrawerTitle>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
)
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content}
</DropdownMenuContent>
</DropdownMenu>
)
}

31
src/components/PostEditor/PostContent.tsx

@ -58,7 +58,8 @@ import { @@ -58,7 +58,8 @@ import {
Mic,
Music,
Video,
Film
Film,
Laugh
} from 'lucide-react'
import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
@ -75,6 +76,7 @@ import { useTranslation } from 'react-i18next' @@ -75,6 +76,7 @@ import { useTranslation } from 'react-i18next'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import EmojiPickerDialog from '../EmojiPickerDialog'
import GifPicker from '../GifPicker'
import MemePicker from '../MemePicker'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Mentions, { extractMentions } from './Mentions'
import PollEditor from './PollEditor'
@ -2169,6 +2171,24 @@ export default function PostContent({ @@ -2169,6 +2171,24 @@ export default function PostContent({
)}
</>
)}
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => {
textareaRef.current?.insertText(memeUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
</>
}
/>
@ -2282,15 +2302,6 @@ export default function PostContent({ @@ -2282,15 +2302,6 @@ export default function PostContent({
<ImageUp />
</Button>
</Uploader>
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
{/* I'm not sure why, but after triggering the virtual keyboard,
opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */}

23
src/i18n/locales/en.ts

@ -292,16 +292,24 @@ export default { @@ -292,16 +292,24 @@ export default {
'Upload Image': 'Upload Image',
'Insert emoji': 'Insert emoji',
'Insert GIF': 'Insert GIF',
'Insert meme': 'Insert meme',
'Search GIFs': 'Search GIFs',
'Search memes': 'Search memes',
'Choose a GIF': 'Choose a GIF',
'Choose a meme': 'Choose a meme',
'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs',
'Add your own GIFs': 'Add your own GIFs',
'Add your own meme templates': 'Add your own meme templates',
'Description (optional, for search)': 'Description (optional, for search)',
'e.g. happy birthday, thumbs up': 'e.g. happy birthday, thumbs up',
'e.g. drake, distracted boyfriend': 'e.g. drake, distracted boyfriend',
'Uploading...': 'Uploading...',
'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.':
'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.',
'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).':
'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).',
'{{name}} is not a GIF file': '{{name}} is not a GIF file',
'{{name}} is not a JPEG, PNG, or WebP file': '{{name}} is not a JPEG, PNG, or WebP file',
'R & W': 'R & W',
Read: 'Read',
Write: 'Write',
@ -1208,6 +1216,8 @@ export default { @@ -1208,6 +1216,8 @@ export default {
Insert: 'Insert',
'Insert URL into your post and publish to Nostr GIF library (NIP-94).':
'Insert URL into your post and publish to Nostr GIF library (NIP-94).',
'Insert URL into your post and publish kind 1063 (NIP-94) with hashtag memeamigo for discoverability.':
'Insert URL into your post and publish kind 1063 (NIP-94) with hashtag memeamigo for discoverability.',
'Insert event or address': 'Insert event or address',
'Insert mention': 'Insert mention',
'Internal Citation': 'Internal Citation',
@ -1292,6 +1302,8 @@ export default { @@ -1292,6 +1302,8 @@ export default {
'Open Timestamp': 'Open Timestamp',
'Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.':
'Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.',
'Opens in a new tab. Copy an image URL there, then paste below. If this picker closed, click “Insert meme” again to paste.':
'Opens in a new tab. Copy an image URL there, then paste below. If this picker closed, click “Insert meme” again to paste.',
Optional: 'Optional',
'Optional image for the event': 'Optional image for the event',
'Optionally, add the full quote/context to show your highlight within it':
@ -1300,6 +1312,7 @@ export default { @@ -1300,6 +1312,7 @@ export default {
'Page Range': 'Page Range',
Pages: 'Pages',
'Paste URL of a GIF': 'Paste URL of a GIF',
'Paste URL of a meme image': 'Paste URL of a meme image',
'Paste the entire original passage that contains your highlight':
'Paste the entire original passage that contains your highlight',
Photo: 'Photo',
@ -1323,6 +1336,8 @@ export default { @@ -1323,6 +1336,8 @@ export default {
'Prompt Citation Settings': 'Prompt Citation Settings',
'Prompt Conversation Script': 'Prompt Conversation Script',
'Proof of Work': 'Proof of Work',
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post':
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post',
'Publish to Relays': 'Publish to Relays',
'Published By': 'Published By',
'Published In': 'Published In',
@ -1440,6 +1455,7 @@ export default { @@ -1440,6 +1455,7 @@ export default {
'Search logs...': 'Search logs...',
'Search notes, threads, long-form…': 'Search notes, threads, long-form…',
'Search on GifBuddy': 'Search on GifBuddy',
'Search on Meme Amigo': 'Search on Meme Amigo',
'Search posts...': 'Search posts...',
'Search threads by title, content, tags, npub, author...':
'Search threads by title, content, tags, npub, author...',
@ -1496,6 +1512,13 @@ export default { @@ -1496,6 +1512,13 @@ export default {
'The main editor above should contain only the text you want to highlight. This field should contain the full quote or paragraph for context.',
'These relays were found from your NIP-05 identifier and signer. You can add them to your relay list.':
'These relays were found from your NIP-05 identifier and signer. You can add them to your relay list.',
'This GIF comes from kind 1063 (NIP-94 file metadata). Choosing it still publishes your own kind 1063 to your write relays (and fast write relays as fallback) so your relays index the URL.':
'This GIF comes from kind 1063 (NIP-94 file metadata). Choosing it still publishes your own kind 1063 to your write relays (and fast write relays as fallback) so your relays index the URL.',
'This GIF was found in a kind 1 note. Notes are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.':
'This GIF was found in a kind 1 note. Notes are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.',
'This GIF was found in a kind 1111 comment. Comments are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.':
'This GIF was found in a kind 1111 comment. Comments are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.',
'This GIF was found in a Nostr event of kind {{kind}}.': 'This GIF was found in a Nostr event of kind {{kind}}.',
'This file could be either audio or video. Please select the correct type:':
'This file could be either audio or video. Please select the correct type:',
'This store does not contain replaceable events':

11
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -18,7 +18,7 @@ import { @@ -18,7 +18,7 @@ import {
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Hash, X, Users, Film, Image, Zap, Settings, Book, ChevronDown, Check, Smile, Upload } from 'lucide-react'
import { Hash, X, Users, Film, Laugh, Image, Zap, Settings, Book, ChevronDown, Check, Smile, Upload } from 'lucide-react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
@ -34,6 +34,7 @@ import { DISCUSSION_TOPICS } from './discussionTopics' @@ -34,6 +34,7 @@ import { DISCUSSION_TOPICS } from './discussionTopics'
import PostRelaySelector from '@/components/PostEditor/PostRelaySelector'
import PostTextarea, { type TPostTextareaHandle } from '@/components/PostEditor/PostTextarea'
import GifPicker from '@/components/GifPicker'
import MemePicker from '@/components/MemePicker'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import Uploader from '@/components/PostEditor/Uploader'
import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog'
@ -785,6 +786,14 @@ export default function CreateThreadDialog({ @@ -785,6 +786,14 @@ export default function CreateThreadDialog({
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => insertAtCursor(memeUrl + ' ')}
portalContainer={pickerPortalContainer ?? undefined}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
<EmojiPickerDialog
portalContainer={pickerPortalContainer ?? undefined}
onEmojiClick={(emoji) => {

28
src/services/gif.service.ts

@ -17,11 +17,34 @@ export interface GifMetadata { @@ -17,11 +17,34 @@ export interface GifMetadata {
mimeType?: string
width?: number
height?: number
/** Nostr kind of the event this row was parsed from (1063 vs note vs comment). */
sourceKind: number
eventId: string
pubkey: string
createdAt: number
}
/** True if the GIF bytes are served from nostr.build (or a subdomain). */
export function isNostrBuildHostedUrl(url: string): boolean {
try {
const h = new URL(url).hostname.toLowerCase()
return h === 'nostr.build' || h.endsWith('.nostr.build')
} catch {
return false
}
}
/**
* External GIF from a note/comment: offer archive = publish kind 1063 + insert.
* Not shown for 1063 events or when the URL already points at nostr.build.
*/
export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean {
if (gif.sourceKind === ExtendedKind.FILE_METADATA) return false
if (isNostrBuildHostedUrl(gif.url)) return false
if (gif.fallbackUrl?.trim() && isNostrBuildHostedUrl(gif.fallbackUrl.trim())) return false
return true
}
/** Normalize a GIF URL for deduplication: strip fragment and query, lowercase. */
function normalizeGifUrl(url: string): string {
try {
@ -174,6 +197,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { @@ -174,6 +197,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
mimeType: mimeType || 'image/gif',
width,
height,
sourceKind: event.kind,
eventId: event.id,
pubkey: event.pubkey,
createdAt: event.created_at
@ -204,8 +228,12 @@ export async function fetchGifs( @@ -204,8 +228,12 @@ export async function fetchGifs(
): Promise<GifMetadata[]> {
if (!forceRefresh && !searchQuery) {
const cached = await indexedDb.getGifCache()
const cacheHasSourceKind =
cached?.gifs.length &&
cached.gifs.every((g) => typeof (g as GifMetadata).sourceKind === 'number')
if (
cached &&
cacheHasSourceKind &&
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {

92
src/services/indexed-db.service.ts

@ -1757,11 +1757,22 @@ class IndexedDbService { @@ -1757,11 +1757,22 @@ class IndexedDbService {
}
private static readonly GIF_CACHE_KEY = 'gifList'
private static readonly MEME_CACHE_KEY = 'memeList'
/**
* Get cached GIF list from IndexedDB. Returns null if missing or store unavailable.
*/
async getGifCache(): Promise<{ gifs: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[]; cachedAt: number } | null> {
async getGifCache(): Promise<{
gifs: {
url: string
fallbackUrl?: string
sourceKind?: number
eventId: string
pubkey: string
createdAt: number
}[]
cachedAt: number
} | null> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return null
@ -1773,7 +1784,17 @@ class IndexedDbService { @@ -1773,7 +1784,17 @@ class IndexedDbService {
request.onsuccess = () => {
const row = request.result as { key: string; value: { gifs: unknown[]; cachedAt: number } } | undefined
if (row?.value?.gifs && typeof row.value.cachedAt === 'number') {
resolve({ gifs: row.value.gifs as { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[], cachedAt: row.value.cachedAt })
resolve({
gifs: row.value.gifs as {
url: string
fallbackUrl?: string
sourceKind?: number
eventId: string
pubkey: string
createdAt: number
}[],
cachedAt: row.value.cachedAt
})
} else {
resolve(null)
}
@ -1785,7 +1806,17 @@ class IndexedDbService { @@ -1785,7 +1806,17 @@ class IndexedDbService {
/**
* Write GIF list cache to IndexedDB.
*/
async setGifCache(gifs: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[], cachedAt: number): Promise<void> {
async setGifCache(
gifs: {
url: string
fallbackUrl?: string
sourceKind?: number
eventId: string
pubkey: string
createdAt: number
}[],
cachedAt: number
): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return
@ -1799,6 +1830,61 @@ class IndexedDbService { @@ -1799,6 +1830,61 @@ class IndexedDbService {
})
}
/**
* Cached memes (kind 1063 `memeamigo` only). Same store as GIF cache, different key.
*/
async getMemeCache(): Promise<{
memes: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[]
cachedAt: number
} | null> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return null
}
return new Promise((resolve) => {
const transaction = this.db!.transaction(StoreNames.GIF_CACHE, 'readonly')
const store = transaction.objectStore(StoreNames.GIF_CACHE)
const request = store.get(IndexedDbService.MEME_CACHE_KEY)
request.onsuccess = () => {
const row = request.result as
| { key: string; value: { memes: unknown[]; cachedAt: number } }
| undefined
if (row?.value?.memes && typeof row.value.cachedAt === 'number') {
resolve({
memes: row.value.memes as {
url: string
fallbackUrl?: string
eventId: string
pubkey: string
createdAt: number
}[],
cachedAt: row.value.cachedAt
})
} else {
resolve(null)
}
}
request.onerror = () => resolve(null)
})
}
async setMemeCache(
memes: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[],
cachedAt: number
): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) {
return
}
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.GIF_CACHE, 'readwrite')
const store = transaction.objectStore(StoreNames.GIF_CACHE)
store.put({ key: IndexedDbService.MEME_CACHE_KEY, value: { memes, cachedAt } })
transaction.oncomplete = () => resolve()
transaction.onerror = () => reject(transaction.error)
})
}
/**
* Get a single setting value from IndexedDB. Returns null if missing.
*/

319
src/services/meme.service.ts

@ -0,0 +1,319 @@ @@ -0,0 +1,319 @@
/**
* 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)
}
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
}
}
function parseMemeFromEvent(event: NEvent): MemeMetadata | null {
if (event.kind !== ExtendedKind.FILE_METADATA) return null
return parseMemeFrom1063(event)
}
const CACHE_MAX_AGE_MS = 5 * 60 * 1000
const MIN_MEME_CACHE_ENTRIES = 6
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 cached.memes.slice(0, limit) 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]
const events = await queryService.fetchEvents(
relays1063,
{ kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 },
fetchOpts
)
const byUrl = new Map<string, { meme: MemeMetadata; priority: number }>()
for (const event of events) {
const meme = parseMemeFromEvent(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)
memes.sort((a, b) => b.createdAt - a.createdAt)
const result = memes.slice(0, limit)
if (result.length >= MIN_MEME_CACHE_ENTRIES && !searchQuery) {
await indexedDb.setMemeCache(result, Date.now())
}
return result
}
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)
}
Loading…
Cancel
Save