From 588424953d209c86f6058376c7d1c855d96bf612 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 27 Mar 2026 20:02:51 +0100 Subject: [PATCH] add meme picker --- src/components/GifPicker/index.tsx | 236 +++++++--- src/components/MemePicker/index.tsx | 430 ++++++++++++++++++ src/components/PostEditor/PostContent.tsx | 31 +- src/i18n/locales/en.ts | 23 + .../DiscussionsPage/CreateThreadDialog.tsx | 11 +- src/services/gif.service.ts | 28 ++ src/services/indexed-db.service.ts | 92 +++- src/services/meme.service.ts | 319 +++++++++++++ 8 files changed, 1103 insertions(+), 67 deletions(-) create mode 100644 src/components/MemePicker/index.tsx create mode 100644 src/services/meme.service.ts diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 35435811..7978cc39 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -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({ const [uploadError, setUploadError] = useState(null) const [pasteUrl, setPasteUrl] = useState('') const [publishingPaste, setPublishingPaste] = useState(false) + const [archivingEventId, setArchivingEventId] = useState(null) const [publishDescription, setPublishDescription] = useState('') const searchTimeoutRef = useRef | null>(null) const fileInputRef = useRef(null) @@ -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() + 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() + 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({ } }, [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) => { const file = e.target.files?.[0] @@ -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() - 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({ ], created_at: Math.floor(Date.now() / 1000) } - const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays] - const seen = new Set() - 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({ 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({ ) : (
- {gifs.map((gif) => ( - - ))} + {gifs.map((gif) => { + const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn + return ( +
+ + + {gifSourceKindShortLabel(gif)} + + {showArchive && ( + + )} +
+ ) + })}
)} diff --git a/src/components/MemePicker/index.tsx b/src/components/MemePicker/index.tsx new file mode 100644 index 00000000..d0c2bfa2 --- /dev/null +++ b/src/components/MemePicker/index.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [pasteUrl, setPasteUrl] = useState('') + const [publishingPaste, setPublishingPaste] = useState(false) + const [publishDescription, setPublishDescription] = useState('') + const searchTimeoutRef = useRef | null>(null) + const fileInputRef = useRef(null) + const memeamigoPopupRef = useRef(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) => { + 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() + 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() + 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 = ( +
+
+ setSearchInput(e.target.value)} + className="flex-1" + /> + +
+ {error &&

{error}

} +
+ + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {memes.map((meme) => ( + + ))} +
+ )} +
+
+
+
+ +

+ {t( + 'Opens in a new tab. Copy an image URL there, then paste below. If this picker closed, click “Insert meme” again to paste.' + )} +

+
+ +
+ setPasteUrl(e.target.value)} + className="flex-1 min-w-0" + /> + +
+
+
+ {isLoggedIn && ( +
+ + setPublishDescription(e.target.value)} + className="min-w-0" + /> +
+ )} + {isLoggedIn && ( + <> + + + {uploadError && ( +

{uploadError}

+ )} + + )} +
+
+ ) + + if (isSmallScreen) { + return ( + + {children} + + + {t('Choose a meme')} + + {content} + + + ) + } + + return ( + + {children} + + {content} + + + ) +} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 937b8d2e..1fa19122 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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' 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({ )} )} + { + textareaRef.current?.insertText(gifUrl) + }} + > + + + { + textareaRef.current?.insertText(memeUrl) + }} + > + + } /> @@ -2282,15 +2302,6 @@ export default function PostContent({ - { - textareaRef.current?.insertText(gifUrl) - }} - > - - {/* 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. */} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 529c5543..845b5224 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { 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 { '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 { '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 { '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 { '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 { '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': diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index cdc8b36e..2b151902 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -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' 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({ + insertAtCursor(memeUrl + ' ')} + portalContainer={pickerPortalContainer ?? undefined} + > + + { diff --git a/src/services/gif.service.ts b/src/services/gif.service.ts index 20345bc8..d25bb7b5 100644 --- a/src/services/gif.service.ts +++ b/src/services/gif.service.ts @@ -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 { 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( ): Promise { 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 ) { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 63f21126..f39c1b8a 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 { 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 { /** * Write GIF list cache to IndexedDB. */ - async setGifCache(gifs: { url: string; fallbackUrl?: string; eventId: string; pubkey: string; createdAt: number }[], cachedAt: number): Promise { + async setGifCache( + gifs: { + url: string + fallbackUrl?: string + sourceKind?: number + eventId: string + pubkey: string + createdAt: number + }[], + cachedAt: number + ): Promise { await this.initPromise if (!this.db || !this.db.objectStoreNames.contains(StoreNames.GIF_CACHE)) { return @@ -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 { + 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. */ diff --git a/src/services/meme.service.ts b/src/services/meme.service.ts new file mode 100644 index 00000000..95e2da45 --- /dev/null +++ b/src/services/meme.service.ts @@ -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 { + 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() + 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() + + 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 { + return fetchMemes(query, limit, forceRefresh, extraReadRelayUrls, userPubkey) +}