diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 4aab2a06..019390ed 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -103,6 +103,7 @@ const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPag const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage')) const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage')) +const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage')) const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage')) const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage')) @@ -844,6 +845,26 @@ export function useSmartInterestListNavigation() { return { navigateToInterestList } } +export function useSmartUserEmojiListNavigation() { + const { setPrimaryNoteView } = usePrimaryNoteView() + const { push: pushSecondaryPage } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + + const navigateToUserEmojiList = (url: string) => { + if (isSmallScreen) { + window.history.pushState(null, '', url) + setPrimaryNoteView( + suspensePrimaryPage(), + 'user-emojis' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToUserEmojiList } +} + // Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop export function useSmartOthersRelaySettingsNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() @@ -1808,6 +1829,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { primaryViewType === 'bookmarks' || primaryViewType === 'pins' || primaryViewType === 'interests' || + primaryViewType === 'user-emojis' || primaryViewType === 'mute' ) { setPrimaryNoteView(null) diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index cad523b2..ae276d8b 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -1,17 +1,25 @@ -import { useMediaExtraction } from '@/hooks' +import { useEmojiInfosForEvent, useMediaExtraction } from '@/hooks' import { parseContent, PARSE_CONTENT_PARSERS_NOTE_TEXT } from '@/lib/content-parser' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' import logger from '@/lib/logger' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' import { getHttpUrlFromITags } from '@/lib/event' import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url' import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/url' -import { TImetaInfo } from '@/types' +import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' +import { randomString } from '@/lib/random' +import modalManager from '@/services/modal-manager.service' +import { TEmoji, TImetaInfo } from '@/types' import { Event } from 'nostr-tools' -import { useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import Lightbox from 'yet-another-react-lightbox' +import Captions from 'yet-another-react-lightbox/plugins/captions' +import Video from 'yet-another-react-lightbox/plugins/video' +import Zoom from 'yet-another-react-lightbox/plugins/zoom' +import 'yet-another-react-lightbox/plugins/captions.css' import { EmbeddedHashtag, EmbeddedLNInvoice, @@ -88,11 +96,55 @@ export default function Content({ // Use unified media extraction service const extractedMedia = useMediaExtraction(event, _content) - - const { nodes, emojiInfos } = useMemo(() => { - if (!_content) return {} + const emojiInfos = useEmojiInfosForEvent(event ?? undefined) + + const customEmojiLightboxId = useMemo(() => `content-custom-emoji-lb-${randomString()}`, []) + const { customEmojiSlides, customEmojiIndexByCleanedUrl } = useMemo(() => { + const seen = new Set() + const ordered: TEmoji[] = [] + for (const e of emojiInfos) { + const c = cleanUrl(e.url) + if (!c || seen.has(c)) continue + seen.add(c) + ordered.push(e) + } + const slides = ordered.map((e) => + lightboxSlideFromImeta({ url: e.url, alt: `:${e.shortcode}:` }) + ) + const byUrl = new Map() + ordered.forEach((e, i) => { + const c = cleanUrl(e.url) + if (c) byUrl.set(c, i) + }) + return { customEmojiSlides: slides, customEmojiIndexByCleanedUrl: byUrl } + }, [emojiInfos]) + + const [customEmojiLbIndex, setCustomEmojiLbIndex] = useState(-1) + const [customEmojiLbPortal, setCustomEmojiLbPortal] = useState(false) + + useEffect(() => { + if (customEmojiLbIndex >= 0) { + modalManager.register(customEmojiLightboxId, () => setCustomEmojiLbIndex(-1)) + } else { + modalManager.unregister(customEmojiLightboxId) + } + }, [customEmojiLightboxId, customEmojiLbIndex]) + + const openCustomEmojiLightbox = useCallback( + (emoji: TEmoji) => { + const c = cleanUrl(emoji.url) + const idx = c ? customEmojiIndexByCleanedUrl.get(c) : undefined + if (typeof idx === 'number') { + setCustomEmojiLbIndex(idx) + setCustomEmojiLbPortal(true) + } + }, + [customEmojiIndexByCleanedUrl] + ) + + const nodes = useMemo(() => { + if (!_content) return undefined - const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) const customShortcodes = emojiInfos.map((e) => e.shortcode) const normalized = replaceStandardEmojiShortcodesInContent(_content, customShortcodes) if (normalized.includes('nostr:')) { @@ -103,10 +155,8 @@ export default function Content({ }) } - const nodes = parseContent(normalized, PARSE_CONTENT_PARSERS_NOTE_TEXT) - - return { nodes, emojiInfos } - }, [_content, event]) + return parseContent(normalized, PARSE_CONTENT_PARSERS_NOTE_TEXT) + }, [_content, emojiInfos]) // Extract HTTP/HTTPS links from content nodes (in order of appearance) for WebPreview cards at bottom // Exclude YouTube URLs, images, and media (they're rendered separately) @@ -591,7 +641,17 @@ export default function Content({ if (node.type === 'emoji') { const shortcode = node.data.slice(1, -1).trim() const emoji = emojiInfos.find((e) => e.shortcode === shortcode) - if (emoji) return + if (emoji) { + const canOpen = customEmojiIndexByCleanedUrl.has(cleanUrl(emoji.url) || '') + return ( + openCustomEmojiLightbox(emoji) : undefined} + /> + ) + } const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis) if (native?.emoji) return return {node.data} @@ -649,6 +709,43 @@ export default function Content({ ))} )} + + {customEmojiLbPortal && + customEmojiSlides.length > 0 && + typeof document !== 'undefined' && + createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + = 0} + close={() => setCustomEmojiLbIndex(-1)} + on={{ + exited: () => setCustomEmojiLbPortal(false) + }} + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true + }} + render={{ + buttonPrev: customEmojiSlides.length <= 1 ? () => null : undefined, + buttonNext: customEmojiSlides.length <= 1 ? () => null : undefined + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + /> +
, + document.body + )} ) } diff --git a/src/components/ContentPreview/HighlightPreview.tsx b/src/components/ContentPreview/HighlightPreview.tsx index fa3de8a1..d70f0853 100644 --- a/src/components/ContentPreview/HighlightPreview.tsx +++ b/src/components/ContentPreview/HighlightPreview.tsx @@ -1,7 +1,6 @@ -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' +import { useEmojiInfosForEvent } from '@/hooks' import { cn } from '@/lib/utils' import { Event } from 'nostr-tools' -import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Content from './Content' @@ -13,7 +12,7 @@ export default function HighlightPreview({ className?: string }) { const { t } = useTranslation() - const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event]) + const emojiInfos = useEmojiInfosForEvent(event) return (
diff --git a/src/components/ContentPreview/NormalContentPreview.tsx b/src/components/ContentPreview/NormalContentPreview.tsx index cee54429..33fae2e3 100644 --- a/src/components/ContentPreview/NormalContentPreview.tsx +++ b/src/components/ContentPreview/NormalContentPreview.tsx @@ -1,6 +1,5 @@ -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' +import { useEmojiInfosForEvent } from '@/hooks' import { Event } from 'nostr-tools' -import { useMemo } from 'react' import Content from './Content' export default function NormalContentPreview({ @@ -10,6 +9,6 @@ export default function NormalContentPreview({ event: Event className?: string }) { - const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags]) + const emojiInfos = useEmojiInfosForEvent(event) return } diff --git a/src/components/ContentPreview/PollPreview.tsx b/src/components/ContentPreview/PollPreview.tsx index 919647c9..e7790f29 100644 --- a/src/components/ContentPreview/PollPreview.tsx +++ b/src/components/ContentPreview/PollPreview.tsx @@ -1,7 +1,7 @@ import { POLL_TYPE } from '@/constants' import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { parsePollOptionVisualParts } from '@/lib/poll-option-display' -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' +import { useEmojiInfosForEvent } from '@/hooks' import { cn } from '@/lib/utils' import { Event } from 'nostr-tools' import { useMemo } from 'react' @@ -11,7 +11,7 @@ import Content from './Content' export default function PollPreview({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() - const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event]) + const emojiInfos = useEmojiInfosForEvent(event) const poll = useMemo(() => getPollMetadataFromEvent(event), [event]) const content = event.content?.trim() diff --git a/src/components/Emoji/index.tsx b/src/components/Emoji/index.tsx index 4da66706..150c1a0c 100644 --- a/src/components/Emoji/index.tsx +++ b/src/components/Emoji/index.tsx @@ -5,13 +5,16 @@ import { HTMLAttributes, useState } from 'react' export default function Emoji({ emoji, - classNames + classNames, + onImageClick }: Omit, 'className'> & { emoji: TEmoji | string classNames?: { text?: string img?: string } + /** Custom emoji only: open in media viewer / lightbox. */ + onImageClick?: (e: React.MouseEvent) => void }) { const [hasError, setHasError] = useState(false) @@ -38,13 +41,25 @@ export default function Emoji({ src={emoji.url} alt={emoji.shortcode} draggable={false} - className={cn('inline-block size-5 rounded-sm pointer-events-none', classNames?.img)} + className={cn( + 'inline-block size-5 rounded-sm', + onImageClick ? 'cursor-zoom-in' : 'pointer-events-none', + classNames?.img + )} onLoad={() => { setHasError(false) }} onError={() => { setHasError(true) }} + onClick={ + onImageClick + ? (e) => { + e.stopPropagation() + onImageClick(e) + } + : undefined + } /> ) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 65b3bc93..e0d8edac 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -9,7 +9,7 @@ import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' -import { useMediaExtraction } from '@/hooks' +import { useEmojiInfosForEvent, useMediaExtraction } from '@/hooks' import { cleanUrl, isImage, @@ -35,7 +35,6 @@ import { isSpotifyOpenUrl } from '@/lib/spotify-url' import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url' import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { TEmoji, TImetaInfo } from '@/types' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react' @@ -90,6 +89,12 @@ function resolveImetaForMarkdownImageUrl( return { url: cleaned, pubkey: eventPubkey } } +/** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */ +type TInlineEmojiLightbox = { + imageIndexMap: Map + openLightbox: (index: number) => void +} + /** * Truncate link display text to 200 characters, adding ellipsis if truncated */ @@ -683,6 +688,7 @@ function parseMarkdownContentLegacy( lazyMedia = true, resolveImetaForImageUrl } = options + const emojiLightbox: TInlineEmojiLightbox = { imageIndexMap, openLightbox } const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() const footnotes = new Map() @@ -1854,7 +1860,7 @@ function parseMarkdownContentLegacy( normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ') normalizedText = normalizedText.trim() if (normalizedText) { - const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos) + const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push(

{textContent} @@ -1917,7 +1923,7 @@ function parseMarkdownContentLegacy( normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ') normalizedText = normalizedText.trim() if (normalizedText) { - const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos) + const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push(

{textContent} @@ -1937,7 +1943,7 @@ function parseMarkdownContentLegacy( normalizedPara = normalizedPara.trim() if (normalizedPara) { // Process paragraph for inline formatting (which will handle markdown links) - const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos) + const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) // Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it) parts.push(

@@ -2179,7 +2185,7 @@ function parseMarkdownContentLegacy( const { text, url } = pattern.data // Process the link text for inline formatting (bold, italic, etc.) const linkContent = stripNestedAnchorsFromNodes( - parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos), + parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos, undefined, emojiLightbox), `link-${patternIdx}-sanitized` ) // Markdown links should always be rendered as inline links, not block-level components @@ -2269,7 +2275,7 @@ function parseMarkdownContentLegacy( } else if (pattern.type === 'header') { const { level, text } = pattern.data // Parse the header text for inline formatting (but not nested headers) - const headerContent = parseInlineMarkdown(text, `header-${patternIdx}`, footnotes, emojiInfos) + const headerContent = parseInlineMarkdown(text, `header-${patternIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const HeaderTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements parts.push( {listContent} @@ -2300,7 +2306,7 @@ function parseMarkdownContentLegacy( ) } else if (pattern.type === 'numbered-list-item') { const { text, number } = pattern.data - const listContent = parseInlineMarkdown(text, `numbered-${patternIdx}`, footnotes, emojiInfos) + const listContent = parseInlineMarkdown(text, `numbered-${patternIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) const itemNumber = number ? parseInt(number, 10) : undefined parts.push(

  • @@ -2322,7 +2328,7 @@ function parseMarkdownContentLegacy( key={`th-${patternIdx}-${cellIdx}`} className="border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800 font-semibold text-left" > - {parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes, emojiInfos)} + {parseInlineMarkdown(cell, `table-header-${patternIdx}-${cellIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)} ))} @@ -2335,7 +2341,7 @@ function parseMarkdownContentLegacy( key={`td-${patternIdx}-${rowIdx}-${cellIdx}`} className="border border-gray-300 dark:border-gray-700 px-4 py-2" > - {parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes, emojiInfos)} + {parseInlineMarkdown(cell, `table-cell-${patternIdx}-${rowIdx}-${cellIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)} ))} @@ -2374,7 +2380,7 @@ function parseMarkdownContentLegacy( // Join paragraph lines with newlines to preserve line breaks (especially before em-dashes) // This preserves the original formatting of the blockquote const paragraphText = paragraphLines.join('\n') - const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos) + const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) return (

    @@ -2397,7 +2403,7 @@ function parseMarkdownContentLegacy( // Each line should have the > prefix preserved const greentextContent = lines.map((line: string, lineIdx: number) => { // Parse inline markdown for each line (for links, hashtags, etc.) - const lineContent = parseInlineMarkdown(line, `greentext-${patternIdx}-line-${lineIdx}`, footnotes, emojiInfos) + const lineContent = parseInlineMarkdown(line, `greentext-${patternIdx}-line-${lineIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) return ( {lineIdx > 0 &&
    } @@ -2664,7 +2670,7 @@ function parseMarkdownContentLegacy( normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.trim() if (normalizedPara) { - const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos) + const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push(

    {paraContent} @@ -2720,7 +2726,7 @@ function parseMarkdownContentLegacy( normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.trim() if (normalizedPara) { - const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos) + const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push(

    {paraContent} @@ -2739,7 +2745,7 @@ function parseMarkdownContentLegacy( normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.trim() if (normalizedPara) { - const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos) + const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push(

    {paraContent} @@ -2762,7 +2768,7 @@ function parseMarkdownContentLegacy( normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.trim() if (!normalizedPara) return null - const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos) + const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) return (

    {paraContent} @@ -2882,7 +2888,7 @@ function parseMarkdownContentLegacy( const originalLine = listItemOriginalLines.get(patternIndex) if (originalLine) { // Render the original line with inline markdown processing - const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos) + const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) wrappedParts.push( {lineContent} @@ -2929,7 +2935,7 @@ function parseMarkdownContentLegacy( className="text-sm text-gray-700 dark:text-gray-300" > [{id}]:{' '} - {parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos)} + {parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, undefined, emojiLightbox)} {' '} @@ -3166,7 +3173,7 @@ function parseMarkdownContentMarked( const txt = String(token.text ?? token.raw ?? '') collectHashtags(txt) out.push( - ...parseInlineMarkdownLegacy(txt, `${key}-text`, footnotes, emojiInfos, navigateToHashtag) + ...parseInlineMarkdownLegacy(txt, `${key}-text`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox) ) break } @@ -3288,7 +3295,7 @@ function parseMarkdownContentMarked( if (txt) { collectHashtags(txt) out.push( - ...parseInlineMarkdownLegacy(txt, `${key}-fallback`, footnotes, emojiInfos, navigateToHashtag) + ...parseInlineMarkdownLegacy(txt, `${key}-fallback`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox) ) } } @@ -3531,7 +3538,7 @@ function parseMarkdownContentMarked( if (before.trim().length > 0) { nodes.push(

    - {parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)} + {parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}

    ) } @@ -3554,7 +3561,7 @@ function parseMarkdownContentMarked( if (after.trim().length > 0) { nodes.push(

    - {parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)} + {parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}

    ) } @@ -3992,7 +3999,7 @@ function parseMarkdownContentMarked( {Array.from(footnotes.entries()).map(([id, text]) => (
  • [{id}]:{' '} - {parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag)}{' '} + {parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}{' '} = new Map(), emojiInfos: TEmoji[] = [], - navigateToHashtag?: (href: string) => void + navigateToHashtag?: (href: string) => void, + emojiLightbox?: TInlineEmojiLightbox ): React.ReactNode[] { const normalized = text.replace(/\n/g, ' ').replace(/[ \t]{2,}/g, ' ') const tokens = lexInlineProtected(normalized) as any[] @@ -4039,7 +4047,7 @@ function parseInlineMarkdown( // Fast path: keep old behavior when there is no markdown syntax. if (!hasMarkdownSyntax) { - return parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag) + return parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag, emojiLightbox) } const renderTokens = (list: any[], path: string): React.ReactNode[] => { @@ -4055,7 +4063,8 @@ function parseInlineMarkdown( `${keyPrefix}-${tokenKey}-text`, _footnotes, emojiInfos, - navigateToHashtag + navigateToHashtag, + emojiLightbox ) ) continue @@ -4143,7 +4152,8 @@ function parseInlineMarkdown( `${keyPrefix}-${tokenKey}-fallback`, _footnotes, emojiInfos, - navigateToHashtag + navigateToHashtag, + emojiLightbox ) ) } @@ -4153,7 +4163,7 @@ function parseInlineMarkdown( const rendered = renderTokens(tokens, `${keyPrefix}-md`) return rendered.length > 0 ? rendered - : parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag) + : parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag, emojiLightbox) } function parseInlineMarkdownLegacy( @@ -4161,7 +4171,8 @@ function parseInlineMarkdownLegacy( keyPrefix: string, _footnotes: Map = new Map(), emojiInfos: TEmoji[] = [], - navigateToHashtag?: (href: string) => void + navigateToHashtag?: (href: string) => void, + emojiLightbox?: TInlineEmojiLightbox ): React.ReactNode[] { if (isContentSpacingDebug() && text.includes('nostr:')) { // eslint-disable-next-line no-console @@ -4396,7 +4407,7 @@ function parseInlineMarkdownLegacy( if (url.startsWith('payto://')) { parts.push( - {parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos)} + {parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos, undefined, emojiLightbox)} ) } else { @@ -4404,7 +4415,9 @@ function parseInlineMarkdownLegacy( text, `${keyPrefix}-link-${i}`, _footnotes, - emojiInfos + emojiInfos, + undefined, + emojiLightbox ) parts.push( e.shortcode === shortcode) if (custom) { - parts.push() + const cleanedUrl = cleanUrl(custom.url) + const lbIdx = + cleanedUrl && emojiLightbox ? emojiLightbox.imageIndexMap.get(cleanedUrl) : undefined + parts.push( + emojiLightbox.openLightbox(lbIdx) + : undefined + } + /> + ) } else { const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis) if (native?.emoji) { @@ -4606,6 +4633,7 @@ export default function MarkdownArticle({ const { navigateToHashtag } = useSmartHashtagNavigationOptional() const { navigateToRelay } = useSmartRelayNavigationOptional() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) + const emojiInfos = useEmojiInfosForEvent(event) const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event]) const webPreviewSuppressCleanedSet = useMemo(() => { @@ -4817,8 +4845,17 @@ export default function MarkdownArticle({ } } + for (const em of emojiInfos) { + const raw = em.url?.trim() + if (!raw) continue + const cleaned = cleanUrl(raw) + if (!cleaned || seenUrls.has(cleaned)) continue + seenUrls.add(cleaned) + images.push({ url: raw, alt: `:${em.shortcode}:` }) + } + return images - }, [extractedMedia.images, metadata.image]) + }, [extractedMedia.images, metadata.image, emojiInfos]) const lightboxSlides = useMemo( () => allImages.map((img) => lightboxSlideFromImeta(img)), @@ -5052,12 +5089,12 @@ export default function MarkdownArticle({ processed = normalizeSetextHeaders(processed) // Normalize backticks (inline code and code blocks) processed = normalizeBackticks(processed) - // Replace standard :shortcode: with Unicode (custom emojis stay as shortcode for tag lookup) - const customShortcodes = event.tags.filter((t) => t[0] === 'emoji').map((t) => t[1]).filter(Boolean) + // Replace standard :shortcode: with Unicode (custom emojis stay as shortcode for tag / profile lookup) + const customShortcodes = emojiInfos.map((e) => e.shortcode) processed = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) // Then preprocess media links return preprocessMarkdownMediaLinks(processed) - }, [event.content, event.tags]) + }, [event.content, emojiInfos]) // Create video poster map from imeta tags const videoPosterMap = useMemo(() => { @@ -5109,8 +5146,6 @@ export default function MarkdownArticle({ return map }, [event.id, JSON.stringify(event.tags)]) - const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags]) - // Parse markdown content with post-processing for nostr: links and hashtags const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { const resolveImetaForImageUrl = (cleaned: string): TImetaInfo | undefined => { diff --git a/src/components/Note/ReactionEmojiDisplay.tsx b/src/components/Note/ReactionEmojiDisplay.tsx index ced0b93f..176678cf 100644 --- a/src/components/Note/ReactionEmojiDisplay.tsx +++ b/src/components/Note/ReactionEmojiDisplay.tsx @@ -1,9 +1,8 @@ import Emoji from '@/components/Emoji' import { ExtendedKind } from '@/constants' +import { fetchAuthorNip30EmojiInfos } from '@/lib/nip30-author-emojis' import { resolveReactionEmojiSync } from '@/lib/reaction-display' -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { cn } from '@/lib/utils' -import { replaceableEventService } from '@/services/client.service' import { TEmoji } from '@/types' import { Event, kinds } from 'nostr-tools' import { useEffect, useMemo, useState } from 'react' @@ -42,9 +41,8 @@ export default function ReactionEmojiDisplay({ if (sync.mode !== 'profile' || (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION)) return let cancelled = false - replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((pe) => { - if (cancelled || !pe) return - const infos = getEmojiInfosFromEmojiTags(pe.tags) + void fetchAuthorNip30EmojiInfos(event.pubkey).then((infos) => { + if (cancelled) return const hit = infos.find((i) => i.shortcode === sync.shortcode) if (hit) setValue(hit) }) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index e7779bad..028f1d71 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1285,16 +1285,18 @@ const NoteList = forwardRef( } const profiles = res.value for (const p of profiles) { - next.set(p.pubkey, p) - pend.delete(p.pubkey) + const pkNorm = p.pubkey.toLowerCase() + next.set(pkNorm, { ...p, pubkey: pkNorm }) + pend.delete(pkNorm) } for (const pk of chunk) { - pend.delete(pk) - if (!next.has(pk)) { - next.set(pk, { - pubkey: pk, - npub: pubkeyToNpub(pk) ?? '', - username: formatPubkey(pk), + const pkNorm = pk.toLowerCase() + pend.delete(pkNorm) + if (!next.has(pkNorm)) { + next.set(pkNorm, { + pubkey: pkNorm, + npub: pubkeyToNpub(pkNorm) ?? '', + username: formatPubkey(pkNorm), batchPlaceholder: true }) } diff --git a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts index bfbbd788..01dc4062 100644 --- a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts @@ -37,10 +37,14 @@ function searchStandardEmojiShortcodes(query: string): string[] { const suggestion = { items: async ({ query }: { query: string }) => { - const custom = await customEmojiService.searchEmojis(query, client.pubkey ?? null) - const customSet = new Set(custom) - const standard = searchStandardEmojiShortcodes(query).filter((s) => !customSet.has(s)) - return [...custom, ...standard].slice(0, 50) + const customIds = await customEmojiService.searchEmojis(query, client.pubkey ?? null) + const customShortcodes = new Set( + customIds + .map((id) => customEmojiService.getEmojiById(id)?.shortcode) + .filter((s): s is string => Boolean(s)) + ) + const standard = searchStandardEmojiShortcodes(query).filter((s) => !customShortcodes.has(s)) + return [...customIds, ...standard].slice(0, 50) }, render: () => { diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx index 99a616e8..0392a5a8 100644 --- a/src/components/Profile/ProfileHeaderInteractions.tsx +++ b/src/components/Profile/ProfileHeaderInteractions.tsx @@ -1,4 +1,5 @@ import Content from '@/components/Content' +import ReactionEmojiDisplay from '@/components/Note/ReactionEmojiDisplay' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog' @@ -7,8 +8,6 @@ import { formatAmount } from '@/lib/lightning' import { cn } from '@/lib/utils' import { toNote, toProfile } from '@/lib/link' import { useSecondaryPage } from '@/PageManager' -import Emoji from '@/components/Emoji' -import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import type { TProfileZap } from '@/hooks/useProfileInteractions' import type { TProfileBadge } from '@/hooks/useProfileBadges' import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' @@ -74,10 +73,9 @@ function ZapBadge({ zap }: { zap: TProfileZap }) { function ReactionBadge({ event }: { event: Event }) { const { push } = useSecondaryPage() - const emojiInfos = getEmojiInfosFromEmojiTags(event.tags) - const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+') - const isPlus = displayContent === '+' - const isMinus = displayContent === '-' + const raw = event.content.trim() + const isPlus = raw === '+' + const isMinus = raw === '-' return ( @@ -110,7 +108,11 @@ function CommentBadge({ event }: { event: Event }) { - + ) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 25ad25cf..39f9305c 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -696,16 +696,18 @@ function ReplyNoteList({ } const profiles = res.value for (const p of profiles) { - next.set(p.pubkey, p) - pend.delete(p.pubkey) + const pkNorm = p.pubkey.toLowerCase() + next.set(pkNorm, { ...p, pubkey: pkNorm }) + pend.delete(pkNorm) } for (const pk of chunk) { - pend.delete(pk) - if (!next.has(pk)) { - next.set(pk, { - pubkey: pk, - npub: pubkeyToNpub(pk) ?? '', - username: formatPubkey(pk) + const pkNorm = pk.toLowerCase() + pend.delete(pkNorm) + if (!next.has(pkNorm)) { + next.set(pkNorm, { + pubkey: pkNorm, + npub: pubkeyToNpub(pkNorm) ?? '', + username: formatPubkey(pkNorm) }) } } diff --git a/src/constants.ts b/src/constants.ts index f3b75524..1cfa608f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -117,6 +117,13 @@ export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 12_000 /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */ export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS +/** + * Maximum `kinds` length in a single NIP-01 filter. Some relays NOTICE "too many kinds" and reject the + * entire REQ (e.g. strfry derivatives, relay.vukihreedia.xyz). QueryService splits larger arrays into + * multiple filters with the same tag scope. + */ +export const RELAY_FILTER_MAX_KINDS_PER_OBJECT = 10 + /** `SimplePool.ensureRelay` WebSocket handshake timeout (parallel multi-relay + slow TLS). */ export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000 diff --git a/src/contexts/primary-note-view-context.tsx b/src/contexts/primary-note-view-context.tsx index 417dd086..348b3dbd 100644 --- a/src/contexts/primary-note-view-context.tsx +++ b/src/contexts/primary-note-view-context.tsx @@ -12,6 +12,7 @@ export type TPrimaryOverlayViewType = | 'bookmarks' | 'pins' | 'interests' + | 'user-emojis' | 'others-relay-settings' export type PrimaryNoteViewContextValue = { diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index a565b353..926142ab 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -8,3 +8,4 @@ export * from './useFetchRelayInfo' export * from './useFetchRelayList' export * from './useSearchProfiles' export * from './useMediaExtraction' +export * from './useEmojiInfosForEvent' diff --git a/src/hooks/useEmojiInfosForEvent.test.ts b/src/hooks/useEmojiInfosForEvent.test.ts new file mode 100644 index 00000000..68ee236a --- /dev/null +++ b/src/hooks/useEmojiInfosForEvent.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + contentNeedsAuthorEmojiLookup, + mergeEmojiInfosEventOverridesAuthor +} from './useEmojiInfosForEvent' + +describe('mergeEmojiInfosEventOverridesAuthor', () => { + it('lets event shortcodes override author', () => { + const merged = mergeEmojiInfosEventOverridesAuthor( + [{ shortcode: 'x', url: 'https://a/a.png' }], + [{ shortcode: 'x', url: 'https://b/b.png' }] + ) + expect(merged).toHaveLength(1) + expect(merged[0]?.url).toBe('https://b/b.png') + }) + + it('merges distinct shortcodes', () => { + const merged = mergeEmojiInfosEventOverridesAuthor( + [{ shortcode: 'a', url: 'https://a' }], + [{ shortcode: 'b', url: 'https://b' }] + ) + expect(merged.map((e) => e.shortcode).sort()).toEqual(['a', 'b']) + }) +}) + +describe('contentNeedsAuthorEmojiLookup', () => { + it('returns false when only standard shortcodes and no event emojis', () => { + expect(contentNeedsAuthorEmojiLookup('hi :smile: bye', [])).toBe(false) + }) + + it('returns false when custom is on event tags', () => { + expect( + contentNeedsAuthorEmojiLookup('hi :chad_yes: bye', [{ shortcode: 'chad_yes', url: 'https://x' }]) + ).toBe(false) + }) + + it('returns true for unknown shortcode without event tag', () => { + expect(contentNeedsAuthorEmojiLookup(':chad_yes:', [])).toBe(true) + }) +}) diff --git a/src/hooks/useEmojiInfosForEvent.ts b/src/hooks/useEmojiInfosForEvent.ts new file mode 100644 index 00000000..d3f2ca53 --- /dev/null +++ b/src/hooks/useEmojiInfosForEvent.ts @@ -0,0 +1,85 @@ +import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' +import { + fetchAuthorNip30EmojiInfos, + fetchAuthorNip30EmojiInfosFromIndexedDb +} from '@/lib/nip30-author-emojis' +import { getEmojiInfosFromEmojiTags } from '@/lib/tag' +import { TEmoji } from '@/types' +import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' +import { type Event } from 'nostr-tools' +import { useEffect, useMemo, useState } from 'react' + +/** Event `emoji` tags override the same shortcode from the author's kind 0. */ +export function mergeEmojiInfosEventOverridesAuthor( + fromAuthor: TEmoji[], + fromEvent: TEmoji[] +): TEmoji[] { + const m = new Map() + for (const e of fromAuthor) m.set(e.shortcode, e) + for (const e of fromEvent) m.set(e.shortcode, e) + return [...m.values()] +} + +/** + * True when `content` contains a `:shortcode:` that is neither defined on the event nor a known + * standard (Unicode) shortcode — likely a custom emoji from the author's profile. + */ +export function contentNeedsAuthorEmojiLookup(content: string | undefined, eventTagInfos: TEmoji[]): boolean { + if (!content) return false + const eventCodes = new Set(eventTagInfos.map((e) => e.shortcode)) + const re = new RegExp(EMOJI_SHORT_CODE_REGEX.source, 'g') + let m: RegExpExecArray | null + while ((m = re.exec(content)) !== null) { + const code = m[1].trim() + if (eventCodes.has(code)) continue + const native = shortcodeToEmoji(code, emojis) ?? shortcodeToEmoji(code.replace(/\s+/g, '_'), emojis) + if (!native?.emoji) return true + } + return false +} + +/** + * NIP-30 emoji tags on the event plus, when needed, the author’s published custom emoji + * (kind 0, 10030, and 30030 — same inventory approach as the emoji picker). + */ +export function useEmojiInfosForEvent(event: Event | undefined | null): TEmoji[] { + const fromEvent = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags ?? []), [event?.tags]) + const needsLookup = useMemo( + () => (event ? contentNeedsAuthorEmojiLookup(event.content, fromEvent) : false), + [event?.id, event?.content, fromEvent] + ) + const pubkey = event?.pubkey?.trim().toLowerCase() ?? '' + const validPk = /^[0-9a-f]{64}$/.test(pubkey) + + const [fromAuthor, setFromAuthor] = useState([]) + + useEffect(() => { + if (!needsLookup || !validPk) { + setFromAuthor([]) + return + } + let cancelled = false + let fullResolved = false + void fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey).then((infos) => { + if (cancelled || fullResolved) return + setFromAuthor(infos) + }) + void fetchAuthorNip30EmojiInfos(pubkey) + .then((infos) => { + if (cancelled) return + fullResolved = true + setFromAuthor(infos) + }) + .catch(() => { + fullResolved = true + }) + return () => { + cancelled = true + } + }, [needsLookup, validPk, pubkey]) + + return useMemo( + () => mergeEmojiInfosEventOverridesAuthor(fromAuthor, fromEvent), + [fromAuthor, fromEvent] + ) +} diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 5c15db99..80dd2f6e 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -364,10 +364,45 @@ export function useFetchProfile(id?: string, skipCache = false) { return } if (noteFeed.pendingPubkeys.has(extractedPubkey)) { + const pkLower = extractedPubkey.toLowerCase() + const sessionEv = eventService.getSessionMetadataForPubkey(pkLower) + if (sessionEv) { + const quick = getProfileFromEvent(sessionEv) + setProfile(quick) + setPubkey(extractedPubkey) + setIsFetching(false) + setError(null) + processingPubkeyRef.current = extractedPubkey + initializedPubkeysRef.current.add(extractedPubkey) + effectRunCountRef.current.delete(extractedPubkey) + return + } setPubkey(extractedPubkey) setIsFetching(false) setError(null) - return + const pendingCancelled = { current: false } + void tryHydrateProfileFromLocalCaches(pkLower, false).then((quick) => { + if (pendingCancelled.current || !quick) return + setProfile(quick) + setIsFetching(false) + setError(null) + processingPubkeyRef.current = extractedPubkey + initializedPubkeysRef.current.add(extractedPubkey) + effectRunCountRef.current.delete(extractedPubkey) + }) + return () => { + pendingCancelled.current = true + if (processingPubkeyRef.current === extractedPubkey) { + processingPubkeyRef.current = null + } + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current) + checkIntervalRef.current = null + } + if (extractedPubkey) { + effectRunCountRef.current.delete(extractedPubkey) + } + } } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 081af47a..1e79fab7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1628,12 +1628,46 @@ export default { 'Follow sets': 'Follow sets', 'Personal Lists': 'Personal Lists', 'Personal lists hub intro': - 'Open mute list, following, bookmarks list, pinned notes, or your interest topics (kind 10015) on their own pages (like mute and following). Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.', + 'Open mute list, following, bookmarks list, pinned notes, interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.', 'Mute list': 'Mute list', 'Following list': 'Following list', 'Bookmarks list': 'Bookmarks list', 'Pinned notes list': 'Pinned notes list', 'Interests list': 'Interests list', + 'User emoji list': 'User emoji list (kind 10030)', + 'Emoji sets': 'Emoji sets (kind 30030)', + 'User emoji list title': "{{username}}'s emoji list", + 'User emoji list intro': + 'NIP-30: inline custom emoji (`emoji` tags) and references to your kind 30030 packs (`a` tags). Publish when you are done editing.', + 'User emoji list saved': 'Emoji list published', + 'User emoji inline section': 'Inline emoji', + 'User emoji inline empty': 'No inline emoji yet. Add a shortcode and image URL below.', + 'User emoji inline invalid': 'Enter a shortcode and a non-empty image URL.', + 'User emoji sets section': 'Emoji set references', + 'User emoji sets hint': + 'Add a coordinate in the form 30030:<64-hex pubkey>:, usually one of your own emoji sets from the Emoji sets page.', + 'User emoji sets empty': 'No emoji set references yet.', + 'User emoji set ref invalid': 'Invalid coordinate. Use 30030::.', + 'User emoji set ref duplicate': 'That emoji set is already in the list.', + 'Emoji set coordinate': 'Emoji set (a tag)', + 'Publish changes': 'Publish changes', + Shortcode: 'Shortcode', + 'Emoji sets settings intro': + 'NIP-30 emoji packs (kind 30030): each set has a `d` tag and `emoji` entries (shortcode + image URL). Publish from the editor dialog.', + 'New emoji set': 'New emoji set', + 'Edit emoji set': 'Edit emoji set', + 'No emoji sets yet': 'You have not created any emoji sets yet.', + 'Emoji set saved': 'Emoji set saved', + 'Emoji set deleted': 'Emoji set deleted', + 'Failed to load emoji sets': 'Failed to load emoji sets', + 'emoji entries': 'emoji', + 'Emoji set d tag hint': + 'Stable identifier for this pack. It cannot be changed after the first publish.', + 'Emoji pack entries': 'Emoji in this pack', + 'No emoji entries in pack': 'No emoji in this pack yet.', + 'Delete emoji set?': 'Delete this emoji set?', + 'Delete emoji set confirm': + 'This sends a deletion request (kind 5). Relays that accept it will drop the set; others may still show a cached copy.', 'Interests list section subtitle': 'Topics you follow for hashtag feeds and the Interests spell. Stored on Nostr as kind 10015 (`t` tags).', 'Interest topic placeholder': 'topic or #hashtag', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index ba1b6398..90ef0f41 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -1558,7 +1558,38 @@ export default { 'Follow sets': 'Follow sets', 'Personal Lists': '个人列表', 'Personal lists hub intro': - '静音列表、关注的人、NIP-51 书签与置顶。网页书签(NIP-B0,kind 39701)另计:可在文章侧栏保存,或在「咒语」里打开书签流同时查看笔记书签与网页书签。', + '静音列表、关注的人、NIP-51 书签、置顶、兴趣主题(kind 10015)、NIP-30 用户表情列表(kind 10030)与表情包(kind 30030)。网页书签(NIP-B0,kind 39701)另计:可在文章侧栏保存,或在「咒语」里打开书签流同时查看笔记书签与网页书签。', + 'User emoji list': '用户表情列表(kind 10030)', + 'Emoji sets': '表情包(kind 30030)', + 'User emoji list title': '{{username}} 的表情列表', + 'User emoji list intro': + 'NIP-30:内联自定义表情(`emoji` 标签)与指向 kind 30030 包的引用(`a` 标签)。编辑完成后点击发布。', + 'User emoji list saved': '表情列表已发布', + 'User emoji inline section': '内联表情', + 'User emoji inline empty': '尚无内联表情。在下方填写短码与图片 URL。', + 'User emoji inline invalid': '请填写短码和非空的图片 URL。', + 'User emoji sets section': '表情包引用', + 'User emoji sets hint': '坐标格式:30030:<64 位十六进制公钥>:,通常为本账户在「表情包」页面创建的集合。', + 'User emoji sets empty': '尚无表情包引用。', + 'User emoji set ref invalid': '坐标无效。请使用 30030:<公钥>:。', + 'User emoji set ref duplicate': '该表情包已在列表中。', + 'Emoji set coordinate': '表情包(a 标签)', + 'Publish changes': '发布更改', + Shortcode: '短码', + 'Emoji sets settings intro': + 'NIP-30 表情包(kind 30030):每个集合有 `d` 标签与若干 `emoji`(短码 + 图片 URL)。在编辑对话框中保存并发布。', + 'New emoji set': '新建表情包', + 'Edit emoji set': '编辑表情包', + 'No emoji sets yet': '尚未创建表情包。', + 'Emoji set saved': '表情包已保存', + 'Emoji set deleted': '表情包已删除', + 'Failed to load emoji sets': '加载表情包失败', + 'emoji entries': '个表情', + 'Emoji set d tag hint': '集合的稳定标识,首次发布后不可更改。', + 'Emoji pack entries': '包内表情', + 'No emoji entries in pack': '包内尚无表情。', + 'Delete emoji set?': '删除此表情包?', + 'Delete emoji set confirm': '将发送删除请求(kind 5)。接受的中继会移除该集合;其他客户端可能仍显示缓存副本。', 'Mute list': '静音列表', 'Following list': '关注列表', 'Bookmarks spell': '书签咒语', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 7d8b034b..3fec3657 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -30,6 +30,7 @@ import { getArticleUrlFromCommentITags, NIP22_URL_SCOPE_KIND } from '@/lib/rss-article' +import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns' import { cleanUrl } from '@/lib/url' import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip' import { randomString } from './random' @@ -894,6 +895,24 @@ export function createFollowSetDraftEvent(tags: string[][], content = '', create } } +export function createUserEmojiListDraftEvent(tags: string[][], content = '', created_at?: number): TDraftEvent { + return { + kind: kinds.UserEmojiList, + content, + created_at: created_at ?? dayjs().unix(), + tags + } +} + +export function createEmojiSetDraftEvent(tags: string[][], content = '', created_at?: number): TDraftEvent { + return { + kind: kinds.Emojisets, + content, + created_at: created_at ?? dayjs().unix(), + tags + } +} + export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent { return { kind: kinds.Metadata, @@ -1347,19 +1366,21 @@ function extractImagesFromContent(content: string) { export function transformCustomEmojisInContent(content: string) { const emojiTags: string[][] = [] let processedContent = content - const matches = content.match(/:[a-zA-Z0-9]+:/g) - - const emojiIdSet = new Set() - matches?.forEach((m) => { - if (emojiIdSet.has(m)) return - emojiIdSet.add(m) + const seen = new Set() + const re = new RegExp(EMOJI_SHORT_CODE_REGEX.source, 'g') + let m: RegExpExecArray | null + while ((m = re.exec(content)) !== null) { + const full = m[0] + const shortcode = m[1]?.trim() ?? '' + if (!shortcode || seen.has(full)) continue + seen.add(full) - const emoji = customEmojiService.getEmojiById(m.slice(1, -1)) + const emoji = customEmojiService.getEmojiById(shortcode) if (emoji) { emojiTags.push(buildEmojiTag(emoji)) - processedContent = processedContent.replace(new RegExp(m, 'g'), `:${emoji.shortcode}:`) + processedContent = processedContent.replace(new RegExp(escapeRegExp(full), 'g'), `:${emoji.shortcode}:`) } - }) + } return { emojiTags, @@ -1367,6 +1388,10 @@ export function transformCustomEmojisInContent(content: string) { } } +function escapeRegExp(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export function buildATag(event: Event, upperCase: boolean = false) { const coordinate = getReplaceableCoordinateFromEvent(event) const hint = client.getEventHint(event.id) diff --git a/src/lib/emoji-set-editor.ts b/src/lib/emoji-set-editor.ts new file mode 100644 index 00000000..d67d3272 --- /dev/null +++ b/src/lib/emoji-set-editor.ts @@ -0,0 +1,100 @@ +import { tagNameEquals } from '@/lib/tag' +import type { TEmoji } from '@/types' +import { kinds, type Event } from 'nostr-tools' + +export function getEmojiSetDTag(event: Event): string | undefined { + return event.tags.find(tagNameEquals('d'))?.[1] +} + +export function labelEmojiSetEvent(event: Event): string { + const title = event.tags.find(tagNameEquals('title'))?.[1]?.trim() + if (title) return title + const d = getEmojiSetDTag(event) + return d ?? 'emoji set' +} + +export function isEmojiSetPointerTag(tag: string[]): boolean { + if (tag[0] !== 'a' || !tag[1]) return false + const k = parseInt(tag[1].split(':')[0] ?? '', 10) + return k === kinds.Emojisets +} + +/** Tags on kind 10030 other than inline `emoji` entries and `a` → 30030 pointers. */ +export function preservedTagsFromUserEmojiListEvent(event: Event | null): string[][] { + if (!event) return [] + return event.tags.filter((t) => { + if (t[0] === 'emoji') return false + if (isEmojiSetPointerTag(t)) return false + return true + }) +} + +/** Normalize `30030::` for an `a` tag value (pubkey lowercased). */ +export function normalizeEmojiSetATagValue(raw: string): string | null { + const s = raw.trim().replace(/\s+/g, '') + const m = /^(\d+):([0-9a-f]{64}):([\s\S]*)$/i.exec(s) + if (!m) return null + const kind = parseInt(m[1], 10) + if (kind !== kinds.Emojisets) return null + const pk = m[2].toLowerCase() + return `${kinds.Emojisets}:${pk}:${m[3]}` +} + +export function buildEmojiSetTags(params: { + d: string + title?: string + description?: string + image?: string + emojis: TEmoji[] +}): string[][] { + const d = params.d.trim() + if (!d) throw new Error('Invalid list id') + const tags: string[][] = [['d', d]] + const title = params.title?.trim() + if (title) tags.push(['title', title]) + const description = params.description?.trim() + if (description) tags.push(['description', description]) + const image = params.image?.trim() + if (image) tags.push(['image', image]) + for (const e of params.emojis) { + const sc = e.shortcode.trim().replace(/^:+|:+$/gu, '') + const url = e.url.trim() + if (!sc || !url) continue + tags.push(['emoji', sc, url]) + } + return tags +} + +export function extractEmojiSetEditorFields(event: Event): { + d: string + title: string + description: string + image: string + emojis: TEmoji[] +} { + const emojis: TEmoji[] = [] + for (const t of event.tags) { + if (t[0] === 'emoji' && t[1] && t[2]) { + emojis.push({ shortcode: t[1], url: t[2] }) + } + } + return { + d: getEmojiSetDTag(event) ?? '', + title: event.tags.find(tagNameEquals('title'))?.[1] ?? '', + description: event.tags.find(tagNameEquals('description'))?.[1] ?? '', + image: event.tags.find(tagNameEquals('image'))?.[1] ?? '', + emojis + } +} + +export function dedupeEmojiSetEventsByD(events: Event[]): Event[] { + const byD = new Map() + for (const e of [...events].sort((a, b) => b.created_at - a.created_at)) { + const d = getEmojiSetDTag(e) + if (!d) continue + if (!byD.has(d)) byD.set(d, e) + } + return [...byD.values()].sort((a, b) => + labelEmojiSetEvent(a).localeCompare(labelEmojiSetEvent(b), undefined, { sensitivity: 'base' }) + ) +} diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 88e0bcc5..f6fe5313 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -712,7 +712,12 @@ export function getEmojisAndEmojiSetsFromEvent(event: Event) { url: tagValues[1] }) } else if (tagName === 'a' && tagValues[0]) { - emojiSetPointers.push(tagValues[0]) + const coord = tagValues[0] + const kindStr = coord.split(':')[0] + const kind = parseInt(kindStr ?? '', 10) + if (kind === kinds.Emojisets) { + emojiSetPointers.push(tagValues[0]) + } } }) diff --git a/src/lib/link.ts b/src/lib/link.ts index 4a9e05fb..850eb976 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -70,6 +70,7 @@ export const toGeneralSettings = () => '/settings/general' export const toTranslation = () => '/settings/translation' export const toRssFeedSettings = () => '/settings/rss-feeds' export const toFollowSetsSettings = () => '/settings/follow-sets' +export const toEmojiSetsSettings = () => '/settings/emoji-sets' export const toCacheSettings = () => '/settings/cache' export const toPersonalListsSettings = () => '/settings/personal-lists' export const toProfileEditor = () => '/profile-editor' @@ -81,6 +82,7 @@ export const toBookmarksList = () => '/bookmarks' export const toPinsList = () => '/pins' export const toInterestsList = () => '/interests' +export const toUserEmojiList = () => '/user-emojis' export const toChachiChat = (relay: string, d: string) => { return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}` diff --git a/src/lib/nip30-author-emojis.ts b/src/lib/nip30-author-emojis.ts new file mode 100644 index 00000000..27f7a5e4 --- /dev/null +++ b/src/lib/nip30-author-emojis.ts @@ -0,0 +1,119 @@ +import { getEmojisAndEmojiSetsFromEvent, getEmojisFromEvent } from '@/lib/event-metadata' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import { TEmoji } from '@/types' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +function addEmojis(map: Map, list: TEmoji[]) { + for (const e of list) { + const sc = e.shortcode?.trim() + const url = e.url?.trim() + if (sc && url) map.set(sc, { shortcode: sc, url }) + } +} + +async function collectAuthorEmojiEventsFromIndexedDb(pk: string): Promise { + const [idbMeta, idbList, idbSets] = await Promise.all([ + indexedDb.getReplaceableEvent(pk, kinds.Metadata).catch(() => null), + indexedDb.getReplaceableEvent(pk, kinds.UserEmojiList).catch(() => null), + indexedDb.getEmojiSetEventsForPubkey(pk).catch(() => [] as Event[]) + ]) + const merged: Event[] = [] + const pushIf = (ev: Event | null | undefined) => { + if (ev?.id) merged.push(ev) + } + pushIf(idbMeta ?? undefined) + pushIf(idbList ?? undefined) + for (const ev of idbSets) pushIf(ev) + return merged +} + +/** + * NIP-30 custom emoji defined by an author: kind 0 `emoji` tags, kind 10030 list (+ `a` → 30030), + * and kind 30030 packs (aligned with the custom emoji picker’s inventory fetch). + */ +async function emojiInfosFromAuthorEvents(events: Event[], pk: string): Promise { + const byShortcode = new Map() + + const latestOfKind = (kind: number): Event | undefined => + events + .filter((e) => e.kind === kind && e.pubkey.trim().toLowerCase() === pk) + .sort((a, b) => b.created_at - a.created_at)[0] + + const meta = latestOfKind(kinds.Metadata) + if (meta) addEmojis(byShortcode, getEmojisFromEvent(meta)) + + const latestList = latestOfKind(kinds.UserEmojiList) + if (latestList) { + const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(latestList) + addEmojis(byShortcode, emojis) + const setEvents = await client.fetchEmojiSetEvents(emojiSetPointers) + for (const se of setEvents) { + if (se) addEmojis(byShortcode, getEmojisFromEvent(se)) + } + } + + for (const ev of events) { + if (ev.kind === kinds.Emojisets && ev.pubkey.trim().toLowerCase() === pk) { + addEmojis(byShortcode, getEmojisFromEvent(ev)) + } + } + + return [...byShortcode.values()] +} + +async function loadAuthorNip30EmojiInfosUncached(pubkey: string): Promise { + const pk = pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) return [] + + const [remote, idbEvents] = await Promise.all([ + client.fetchAuthorEmojiInventory(pk).catch(() => [] as Event[]), + collectAuthorEmojiEventsFromIndexedDb(pk) + ]) + const merged: Event[] = [...remote] + for (const ev of idbEvents) { + if (ev?.id) merged.push(ev) + } + + return emojiInfosFromAuthorEvents(merged, pk) +} + +async function loadAuthorNip30FromIndexedDbUncached(pubkey: string): Promise { + const pk = pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) return [] + const events = await collectAuthorEmojiEventsFromIndexedDb(pk) + return emojiInfosFromAuthorEvents(events, pk) +} + +const inflightAuthorEmoji = new Map>() +const inflightAuthorEmojiIdb = new Map>() + +export function fetchAuthorNip30EmojiInfos(pubkey: string): Promise { + const pk = pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) + + const existing = inflightAuthorEmoji.get(pk) + if (existing) return existing + + const p = loadAuthorNip30EmojiInfosUncached(pk).finally(() => { + if (inflightAuthorEmoji.get(pk) === p) inflightAuthorEmoji.delete(pk) + }) + inflightAuthorEmoji.set(pk, p) + return p +} + +/** IndexedDB only — no relay inventory query; use with {@link fetchAuthorNip30EmojiInfos} for a full refresh. */ +export function fetchAuthorNip30EmojiInfosFromIndexedDb(pubkey: string): Promise { + const pk = pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) return Promise.resolve([]) + + const existing = inflightAuthorEmojiIdb.get(pk) + if (existing) return existing + + const p = loadAuthorNip30FromIndexedDbUncached(pk).finally(() => { + if (inflightAuthorEmojiIdb.get(pk) === p) inflightAuthorEmojiIdb.delete(pk) + }) + inflightAuthorEmojiIdb.set(pk, p) + return p +} diff --git a/src/pages/secondary/EmojiSetsSettingsPage/index.tsx b/src/pages/secondary/EmojiSetsSettingsPage/index.tsx new file mode 100644 index 00000000..fdb62b07 --- /dev/null +++ b/src/pages/secondary/EmojiSetsSettingsPage/index.tsx @@ -0,0 +1,525 @@ +import { RefreshButton } from '@/components/RefreshButton' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { Textarea } from '@/components/ui/textarea' +import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { + buildEmojiSetTags, + dedupeEmojiSetEventsByD, + extractEmojiSetEditorFields, + labelEmojiSetEvent +} from '@/lib/emoji-set-editor' +import { randomString } from '@/lib/random' +import { showPublishingError } from '@/lib/publishing-feedback' +import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' +import { createEmojiSetDraftEvent } from '@/lib/draft-event' +import { filterEventsExcludingTombstones } from '@/lib/event' +import logger from '@/lib/logger' +import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' +import { useNostr } from '@/providers/NostrProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import customEmojiService from '@/services/custom-emoji.service' +import { queryService, replaceableEventService } from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import dayjs from 'dayjs' +import type { TEmoji } from '@/types' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' +import { Eraser, Pencil, Plus, Sticker, Trash2 } from 'lucide-react' +import { forwardRef, useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +const EMOJI_SET_FETCH_OPTS = { + eoseTimeout: 2000, + globalTimeout: 15000, + firstRelayResultGraceMs: false +} as const + +const EmojiSetsSettingsPage = forwardRef( + ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { + const { t } = useTranslation() + const { pubkey, account, publish, attemptDelete, checkLogin, relayList, userEmojiListEvent, profileEvent } = + useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const [lists, setLists] = useState([]) + const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [editing, setEditing] = useState(null) + const [formD, setFormD] = useState('') + const [formTitle, setFormTitle] = useState('') + const [formDescription, setFormDescription] = useState('') + const [formImage, setFormImage] = useState('') + const [formEmojis, setFormEmojis] = useState([]) + const [newShortcode, setNewShortcode] = useState('') + const [newUrl, setNewUrl] = useState('') + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [cleanTarget, setCleanTarget] = useState(null) + const [cleaning, setCleaning] = useState(false) + + const canSignEvents = account != null && account.signerType !== 'npub' + + const { registerPrimaryPanelRefresh } = usePrimaryNoteView() + + const buildReadRelays = useCallback((): string[] => { + const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { userWriteRelays: relayList?.write ?? [] } + ) + return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) + }, [favoriteRelays, blockedRelays, relayList]) + + const loadLists = useCallback(async () => { + if (!pubkey) { + setLists([]) + setLoading(false) + return + } + setLoading(true) + try { + const urls = buildReadRelays() + if (!urls.length) { + setLists([]) + return + } + const events = await queryService.fetchEvents( + urls, + { authors: [pubkey], kinds: [kinds.Emojisets], limit: 500 }, + EMOJI_SET_FETCH_OPTS + ) + const tombstones = await indexedDb.getAllTombstones() + setLists(dedupeEmojiSetEventsByD(filterEventsExcludingTombstones(events, tombstones))) + } catch (e) { + logger.warn('[EmojiSetsSettings] Failed to load emoji sets', e) + toast.error(t('Failed to load emoji sets')) + setLists([]) + } finally { + setLoading(false) + } + }, [pubkey, buildReadRelays, t]) + + useEffect(() => { + void loadLists() + }, [loadLists]) + + useEffect(() => { + const onTombstones = () => void loadLists() + window.addEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) + return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) + }, [loadLists]) + + useEffect(() => { + if (!hideTitlebar) { + registerPrimaryPanelRefresh(null) + return + } + registerPrimaryPanelRefresh(() => void loadLists()) + return () => registerPrimaryPanelRefresh(null) + }, [hideTitlebar, registerPrimaryPanelRefresh, loadLists]) + + const openNew = () => { + setEditing(null) + setFormD(randomString(16)) + setFormTitle('') + setFormDescription('') + setFormImage('') + setFormEmojis([]) + setNewShortcode('') + setNewUrl('') + setDialogOpen(true) + } + + const openEdit = (ev: Event) => { + const f = extractEmojiSetEditorFields(ev) + setEditing(ev) + setFormD(f.d) + setFormTitle(f.title) + setFormDescription(f.description) + setFormImage(f.image) + setFormEmojis([...f.emojis]) + setNewShortcode('') + setNewUrl('') + setDialogOpen(true) + } + + const closeDialog = () => { + setDialogOpen(false) + setEditing(null) + } + + const addEmojiRow = (e: React.FormEvent) => { + e.preventDefault() + const sc = newShortcode.trim().replace(/^:+|:+$/gu, '') + const url = newUrl.trim() + if (!sc || !url) return + setFormEmojis((prev) => [...prev, { shortcode: sc, url }]) + setNewShortcode('') + setNewUrl('') + } + + const handleSave = async () => { + await checkLogin(async () => { + if (!pubkey) return + let tags: string[][] + try { + tags = buildEmojiSetTags({ + d: formD, + title: formTitle, + description: formDescription, + image: formImage, + emojis: formEmojis + }) + } catch (err) { + toast.error((err as Error).message) + return + } + + setSaving(true) + try { + let createdAt = dayjs().unix() + if (editing && createdAt === editing.created_at) { + await new Promise((r) => setTimeout(r, 1100)) + createdAt = dayjs().unix() + } + const draft = createEmojiSetDraftEvent(tags, '', createdAt) + const published = await publish(draft) + const ev = published as Event + try { + await indexedDb.putReplaceableEvent(ev) + } catch { + /* ignore tombstone / IDB */ + } + void replaceableEventService.updateReplaceableEventCache(ev).catch(() => {}) + await customEmojiService.init(userEmojiListEvent, pubkey, profileEvent, [ev]) + toast.success(t('Emoji set saved')) + closeDialog() + await loadLists() + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } finally { + setSaving(false) + } + }) + } + + const handleConfirmDelete = async () => { + if (!deleteTarget) return + await checkLogin(async () => { + setDeleting(true) + try { + await attemptDelete(deleteTarget) + toast.success(t('Emoji set deleted')) + setDeleteTarget(null) + await loadLists() + await customEmojiService.init(userEmojiListEvent, pubkey, profileEvent) + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } finally { + setDeleting(false) + } + }) + } + + const handleConfirmClean = async () => { + if (!cleanTarget) return + await checkLogin(async () => { + setCleaning(true) + try { + const fields = extractEmojiSetEditorFields(cleanTarget) + let createdAt = dayjs().unix() + if (createdAt === cleanTarget.created_at) { + await new Promise((r) => setTimeout(r, 1100)) + createdAt = dayjs().unix() + } + const tags = buildEmojiSetTags({ d: fields.d, title: fields.title, description: fields.description, image: fields.image, emojis: [] }) + const draft = createEmojiSetDraftEvent(tags, '', createdAt) + const published = await publish(draft) + const ev = published as Event + try { + await indexedDb.putReplaceableEvent(ev) + } catch { + /* ignore tombstone / IDB */ + } + void replaceableEventService.updateReplaceableEventCache(ev).catch(() => {}) + await customEmojiService.init(userEmojiListEvent, pubkey, profileEvent, [ev]) + toast.success(t('List cleaned')) + setCleanTarget(null) + await loadLists() + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } finally { + setCleaning(false) + } + }) + } + + return ( + void loadLists()} />} + displayScrollToTopButton + > +
    +

    {t('Emoji sets settings intro')}

    + + {!pubkey ? ( +

    {t('Login to set')}

    + ) : ( + <> +
    + +
    + + {loading ? ( +
    + + +
    + ) : lists.length === 0 ? ( +

    {t('No emoji sets yet')}

    + ) : ( +
      + {lists.map((ev) => ( +
    • +
      + +
      +
      {labelEmojiSetEvent(ev)}
      +
      + {extractEmojiSetEditorFields(ev).emojis.length} {t('emoji entries')} + · + d={extractEmojiSetEditorFields(ev).d} +
      +
      +
      +
      + + + {canSignEvents && ev.pubkey === pubkey ? ( + + ) : null} +
      +
    • + ))} +
    + )} + + )} +
    + + !o && closeDialog()}> + + + {editing ? t('Edit emoji set') : t('New emoji set')} + +
    +
    + + setFormD(e.target.value)} + disabled={!!editing} + className="font-mono text-sm" + /> +

    {t('Emoji set d tag hint')}

    +
    +
    + + setFormTitle(e.target.value)} + placeholder={t('Optional display title')} + /> +
    +
    + +