diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 8cab8150..2def4a88 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -5,6 +5,7 @@ import { EmbeddedHashtagParser, EmbeddedLNInvoiceParser, EmbeddedMentionParser, + EmbeddedPaytoParser, EmbeddedUrlParser, EmbeddedWebsocketUrlParser, parseContent @@ -25,6 +26,7 @@ import { EmbeddedNote, EmbeddedWebsocketUrl } from '../Embedded' +import PaytoLink from '../PaytoLink' import Emoji from '../Emoji' import ImageGallery from '../ImageGallery' import MediaPlayer from '../MediaPlayer' @@ -90,6 +92,7 @@ export default function Content({ const nodes = parseContent(_content, [ EmbeddedUrlParser, EmbeddedLNInvoiceParser, + EmbeddedPaytoParser, EmbeddedWebsocketUrlParser, EmbeddedEventParser, EmbeddedMentionParser, @@ -442,6 +445,15 @@ export default function Content({ if (node.type === 'invoice') { return } + if (node.type === 'payto') { + return ( + + ) + } if (node.type === 'websocket-url') { return } diff --git a/src/components/ContentPreview/Content.tsx b/src/components/ContentPreview/Content.tsx index c0222853..159f15c9 100644 --- a/src/components/ContentPreview/Content.tsx +++ b/src/components/ContentPreview/Content.tsx @@ -2,6 +2,7 @@ import { EmbeddedEmojiParser, EmbeddedEventParser, EmbeddedMentionParser, + EmbeddedPaytoParser, EmbeddedUrlParser, parseContent } from '@/lib/content-parser' @@ -10,6 +11,7 @@ import { cn } from '@/lib/utils' import { TEmoji } from '@/types' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import PaytoLink from '../PaytoLink' import { EmbeddedMentionText } from '../Embedded' import Emoji from '../Emoji' @@ -26,6 +28,7 @@ export default function Content({ const nodes = useMemo(() => { return parseContent(content, [ EmbeddedUrlParser, + EmbeddedPaytoParser, EmbeddedEventParser, EmbeddedMentionParser, EmbeddedEmojiParser @@ -47,6 +50,15 @@ export default function Content({ if (node.type === 'mention') { return } + if (node.type === 'payto') { + return ( + + ) + } if (node.type === 'emoji') { const shortcode = node.data.slice(1, -1).trim() const emoji = emojiInfos?.find((e) => e.shortcode === shortcode) diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 8bbf6466..bace3a89 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -6,17 +6,21 @@ import { } 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 { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service' import mediaUpload from '@/services/media-upload.service' -import { Loader2, X } from 'lucide-react' +import { ExternalLink, Loader2, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' const GIFBUDDY_URL = 'https://www.gifbuddy.lol/' +/** Query param gifbuddy may use for pre-filled search (common convention). */ +const GIFBUDDY_SEARCH_URL = (q: string) => + q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL export default function GifPicker({ children, @@ -39,8 +43,11 @@ export default function GifPicker({ 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 searchTimeoutRef = useRef | null>(null) const fileInputRef = useRef(null) + const gifbuddyPopupRef = useRef(null) const loadGifs = useCallback(async (q: string, forceRefresh = false) => { setError(null) @@ -126,6 +133,62 @@ export default function GifPicker({ const isLoggedIn = !!pubkey + /** Open GifBuddy in a new tab (not a popup) so the picker doesn't close from focus loss. Listen for postMessage in case GifBuddy adds embed support. */ + const openGifBuddySearch = useCallback(() => { + const url = GIFBUDDY_SEARCH_URL(searchInput) + const w = window.open(url, '_blank', 'noopener,noreferrer') + gifbuddyPopupRef.current = w ?? null + const handler = (event: MessageEvent) => { + if (event.origin !== 'https://www.gifbuddy.lol' && event.origin !== 'https://gifbuddy.lol') return + const data = event.data + const urlToInsert = + typeof data === 'string' && (data.startsWith('http://') || data.startsWith('https://')) + ? data + : data?.url ?? data?.gifUrl + if (urlToInsert && typeof urlToInsert === 'string') { + window.removeEventListener('message', handler) + gifbuddyPopupRef.current = null + onSelect?.(urlToInsert) + setOpen(false) + } + } + window.addEventListener('message', handler) + const t = setTimeout(() => { + window.removeEventListener('message', handler) + gifbuddyPopupRef.current = null + }, 10 * 60 * 1000) + if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) }) + }, [searchInput, onSelect]) + + /** Insert pasted GIF URL and publish kind 1063 so it's added to Nostr GIF library. */ + 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 draft = { + kind: ExtendedKind.FILE_METADATA, + content: '', + tags: [ + ['url', url], + ['m', 'image/gif'], + ['t', 'gif'] + ], + created_at: Math.floor(Date.now() / 1000) + } + await publish(draft, { specifiedRelayUrls: GIF_RELAY_URLS }) + } catch { + // ignore; URL was still inserted + } finally { + setPublishingPaste(false) + } + } + }, [pasteUrl, pubkey, onSelect, publish]) + /** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */ const isDrawer = isSmallScreen const content = ( @@ -194,14 +257,43 @@ export default function GifPicker({
- - {t('Search GifBuddy for more GIFs')} - +
+ +

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

+
+ +
+ setPasteUrl(e.target.value)} + className="flex-1 min-w-0" + /> + +
+
+
{isLoggedIn && ( <> { @@ -25,6 +28,15 @@ export default function ProfileAbout({ about, className }: { about?: string; cla if (node.type === 'websocket-url') { return } + if (node.type === 'payto') { + return ( + + ) + } if (node.type === 'hashtag') { return } diff --git a/src/constants.ts b/src/constants.ts index 8fba58df..f4dd438e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -122,12 +122,13 @@ export const FAST_WRITE_RELAY_URLS = [ ] /** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. - * Include relay.gifbuddy.lol (GifBuddy) so we get many kind 1063 GIFs; damus/primal/thecitadel have fewer. */ + * Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */ export const GIF_RELAY_URLS = [ 'wss://relay.gifbuddy.lol', 'wss://relay.damus.io', 'wss://relay.primal.net', - 'wss://thecitadel.nostr1.com' + 'wss://thecitadel.nostr1.com', + 'wss://nos.lol', ] export const SEARCHABLE_RELAY_URLS = [ diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts index 7bf1088a..d92fa524 100644 --- a/src/lib/content-parser.ts +++ b/src/lib/content-parser.ts @@ -8,6 +8,7 @@ import { WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' +import { PAYTO_URI_REGEX } from '@/lib/payto' import { isImage, isMedia } from './url' export type TEmbeddedNodeType = @@ -24,6 +25,7 @@ export type TEmbeddedNodeType = | 'emoji' | 'invoice' | 'youtube' + | 'payto' export type TEmbeddedNode = | { @@ -74,6 +76,12 @@ export const EmbeddedLNInvoiceParser: TContentParser = { regex: LN_INVOICE_REGEX } +/** payto:// URIs (RFC-8905 / NIP-A3) – e.g. in profile about or note content */ +export const EmbeddedPaytoParser: TContentParser = { + type: 'payto', + regex: PAYTO_URI_REGEX +} + export const EmbeddedUrlParser: TContentParser = (content: string) => { const matches = content.matchAll(URL_REGEX) const result: TEmbeddedNode[] = []