Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
529f5732b2
  1. 22
      src/PageManager.tsx
  2. 121
      src/components/Content/index.tsx
  3. 5
      src/components/ContentPreview/HighlightPreview.tsx
  4. 5
      src/components/ContentPreview/NormalContentPreview.tsx
  5. 4
      src/components/ContentPreview/PollPreview.tsx
  6. 19
      src/components/Emoji/index.tsx
  7. 113
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  8. 8
      src/components/Note/ReactionEmojiDisplay.tsx
  9. 18
      src/components/NoteList/index.tsx
  10. 12
      src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
  11. 22
      src/components/Profile/ProfileHeaderInteractions.tsx
  12. 18
      src/components/ReplyNoteList/index.tsx
  13. 7
      src/constants.ts
  14. 1
      src/contexts/primary-note-view-context.tsx
  15. 1
      src/hooks/index.tsx
  16. 40
      src/hooks/useEmojiInfosForEvent.test.ts
  17. 85
      src/hooks/useEmojiInfosForEvent.ts
  18. 37
      src/hooks/useFetchProfile.tsx
  19. 36
      src/i18n/locales/en.ts
  20. 33
      src/i18n/locales/zh.ts
  21. 43
      src/lib/draft-event.ts
  22. 100
      src/lib/emoji-set-editor.ts
  23. 7
      src/lib/event-metadata.ts
  24. 2
      src/lib/link.ts
  25. 119
      src/lib/nip30-author-emojis.ts
  26. 525
      src/pages/secondary/EmojiSetsSettingsPage/index.tsx
  27. 28
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  28. 470
      src/pages/secondary/UserEmojiListPage/index.tsx
  29. 16
      src/providers/NostrProvider/index.tsx
  30. 1
      src/providers/nostr-context.tsx
  31. 4
      src/routes.tsx
  32. 25
      src/services/client-query.service.ts
  33. 21
      src/services/client.service.ts
  34. 52
      src/services/custom-emoji.service.ts
  35. 43
      src/services/indexed-db.service.ts
  36. 9
      src/services/media-upload.service.ts
  37. 7
      src/services/navigation.service.ts

22
src/PageManager.tsx

@ -103,6 +103,7 @@ const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPag
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage'))
const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage')) const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage'))
const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage')) const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage'))
const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage'))
const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage')) const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage')) const SecondaryRelayPageLazy = lazy(() => import('@/pages/secondary/RelayPage'))
@ -844,6 +845,26 @@ export function useSmartInterestListNavigation() {
return { navigateToInterestList } 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(<PrimaryUserEmojiListPageLazy index={0} hideTitlebar={true} />),
'user-emojis'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToUserEmojiList }
}
// Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop // Fixed: Others relay settings navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartOthersRelaySettingsNavigation() { export function useSmartOthersRelaySettingsNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
@ -1808,6 +1829,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
primaryViewType === 'bookmarks' || primaryViewType === 'bookmarks' ||
primaryViewType === 'pins' || primaryViewType === 'pins' ||
primaryViewType === 'interests' || primaryViewType === 'interests' ||
primaryViewType === 'user-emojis' ||
primaryViewType === 'mute' primaryViewType === 'mute'
) { ) {
setPrimaryNoteView(null) setPrimaryNoteView(null)

121
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 { parseContent, PARSE_CONTENT_PARSERS_NOTE_TEXT } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event' import { getHttpUrlFromITags } from '@/lib/event'
import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url' import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url'
import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/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 { 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 { import {
EmbeddedHashtag, EmbeddedHashtag,
EmbeddedLNInvoice, EmbeddedLNInvoice,
@ -88,11 +96,55 @@ export default function Content({
// Use unified media extraction service // Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content) const extractedMedia = useMediaExtraction(event, _content)
const emojiInfos = useEmojiInfosForEvent(event ?? undefined)
const { nodes, emojiInfos } = useMemo(() => { const customEmojiLightboxId = useMemo(() => `content-custom-emoji-lb-${randomString()}`, [])
if (!_content) return {} const { customEmojiSlides, customEmojiIndexByCleanedUrl } = useMemo(() => {
const seen = new Set<string>()
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<string, number>()
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 customShortcodes = emojiInfos.map((e) => e.shortcode)
const normalized = replaceStandardEmojiShortcodesInContent(_content, customShortcodes) const normalized = replaceStandardEmojiShortcodesInContent(_content, customShortcodes)
if (normalized.includes('nostr:')) { if (normalized.includes('nostr:')) {
@ -103,10 +155,8 @@ export default function Content({
}) })
} }
const nodes = parseContent(normalized, PARSE_CONTENT_PARSERS_NOTE_TEXT) return parseContent(normalized, PARSE_CONTENT_PARSERS_NOTE_TEXT)
}, [_content, emojiInfos])
return { nodes, emojiInfos }
}, [_content, event])
// Extract HTTP/HTTPS links from content nodes (in order of appearance) for WebPreview cards at bottom // 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) // Exclude YouTube URLs, images, and media (they're rendered separately)
@ -591,7 +641,17 @@ export default function Content({
if (node.type === 'emoji') { if (node.type === 'emoji') {
const shortcode = node.data.slice(1, -1).trim() const shortcode = node.data.slice(1, -1).trim()
const emoji = emojiInfos.find((e) => e.shortcode === shortcode) const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} /> if (emoji) {
const canOpen = customEmojiIndexByCleanedUrl.has(cleanUrl(emoji.url) || '')
return (
<Emoji
classNames={{ img: 'mb-1' }}
emoji={emoji}
key={index}
onImageClick={canOpen ? () => openCustomEmojiLightbox(emoji) : undefined}
/>
)
}
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis) const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={native.emoji} key={index} /> if (native?.emoji) return <Emoji classNames={{ img: 'mb-1' }} emoji={native.emoji} key={index} />
return <span key={index}>{node.data}</span> return <span key={index}>{node.data}</span>
@ -649,6 +709,43 @@ export default function Content({
))} ))}
</div> </div>
)} )}
{customEmojiLbPortal &&
customEmojiSlides.length > 0 &&
typeof document !== 'undefined' &&
createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={customEmojiLbIndex}
slides={customEmojiSlides}
plugins={[Video, Zoom, Captions]}
open={customEmojiLbIndex >= 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' }
}}
/>
</div>,
document.body
)}
</div> </div>
) )
} }

5
src/components/ContentPreview/HighlightPreview.tsx

@ -1,7 +1,6 @@
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { useEmojiInfosForEvent } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Content from './Content' import Content from './Content'
@ -13,7 +12,7 @@ export default function HighlightPreview({
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event]) const emojiInfos = useEmojiInfosForEvent(event)
return ( return (
<div className={cn('pointer-events-none', className)}> <div className={cn('pointer-events-none', className)}>

5
src/components/ContentPreview/NormalContentPreview.tsx

@ -1,6 +1,5 @@
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { useEmojiInfosForEvent } from '@/hooks'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Content from './Content' import Content from './Content'
export default function NormalContentPreview({ export default function NormalContentPreview({
@ -10,6 +9,6 @@ export default function NormalContentPreview({
event: Event event: Event
className?: string className?: string
}) { }) {
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags]) const emojiInfos = useEmojiInfosForEvent(event)
return <Content content={event.content} className={className} emojiInfos={emojiInfos} /> return <Content content={event.content} className={className} emojiInfos={emojiInfos} />
} }

4
src/components/ContentPreview/PollPreview.tsx

@ -1,7 +1,7 @@
import { POLL_TYPE } from '@/constants' import { POLL_TYPE } from '@/constants'
import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { getPollMetadataFromEvent } from '@/lib/event-metadata'
import { parsePollOptionVisualParts } from '@/lib/poll-option-display' import { parsePollOptionVisualParts } from '@/lib/poll-option-display'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { useEmojiInfosForEvent } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -11,7 +11,7 @@ import Content from './Content'
export default function PollPreview({ event, className }: { event: Event; className?: string }) { export default function PollPreview({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event]) const emojiInfos = useEmojiInfosForEvent(event)
const poll = useMemo(() => getPollMetadataFromEvent(event), [event]) const poll = useMemo(() => getPollMetadataFromEvent(event), [event])
const content = event.content?.trim() const content = event.content?.trim()

19
src/components/Emoji/index.tsx

@ -5,13 +5,16 @@ import { HTMLAttributes, useState } from 'react'
export default function Emoji({ export default function Emoji({
emoji, emoji,
classNames classNames,
onImageClick
}: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & { }: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & {
emoji: TEmoji | string emoji: TEmoji | string
classNames?: { classNames?: {
text?: string text?: string
img?: string img?: string
} }
/** Custom emoji only: open in media viewer / lightbox. */
onImageClick?: (e: React.MouseEvent) => void
}) { }) {
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
@ -38,13 +41,25 @@ export default function Emoji({
src={emoji.url} src={emoji.url}
alt={emoji.shortcode} alt={emoji.shortcode}
draggable={false} 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={() => { onLoad={() => {
setHasError(false) setHasError(false)
}} }}
onError={() => { onError={() => {
setHasError(true) setHasError(true)
}} }}
onClick={
onImageClick
? (e) => {
e.stopPropagation()
onImageClick(e)
}
: undefined
}
/> />
) )
} }

113
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -9,7 +9,7 @@ import ZapStreamLiveEventEmbed from '@/components/ZapStreamLiveEventEmbed'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks' import { useEmojiInfosForEvent, useMediaExtraction } from '@/hooks'
import { import {
cleanUrl, cleanUrl,
isImage, isImage,
@ -35,7 +35,6 @@ import { isSpotifyOpenUrl } from '@/lib/spotify-url'
import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url' import { canonicalZapStreamWatchUrl, isZapStreamWatchUrl } from '@/lib/zap-stream-url'
import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns' import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji, TImetaInfo } from '@/types' import { TEmoji, TImetaInfo } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react' import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
@ -90,6 +89,12 @@ function resolveImetaForMarkdownImageUrl(
return { url: cleaned, pubkey: eventPubkey } return { url: cleaned, pubkey: eventPubkey }
} }
/** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */
type TInlineEmojiLightbox = {
imageIndexMap: Map<string, number>
openLightbox: (index: number) => void
}
/** /**
* Truncate link display text to 200 characters, adding ellipsis if truncated * Truncate link display text to 200 characters, adding ellipsis if truncated
*/ */
@ -683,6 +688,7 @@ function parseMarkdownContentLegacy(
lazyMedia = true, lazyMedia = true,
resolveImetaForImageUrl resolveImetaForImageUrl
} = options } = options
const emojiLightbox: TInlineEmojiLightbox = { imageIndexMap, openLightbox }
const parts: React.ReactNode[] = [] const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>() const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>() const footnotes = new Map<string, string>()
@ -1854,7 +1860,7 @@ function parseMarkdownContentLegacy(
normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ') normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ')
normalizedText = normalizedText.trim() normalizedText = normalizedText.trim()
if (normalizedText) { 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( parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`} className="mb-1 last:mb-0"> <p key={`text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`} className="mb-1 last:mb-0">
{textContent} {textContent}
@ -1917,7 +1923,7 @@ function parseMarkdownContentLegacy(
normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ') normalizedText = normalizedText.replace(/[ \t]{2,}/g, ' ')
normalizedText = normalizedText.trim() normalizedText = normalizedText.trim()
if (normalizedText) { 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( parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}-final`} className="mb-1 last:mb-0"> <p key={`text-${patternIdx}-para-${paraIdx}-final`} className="mb-1 last:mb-0">
{textContent} {textContent}
@ -1937,7 +1943,7 @@ function parseMarkdownContentLegacy(
normalizedPara = normalizedPara.trim() normalizedPara = normalizedPara.trim()
if (normalizedPara) { if (normalizedPara) {
// Process paragraph for inline formatting (which will handle markdown links) // 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) // Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it)
parts.push( parts.push(
<p key={`text-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0"> <p key={`text-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0">
@ -2179,7 +2185,7 @@ function parseMarkdownContentLegacy(
const { text, url } = pattern.data const { text, url } = pattern.data
// Process the link text for inline formatting (bold, italic, etc.) // Process the link text for inline formatting (bold, italic, etc.)
const linkContent = stripNestedAnchorsFromNodes( const linkContent = stripNestedAnchorsFromNodes(
parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos), parseInlineMarkdown(text, `link-${patternIdx}`, footnotes, emojiInfos, undefined, emojiLightbox),
`link-${patternIdx}-sanitized` `link-${patternIdx}-sanitized`
) )
// Markdown links should always be rendered as inline links, not block-level components // Markdown links should always be rendered as inline links, not block-level components
@ -2269,7 +2275,7 @@ function parseMarkdownContentLegacy(
} else if (pattern.type === 'header') { } else if (pattern.type === 'header') {
const { level, text } = pattern.data const { level, text } = pattern.data
// Parse the header text for inline formatting (but not nested headers) // 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 const HeaderTag = `h${Math.min(level, 6)}` as keyof JSX.IntrinsicElements
parts.push( parts.push(
<HeaderTag <HeaderTag
@ -2292,7 +2298,7 @@ function parseMarkdownContentLegacy(
) )
} else if (pattern.type === 'bullet-list-item') { } else if (pattern.type === 'bullet-list-item') {
const { text } = pattern.data const { text } = pattern.data
const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes, emojiInfos) const listContent = parseInlineMarkdown(text, `bullet-${patternIdx}`, footnotes, emojiInfos, undefined, emojiLightbox)
parts.push( parts.push(
<li key={`bullet-${patternIdx}`} className="list-disc list-inside my-1"> <li key={`bullet-${patternIdx}`} className="list-disc list-inside my-1">
{listContent} {listContent}
@ -2300,7 +2306,7 @@ function parseMarkdownContentLegacy(
) )
} else if (pattern.type === 'numbered-list-item') { } else if (pattern.type === 'numbered-list-item') {
const { text, number } = pattern.data 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 const itemNumber = number ? parseInt(number, 10) : undefined
parts.push( parts.push(
<li key={`numbered-${patternIdx}`} className="leading-tight" value={itemNumber}> <li key={`numbered-${patternIdx}`} className="leading-tight" value={itemNumber}>
@ -2322,7 +2328,7 @@ function parseMarkdownContentLegacy(
key={`th-${patternIdx}-${cellIdx}`} 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" 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)}
</th> </th>
))} ))}
</tr> </tr>
@ -2335,7 +2341,7 @@ function parseMarkdownContentLegacy(
key={`td-${patternIdx}-${rowIdx}-${cellIdx}`} key={`td-${patternIdx}-${rowIdx}-${cellIdx}`}
className="border border-gray-300 dark:border-gray-700 px-4 py-2" 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)}
</td> </td>
))} ))}
</tr> </tr>
@ -2374,7 +2380,7 @@ function parseMarkdownContentLegacy(
// Join paragraph lines with newlines to preserve line breaks (especially before em-dashes) // Join paragraph lines with newlines to preserve line breaks (especially before em-dashes)
// This preserves the original formatting of the blockquote // This preserves the original formatting of the blockquote
const paragraphText = paragraphLines.join('\n') 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 ( return (
<p key={`blockquote-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0 whitespace-pre-line"> <p key={`blockquote-${patternIdx}-para-${paraIdx}`} className="mb-1 last:mb-0 whitespace-pre-line">
@ -2397,7 +2403,7 @@ function parseMarkdownContentLegacy(
// Each line should have the > prefix preserved // Each line should have the > prefix preserved
const greentextContent = lines.map((line: string, lineIdx: number) => { const greentextContent = lines.map((line: string, lineIdx: number) => {
// Parse inline markdown for each line (for links, hashtags, etc.) // 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 ( return (
<React.Fragment key={`greentext-${patternIdx}-line-${lineIdx}`}> <React.Fragment key={`greentext-${patternIdx}-line-${lineIdx}`}>
{lineIdx > 0 && <br />} {lineIdx > 0 && <br />}
@ -2664,7 +2670,7 @@ function parseMarkdownContentLegacy(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim() normalizedPara = normalizedPara.trim()
if (normalizedPara) { 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( parts.push(
<p key={`text-end-para-${imgIdx}-${paraIdx}`} className="mb-1 last:mb-0"> <p key={`text-end-para-${imgIdx}-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent} {paraContent}
@ -2720,7 +2726,7 @@ function parseMarkdownContentLegacy(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim() normalizedPara = normalizedPara.trim()
if (normalizedPara) { 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( parts.push(
<p key={`text-end-final-para-${paraIdx}`} className="mb-1 last:mb-0"> <p key={`text-end-final-para-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent} {paraContent}
@ -2739,7 +2745,7 @@ function parseMarkdownContentLegacy(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim() normalizedPara = normalizedPara.trim()
if (normalizedPara) { 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( parts.push(
<p key={`text-end-para-${paraIdx}`} className="mb-1 last:mb-0"> <p key={`text-end-para-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent} {paraContent}
@ -2762,7 +2768,7 @@ function parseMarkdownContentLegacy(
normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ') normalizedPara = normalizedPara.replace(/[ \t]{2,}/g, ' ')
normalizedPara = normalizedPara.trim() normalizedPara = normalizedPara.trim()
if (!normalizedPara) return null 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 ( return (
<p key={`text-only-para-${paraIdx}`} className="mb-1 last:mb-0"> <p key={`text-only-para-${paraIdx}`} className="mb-1 last:mb-0">
{paraContent} {paraContent}
@ -2882,7 +2888,7 @@ function parseMarkdownContentLegacy(
const originalLine = listItemOriginalLines.get(patternIndex) const originalLine = listItemOriginalLines.get(patternIndex)
if (originalLine) { if (originalLine) {
// Render the original line with inline markdown processing // 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( wrappedParts.push(
<span key={`list-item-content-${partIdx}`}> <span key={`list-item-content-${partIdx}`}>
{lineContent} {lineContent}
@ -2929,7 +2935,7 @@ function parseMarkdownContentLegacy(
className="text-sm text-gray-700 dark:text-gray-300" className="text-sm text-gray-700 dark:text-gray-300"
> >
<span className="font-semibold">[{id}]:</span>{' '} <span className="font-semibold">[{id}]:</span>{' '}
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos)}</span> <span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, undefined, emojiLightbox)}</span>
{' '} {' '}
<a <a
href={`#footnote-ref-${id}`} href={`#footnote-ref-${id}`}
@ -3082,6 +3088,7 @@ function parseMarkdownContentMarked(
lazyMedia = true, lazyMedia = true,
resolveImetaForImageUrl resolveImetaForImageUrl
} = options } = options
const emojiLightbox: TInlineEmojiLightbox = { imageIndexMap, openLightbox }
/** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */ /** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */
const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo => const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo =>
@ -3166,7 +3173,7 @@ function parseMarkdownContentMarked(
const txt = String(token.text ?? token.raw ?? '') const txt = String(token.text ?? token.raw ?? '')
collectHashtags(txt) collectHashtags(txt)
out.push( out.push(
...parseInlineMarkdownLegacy(txt, `${key}-text`, footnotes, emojiInfos, navigateToHashtag) ...parseInlineMarkdownLegacy(txt, `${key}-text`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)
) )
break break
} }
@ -3288,7 +3295,7 @@ function parseMarkdownContentMarked(
if (txt) { if (txt) {
collectHashtags(txt) collectHashtags(txt)
out.push( 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) { if (before.trim().length > 0) {
nodes.push( nodes.push(
<p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0"> <p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0">
{parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)} {parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}
</p> </p>
) )
} }
@ -3554,7 +3561,7 @@ function parseMarkdownContentMarked(
if (after.trim().length > 0) { if (after.trim().length > 0) {
nodes.push( nodes.push(
<p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0"> <p key={`${key}-nostr-raw-segment-${segmentIdx++}`} className="mb-1 last:mb-0">
{parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag)} {parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}
</p> </p>
) )
} }
@ -3992,7 +3999,7 @@ function parseMarkdownContentMarked(
{Array.from(footnotes.entries()).map(([id, text]) => ( {Array.from(footnotes.entries()).map(([id, text]) => (
<li key={`footnote-${id}`} id={`footnote-${id}`} className="text-sm text-gray-700 dark:text-gray-300"> <li key={`footnote-${id}`} id={`footnote-${id}`} className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-semibold">[{id}]:</span>{' '} <span className="font-semibold">[{id}]:</span>{' '}
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag)}</span>{' '} <span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}</span>{' '}
<a <a
href={`#footnote-ref-${id}`} href={`#footnote-ref-${id}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs" className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs"
@ -4031,7 +4038,8 @@ function parseInlineMarkdown(
keyPrefix: string, keyPrefix: string,
_footnotes: Map<string, string> = new Map(), _footnotes: Map<string, string> = new Map(),
emojiInfos: TEmoji[] = [], emojiInfos: TEmoji[] = [],
navigateToHashtag?: (href: string) => void navigateToHashtag?: (href: string) => void,
emojiLightbox?: TInlineEmojiLightbox
): React.ReactNode[] { ): React.ReactNode[] {
const normalized = text.replace(/\n/g, ' ').replace(/[ \t]{2,}/g, ' ') const normalized = text.replace(/\n/g, ' ').replace(/[ \t]{2,}/g, ' ')
const tokens = lexInlineProtected(normalized) as any[] const tokens = lexInlineProtected(normalized) as any[]
@ -4039,7 +4047,7 @@ function parseInlineMarkdown(
// Fast path: keep old behavior when there is no markdown syntax. // Fast path: keep old behavior when there is no markdown syntax.
if (!hasMarkdownSyntax) { 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[] => { const renderTokens = (list: any[], path: string): React.ReactNode[] => {
@ -4055,7 +4063,8 @@ function parseInlineMarkdown(
`${keyPrefix}-${tokenKey}-text`, `${keyPrefix}-${tokenKey}-text`,
_footnotes, _footnotes,
emojiInfos, emojiInfos,
navigateToHashtag navigateToHashtag,
emojiLightbox
) )
) )
continue continue
@ -4143,7 +4152,8 @@ function parseInlineMarkdown(
`${keyPrefix}-${tokenKey}-fallback`, `${keyPrefix}-${tokenKey}-fallback`,
_footnotes, _footnotes,
emojiInfos, emojiInfos,
navigateToHashtag navigateToHashtag,
emojiLightbox
) )
) )
} }
@ -4153,7 +4163,7 @@ function parseInlineMarkdown(
const rendered = renderTokens(tokens, `${keyPrefix}-md`) const rendered = renderTokens(tokens, `${keyPrefix}-md`)
return rendered.length > 0 return rendered.length > 0
? rendered ? rendered
: parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag) : parseInlineMarkdownLegacy(normalized, keyPrefix, _footnotes, emojiInfos, navigateToHashtag, emojiLightbox)
} }
function parseInlineMarkdownLegacy( function parseInlineMarkdownLegacy(
@ -4161,7 +4171,8 @@ function parseInlineMarkdownLegacy(
keyPrefix: string, keyPrefix: string,
_footnotes: Map<string, string> = new Map(), _footnotes: Map<string, string> = new Map(),
emojiInfos: TEmoji[] = [], emojiInfos: TEmoji[] = [],
navigateToHashtag?: (href: string) => void navigateToHashtag?: (href: string) => void,
emojiLightbox?: TInlineEmojiLightbox
): React.ReactNode[] { ): React.ReactNode[] {
if (isContentSpacingDebug() && text.includes('nostr:')) { if (isContentSpacingDebug() && text.includes('nostr:')) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -4396,7 +4407,7 @@ function parseInlineMarkdownLegacy(
if (url.startsWith('payto://')) { if (url.startsWith('payto://')) {
parts.push( parts.push(
<PaytoLink key={`${keyPrefix}-payto-link-${i}`} paytoUri={url} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"> <PaytoLink key={`${keyPrefix}-payto-link-${i}`} paytoUri={url} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words">
{parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos)} {parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos, undefined, emojiLightbox)}
</PaytoLink> </PaytoLink>
) )
} else { } else {
@ -4404,7 +4415,9 @@ function parseInlineMarkdownLegacy(
text, text,
`${keyPrefix}-link-${i}`, `${keyPrefix}-link-${i}`,
_footnotes, _footnotes,
emojiInfos emojiInfos,
undefined,
emojiLightbox
) )
parts.push( parts.push(
<a <a
@ -4499,7 +4512,21 @@ function parseInlineMarkdownLegacy(
const shortcode = pattern.data as string const shortcode = pattern.data as string
const custom = emojiInfos.find((e) => e.shortcode === shortcode) const custom = emojiInfos.find((e) => e.shortcode === shortcode)
if (custom) { if (custom) {
parts.push(<Emoji key={`${keyPrefix}-emoji-${i}`} emoji={custom} classNames={{ img: 'size-4 inline-block' }} />) const cleanedUrl = cleanUrl(custom.url)
const lbIdx =
cleanedUrl && emojiLightbox ? emojiLightbox.imageIndexMap.get(cleanedUrl) : undefined
parts.push(
<Emoji
key={`${keyPrefix}-emoji-${i}`}
emoji={custom}
classNames={{ img: 'size-4 inline-block' }}
onImageClick={
typeof lbIdx === 'number' && emojiLightbox
? () => emojiLightbox.openLightbox(lbIdx)
: undefined
}
/>
)
} else { } else {
const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis) const native = shortcodeToEmoji(shortcode, emojis) ?? shortcodeToEmoji(shortcode.replace(/\s+/g, '_'), emojis)
if (native?.emoji) { if (native?.emoji) {
@ -4606,6 +4633,7 @@ export default function MarkdownArticle({
const { navigateToHashtag } = useSmartHashtagNavigationOptional() const { navigateToHashtag } = useSmartHashtagNavigationOptional()
const { navigateToRelay } = useSmartRelayNavigationOptional() const { navigateToRelay } = useSmartRelayNavigationOptional()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const emojiInfos = useEmojiInfosForEvent(event)
const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event]) const iArticleUrl = useMemo(() => getHttpUrlFromITags(event), [event])
const webPreviewSuppressCleanedSet = useMemo(() => { 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 return images
}, [extractedMedia.images, metadata.image]) }, [extractedMedia.images, metadata.image, emojiInfos])
const lightboxSlides = useMemo( const lightboxSlides = useMemo(
() => allImages.map((img) => lightboxSlideFromImeta(img)), () => allImages.map((img) => lightboxSlideFromImeta(img)),
@ -5052,12 +5089,12 @@ export default function MarkdownArticle({
processed = normalizeSetextHeaders(processed) processed = normalizeSetextHeaders(processed)
// Normalize backticks (inline code and code blocks) // Normalize backticks (inline code and code blocks)
processed = normalizeBackticks(processed) processed = normalizeBackticks(processed)
// Replace standard :shortcode: with Unicode (custom emojis stay as shortcode for tag lookup) // Replace standard :shortcode: with Unicode (custom emojis stay as shortcode for tag / profile lookup)
const customShortcodes = event.tags.filter((t) => t[0] === 'emoji').map((t) => t[1]).filter(Boolean) const customShortcodes = emojiInfos.map((e) => e.shortcode)
processed = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) processed = replaceStandardEmojiShortcodesInContent(processed, customShortcodes)
// Then preprocess media links // Then preprocess media links
return preprocessMarkdownMediaLinks(processed) return preprocessMarkdownMediaLinks(processed)
}, [event.content, event.tags]) }, [event.content, emojiInfos])
// Create video poster map from imeta tags // Create video poster map from imeta tags
const videoPosterMap = useMemo(() => { const videoPosterMap = useMemo(() => {
@ -5109,8 +5146,6 @@ export default function MarkdownArticle({
return map return map
}, [event.id, JSON.stringify(event.tags)]) }, [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 // Parse markdown content with post-processing for nostr: links and hashtags
const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { const { nodes: parsedContent, hashtagsInContent } = useMemo(() => {
const resolveImetaForImageUrl = (cleaned: string): TImetaInfo | undefined => { const resolveImetaForImageUrl = (cleaned: string): TImetaInfo | undefined => {

8
src/components/Note/ReactionEmojiDisplay.tsx

@ -1,9 +1,8 @@
import Emoji from '@/components/Emoji' import Emoji from '@/components/Emoji'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { fetchAuthorNip30EmojiInfos } from '@/lib/nip30-author-emojis'
import { resolveReactionEmojiSync } from '@/lib/reaction-display' import { resolveReactionEmojiSync } from '@/lib/reaction-display'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { replaceableEventService } from '@/services/client.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' 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)) if (sync.mode !== 'profile' || (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION))
return return
let cancelled = false let cancelled = false
replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((pe) => { void fetchAuthorNip30EmojiInfos(event.pubkey).then((infos) => {
if (cancelled || !pe) return if (cancelled) return
const infos = getEmojiInfosFromEmojiTags(pe.tags)
const hit = infos.find((i) => i.shortcode === sync.shortcode) const hit = infos.find((i) => i.shortcode === sync.shortcode)
if (hit) setValue(hit) if (hit) setValue(hit)
}) })

18
src/components/NoteList/index.tsx

@ -1285,16 +1285,18 @@ const NoteList = forwardRef(
} }
const profiles = res.value const profiles = res.value
for (const p of profiles) { for (const p of profiles) {
next.set(p.pubkey, p) const pkNorm = p.pubkey.toLowerCase()
pend.delete(p.pubkey) next.set(pkNorm, { ...p, pubkey: pkNorm })
pend.delete(pkNorm)
} }
for (const pk of chunk) { for (const pk of chunk) {
pend.delete(pk) const pkNorm = pk.toLowerCase()
if (!next.has(pk)) { pend.delete(pkNorm)
next.set(pk, { if (!next.has(pkNorm)) {
pubkey: pk, next.set(pkNorm, {
npub: pubkeyToNpub(pk) ?? '', pubkey: pkNorm,
username: formatPubkey(pk), npub: pubkeyToNpub(pkNorm) ?? '',
username: formatPubkey(pkNorm),
batchPlaceholder: true batchPlaceholder: true
}) })
} }

12
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts

@ -37,10 +37,14 @@ function searchStandardEmojiShortcodes(query: string): string[] {
const suggestion = { const suggestion = {
items: async ({ query }: { query: string }) => { items: async ({ query }: { query: string }) => {
const custom = await customEmojiService.searchEmojis(query, client.pubkey ?? null) const customIds = await customEmojiService.searchEmojis(query, client.pubkey ?? null)
const customSet = new Set(custom) const customShortcodes = new Set(
const standard = searchStandardEmojiShortcodes(query).filter((s) => !customSet.has(s)) customIds
return [...custom, ...standard].slice(0, 50) .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: () => { render: () => {

22
src/components/Profile/ProfileHeaderInteractions.tsx

@ -1,4 +1,5 @@
import Content from '@/components/Content' import Content from '@/components/Content'
import ReactionEmojiDisplay from '@/components/Note/ReactionEmojiDisplay'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog' import ProfileBadgeDetailDialog from './ProfileBadgeDetailDialog'
@ -7,8 +8,6 @@ import { formatAmount } from '@/lib/lightning'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toNote, toProfile } from '@/lib/link' import { toNote, toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import Emoji from '@/components/Emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import type { TProfileZap } from '@/hooks/useProfileInteractions' import type { TProfileZap } from '@/hooks/useProfileInteractions'
import type { TProfileBadge } from '@/hooks/useProfileBadges' import type { TProfileBadge } from '@/hooks/useProfileBadges'
import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks' import type { TProfileFollowPack } from '@/hooks/useProfileFollowPacks'
@ -74,10 +73,9 @@ function ZapBadge({ zap }: { zap: TProfileZap }) {
function ReactionBadge({ event }: { event: Event }) { function ReactionBadge({ event }: { event: Event }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags) const raw = event.content.trim()
const displayContent = event.content.trim() || (emojiInfos[0] ? emojiInfos[0].shortcode : '+') const isPlus = raw === '+'
const isPlus = displayContent === '+' const isMinus = raw === '-'
const isMinus = displayContent === '-'
return ( return (
<button <button
type="button" type="button"
@ -89,10 +87,10 @@ function ReactionBadge({ event }: { event: Event }) {
<ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden /> <ThumbsUp className="size-3 shrink-0 text-primary" aria-hidden />
) : isMinus ? ( ) : isMinus ? (
<ThumbsDown className="size-3 shrink-0 text-muted-foreground" aria-hidden /> <ThumbsDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
) : typeof displayContent === 'string' && !displayContent.startsWith(':') ? ( ) : raw && !raw.startsWith(':') ? (
<span className="text-xs shrink-0">{displayContent}</span> <span className="text-xs shrink-0">{raw}</span>
) : ( ) : (
<Emoji emoji={emojiInfos[0] ?? displayContent} classNames={{ img: 'size-3' }} /> <ReactionEmojiDisplay event={event} variant="compact" maxRawLength={64} className="shrink-0" />
)} )}
<Username userId={event.pubkey} className="truncate text-xs text-muted-foreground min-w-0" skeletonClassName="h-3" /> <Username userId={event.pubkey} className="truncate text-xs text-muted-foreground min-w-0" skeletonClassName="h-3" />
</button> </button>
@ -110,7 +108,11 @@ function CommentBadge({ event }: { event: Event }) {
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" /> <UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<MessageCircle className="size-3 shrink-0 text-primary" aria-hidden /> <MessageCircle className="size-3 shrink-0 text-primary" aria-hidden />
<span className="truncate text-xs text-muted-foreground min-w-0"> <span className="truncate text-xs text-muted-foreground min-w-0">
<Content content={event.content} className="text-xs [&_p]:text-xs [&_p]:m-0 [&_p]:inline" /> <Content
event={event}
content={event.content}
className="text-xs [&_p]:text-xs [&_p]:m-0 [&_p]:inline"
/>
</span> </span>
</button> </button>
) )

18
src/components/ReplyNoteList/index.tsx

@ -696,16 +696,18 @@ function ReplyNoteList({
} }
const profiles = res.value const profiles = res.value
for (const p of profiles) { for (const p of profiles) {
next.set(p.pubkey, p) const pkNorm = p.pubkey.toLowerCase()
pend.delete(p.pubkey) next.set(pkNorm, { ...p, pubkey: pkNorm })
pend.delete(pkNorm)
} }
for (const pk of chunk) { for (const pk of chunk) {
pend.delete(pk) const pkNorm = pk.toLowerCase()
if (!next.has(pk)) { pend.delete(pkNorm)
next.set(pk, { if (!next.has(pkNorm)) {
pubkey: pk, next.set(pkNorm, {
npub: pubkeyToNpub(pk) ?? '', pubkey: pkNorm,
username: formatPubkey(pk) npub: pubkeyToNpub(pkNorm) ?? '',
username: formatPubkey(pkNorm)
}) })
} }
} }

7
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`). */ /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */
export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS 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). */ /** `SimplePool.ensureRelay` WebSocket handshake timeout (parallel multi-relay + slow TLS). */
export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000 export const RELAY_POOL_CONNECTION_TIMEOUT_MS = 20_000

1
src/contexts/primary-note-view-context.tsx

@ -12,6 +12,7 @@ export type TPrimaryOverlayViewType =
| 'bookmarks' | 'bookmarks'
| 'pins' | 'pins'
| 'interests' | 'interests'
| 'user-emojis'
| 'others-relay-settings' | 'others-relay-settings'
export type PrimaryNoteViewContextValue = { export type PrimaryNoteViewContextValue = {

1
src/hooks/index.tsx

@ -8,3 +8,4 @@ export * from './useFetchRelayInfo'
export * from './useFetchRelayList' export * from './useFetchRelayList'
export * from './useSearchProfiles' export * from './useSearchProfiles'
export * from './useMediaExtraction' export * from './useMediaExtraction'
export * from './useEmojiInfosForEvent'

40
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)
})
})

85
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<string, TEmoji>()
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 authors 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<TEmoji[]>([])
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]
)
}

37
src/hooks/useFetchProfile.tsx

@ -364,10 +364,45 @@ export function useFetchProfile(id?: string, skipCache = false) {
return return
} }
if (noteFeed.pendingPubkeys.has(extractedPubkey)) { 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) setPubkey(extractedPubkey)
setIsFetching(false) setIsFetching(false)
setError(null) 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)
}
}
} }
} }

36
src/i18n/locales/en.ts

@ -1628,12 +1628,46 @@ export default {
'Follow sets': 'Follow sets', 'Follow sets': 'Follow sets',
'Personal Lists': 'Personal Lists', 'Personal Lists': 'Personal Lists',
'Personal lists hub intro': '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', 'Mute list': 'Mute list',
'Following list': 'Following list', 'Following list': 'Following list',
'Bookmarks list': 'Bookmarks list', 'Bookmarks list': 'Bookmarks list',
'Pinned notes list': 'Pinned notes list', 'Pinned notes list': 'Pinned notes list',
'Interests list': 'Interests 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>:<d tag>, 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:<pubkey>:<d>.',
'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': 'Interests list section subtitle':
'Topics you follow for hashtag feeds and the Interests spell. Stored on Nostr as kind 10015 (`t` tags).', 'Topics you follow for hashtag feeds and the Interests spell. Stored on Nostr as kind 10015 (`t` tags).',
'Interest topic placeholder': 'topic or #hashtag', 'Interest topic placeholder': 'topic or #hashtag',

33
src/i18n/locales/zh.ts

@ -1558,7 +1558,38 @@ export default {
'Follow sets': 'Follow sets', 'Follow sets': 'Follow sets',
'Personal Lists': '个人列表', 'Personal Lists': '个人列表',
'Personal lists hub intro': '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 位十六进制公钥>:<d 标签>,通常为本账户在「表情包」页面创建的集合。',
'User emoji sets empty': '尚无表情包引用。',
'User emoji set ref invalid': '坐标无效。请使用 30030:<公钥>:<d>。',
'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': '静音列表', 'Mute list': '静音列表',
'Following list': '关注列表', 'Following list': '关注列表',
'Bookmarks spell': '书签咒语', 'Bookmarks spell': '书签咒语',

43
src/lib/draft-event.ts

@ -30,6 +30,7 @@ import {
getArticleUrlFromCommentITags, getArticleUrlFromCommentITags,
NIP22_URL_SCOPE_KIND NIP22_URL_SCOPE_KIND
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip' import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random' 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 { export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
return { return {
kind: kinds.Metadata, kind: kinds.Metadata,
@ -1347,19 +1366,21 @@ function extractImagesFromContent(content: string) {
export function transformCustomEmojisInContent(content: string) { export function transformCustomEmojisInContent(content: string) {
const emojiTags: string[][] = [] const emojiTags: string[][] = []
let processedContent = content let processedContent = content
const matches = content.match(/:[a-zA-Z0-9]+:/g) const seen = new Set<string>()
const re = new RegExp(EMOJI_SHORT_CODE_REGEX.source, 'g')
const emojiIdSet = new Set<string>() let m: RegExpExecArray | null
matches?.forEach((m) => { while ((m = re.exec(content)) !== null) {
if (emojiIdSet.has(m)) return const full = m[0]
emojiIdSet.add(m) 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) { if (emoji) {
emojiTags.push(buildEmojiTag(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 { return {
emojiTags, 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) { export function buildATag(event: Event, upperCase: boolean = false) {
const coordinate = getReplaceableCoordinateFromEvent(event) const coordinate = getReplaceableCoordinateFromEvent(event)
const hint = client.getEventHint(event.id) const hint = client.getEventHint(event.id)

100
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:<hex64>:<d>` 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<string, Event>()
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' })
)
}

7
src/lib/event-metadata.ts

@ -712,7 +712,12 @@ export function getEmojisAndEmojiSetsFromEvent(event: Event) {
url: tagValues[1] url: tagValues[1]
}) })
} else if (tagName === 'a' && tagValues[0]) { } 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])
}
} }
}) })

2
src/lib/link.ts

@ -70,6 +70,7 @@ export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation' export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds' export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toFollowSetsSettings = () => '/settings/follow-sets' export const toFollowSetsSettings = () => '/settings/follow-sets'
export const toEmojiSetsSettings = () => '/settings/emoji-sets'
export const toCacheSettings = () => '/settings/cache' export const toCacheSettings = () => '/settings/cache'
export const toPersonalListsSettings = () => '/settings/personal-lists' export const toPersonalListsSettings = () => '/settings/personal-lists'
export const toProfileEditor = () => '/profile-editor' export const toProfileEditor = () => '/profile-editor'
@ -81,6 +82,7 @@ export const toBookmarksList = () => '/bookmarks'
export const toPinsList = () => '/pins' export const toPinsList = () => '/pins'
export const toInterestsList = () => '/interests' export const toInterestsList = () => '/interests'
export const toUserEmojiList = () => '/user-emojis'
export const toChachiChat = (relay: string, d: string) => { export const toChachiChat = (relay: string, d: string) => {
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}` return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`

119
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<string, TEmoji>, 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<Event[]> {
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 pickers inventory fetch).
*/
async function emojiInfosFromAuthorEvents(events: Event[], pk: string): Promise<TEmoji[]> {
const byShortcode = new Map<string, TEmoji>()
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<TEmoji[]> {
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<TEmoji[]> {
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<string, Promise<TEmoji[]>>()
const inflightAuthorEmojiIdb = new Map<string, Promise<TEmoji[]>>()
export function fetchAuthorNip30EmojiInfos(pubkey: string): Promise<TEmoji[]> {
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<TEmoji[]> {
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
}

525
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<Event[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [editing, setEditing] = useState<Event | null>(null)
const [formD, setFormD] = useState('')
const [formTitle, setFormTitle] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formImage, setFormImage] = useState('')
const [formEmojis, setFormEmojis] = useState<TEmoji[]>([])
const [newShortcode, setNewShortcode] = useState('')
const [newUrl, setNewUrl] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Event | null>(null)
const [deleting, setDeleting] = useState(false)
const [cleanTarget, setCleanTarget] = useState<Event | null>(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 (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Emoji sets')}
hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void loadLists()} />}
displayScrollToTopButton
>
<div className="min-w-0 space-y-4 px-4 pb-8 pt-2">
<p className="text-sm text-muted-foreground leading-relaxed">{t('Emoji sets settings intro')}</p>
{!pubkey ? (
<p className="text-sm text-muted-foreground">{t('Login to set')}</p>
) : (
<>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={openNew} className="gap-2">
<Plus className="size-4" />
{t('New emoji set')}
</Button>
</div>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : lists.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('No emoji sets yet')}</p>
) : (
<ul className="space-y-2">
{lists.map((ev) => (
<li
key={extractEmojiSetEditorFields(ev).d}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<Sticker className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium">{labelEmojiSetEvent(ev)}</div>
<div className="truncate text-xs text-muted-foreground">
{extractEmojiSetEditorFields(ev).emojis.length} {t('emoji entries')}
<span className="mx-1">·</span>
<code className="text-[11px]">d={extractEmojiSetEditorFields(ev).d}</code>
</div>
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCleanTarget(ev)}
title={t('Clean list')}
className="text-destructive hover:text-destructive"
>
<Eraser className="size-4" />
<span className="sr-only">{t('Clean list')}</span>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openEdit(ev)}
title={t('Edit')}
>
<Pencil className="size-4" />
<span className="sr-only">{t('Edit')}</span>
</Button>
{canSignEvents && ev.pubkey === pubkey ? (
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(ev)}
title={t('Delete')}
>
<Trash2 className="size-4" />
<span className="sr-only">{t('Delete')}</span>
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
</>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent className="max-h-[min(90dvh,36rem)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? t('Edit emoji set') : t('New emoji set')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label htmlFor="emoji-set-d">{t('List id (d tag)')}</Label>
<Input
id="emoji-set-d"
value={formD}
onChange={(e) => setFormD(e.target.value)}
disabled={!!editing}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">{t('Emoji set d tag hint')}</p>
</div>
<div className="space-y-1">
<Label htmlFor="emoji-set-title">{t('Title')}</Label>
<Input
id="emoji-set-title"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder={t('Optional display title')}
/>
</div>
<div className="space-y-1">
<Label htmlFor="emoji-set-desc">{t('Description')}</Label>
<Textarea
id="emoji-set-desc"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={2}
placeholder={t('Optional')}
/>
</div>
<div className="space-y-1">
<Label htmlFor="emoji-set-image">{t('Image URL')}</Label>
<Input
id="emoji-set-image"
value={formImage}
onChange={(e) => setFormImage(e.target.value)}
placeholder="https://…"
/>
</div>
<div className="space-y-2">
<Label>{t('Emoji pack entries')}</Label>
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-md border border-border/80 p-2">
{formEmojis.length === 0 ? (
<li className="text-sm text-muted-foreground">{t('No emoji entries in pack')}</li>
) : (
formEmojis.map((em, idx) => (
<li key={`${em.shortcode}-${idx}`} className="flex items-center justify-between gap-2 text-sm">
<span className="min-w-0 truncate">
<code>:{em.shortcode}:</code>{' '}
<span className="text-muted-foreground">{em.url}</span>
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-destructive"
onClick={() => setFormEmojis((prev) => prev.filter((_, j) => j !== idx))}
>
{t('Remove')}
</Button>
</li>
))
)}
</ul>
<form onSubmit={addEmojiRow} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="grid flex-1 gap-2 sm:grid-cols-2">
<Input
value={newShortcode}
onChange={(e) => setNewShortcode(e.target.value)}
placeholder={t('Shortcode')}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder={t('Image URL')}
/>
</div>
<Button type="submit" variant="secondary">
{t('Add')}
</Button>
</form>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={closeDialog}>
{t('Cancel')}
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={saving || !formD.trim()}>
{saving ? t('loading...') : t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Delete emoji set?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Delete emoji set confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
onClick={(e) => {
e.preventDefault()
void handleConfirmDelete()
}}
>
{deleting ? t('loading...') : t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!cleanTarget} onOpenChange={(o) => !o && setCleanTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleConfirmClean()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SecondaryPageLayout>
)
}
)
EmojiSetsSettingsPage.displayName = 'EmojiSetsSettingsPage'
export default EmojiSetsSettingsPage

28
src/pages/secondary/PersonalListsSettingsPage/index.tsx

@ -9,23 +9,26 @@ import {
useSmartInterestListNavigation, useSmartInterestListNavigation,
useSmartMuteListNavigation, useSmartMuteListNavigation,
useSmartPinListNavigation, useSmartPinListNavigation,
useSmartSettingsNavigation useSmartSettingsNavigation,
useSmartUserEmojiListNavigation
} from '@/PageManager' } from '@/PageManager'
import { import {
toBookmarksList, toBookmarksList,
toEmojiSetsSettings,
toFollowSetsSettings, toFollowSetsSettings,
toFollowingList, toFollowingList,
toInterestsList, toInterestsList,
toMuteList, toMuteList,
toPinsList toPinsList,
toUserEmojiList
} from '@/lib/link' } from '@/lib/link'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Bookmark, ChevronRight, Hash, Pin, Users, VolumeX } from 'lucide-react' import { Bookmark, ChevronRight, Hash, Pin, Smile, Sticker, Users, VolumeX } from 'lucide-react'
import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react' import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
/** /**
* Hub for Nostr personal lists (mute list, follows, NIP-51 bookmarks, pins, interest topics) not the same as NIP-B0 web bookmarks. * Hub for Nostr personal lists (mute list, follows, NIP-51 bookmarks, pins, interest topics, NIP-30 emoji list & sets) not the same as NIP-B0 web bookmarks.
*/ */
const PersonalListsSettingsPage = forwardRef( const PersonalListsSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
@ -38,6 +41,7 @@ const PersonalListsSettingsPage = forwardRef(
const { navigateToBookmarkList } = useSmartBookmarkListNavigation() const { navigateToBookmarkList } = useSmartBookmarkListNavigation()
const { navigateToPinList } = useSmartPinListNavigation() const { navigateToPinList } = useSmartPinListNavigation()
const { navigateToInterestList } = useSmartInterestListNavigation() const { navigateToInterestList } = useSmartInterestListNavigation()
const { navigateToUserEmojiList } = useSmartUserEmojiListNavigation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) const bump = useCallback(() => setContentKey((k) => k + 1), [])
@ -106,6 +110,22 @@ const PersonalListsSettingsPage = forwardRef(
<ChevronRight /> <ChevronRight />
</SettingRow> </SettingRow>
) : null} ) : null}
{pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToUserEmojiList(toUserEmojiList())}>
<div className="flex items-center gap-3">
<Smile />
<div>{t('User emoji list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
<SettingRow className="clickable" onClick={() => navigateToSettings(toEmojiSetsSettings())}>
<div className="flex items-center gap-3">
<Sticker />
<div>{t('Emoji sets')}</div>
</div>
<ChevronRight />
</SettingRow>
<SettingRow className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}> <SettingRow className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Users /> <Users />

470
src/pages/secondary/UserEmojiListPage/index.tsx

@ -0,0 +1,470 @@
import JsonViewDialog from '@/components/JsonViewDialog'
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createUserEmojiListDraftEvent } from '@/lib/draft-event'
import {
isEmojiSetPointerTag,
normalizeEmojiSetATagValue,
preservedTagsFromUserEmojiListEvent
} from '@/lib/emoji-set-editor'
import { getEmojisFromEvent } from '@/lib/event-metadata'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { showPublishingError } from '@/lib/publishing-feedback'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import indexedDb from '@/services/indexed-db.service'
import dayjs from 'dayjs'
import { Code, Eraser, MoreVertical, Trash2 } from 'lucide-react'
import { kinds } from 'nostr-tools'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage'
function normalizeShortcode(raw: string): string {
return raw.trim().replace(/^:+|:+$/gu, '')
}
function parseEditorState(ev: Event | null): { inline: { shortcode: string; url: string }[]; aRefs: string[][] } {
if (!ev) return { inline: [], aRefs: [] }
return {
inline: getEmojisFromEvent(ev),
aRefs: ev.tags.filter((t) => isEmojiSetPointerTag(t)).map((t) => [...t])
}
}
const UserEmojiListPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey, publish, checkLogin, updateUserEmojiListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [listEvent, setListEvent] = useState<Event | null>(null)
const [inlineEmojis, setInlineEmojis] = useState<{ shortcode: string; url: string }[]>([])
const [setATags, setSetATags] = useState<string[][]>([])
const [newShortcode, setNewShortcode] = useState('')
const [newUrl, setNewUrl] = useState('')
const [newSetRef, setNewSetRef] = useState('')
const [publishing, setPublishing] = useState(false)
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const loadList = useCallback(async () => {
if (!pubkey) {
setListEvent(null)
setInlineEmojis([])
setSetATags([])
return
}
let cached: Event | null | undefined
try {
cached = (await indexedDb.getReplaceableEvent(pubkey, kinds.UserEmojiList)) ?? undefined
} catch {
cached = undefined
}
const relays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const fromNet = await fetchLatestReplaceableListEvent(pubkey, kinds.UserEmojiList, relays)
const best =
!cached && fromNet
? fromNet
: cached && !fromNet
? cached
: cached && fromNet
? fromNet.created_at >= cached.created_at
? fromNet
: cached
: null
setListEvent(best ?? null)
const parsed = parseEditorState(best ?? null)
setInlineEmojis(parsed.inline)
setSetATags(parsed.aRefs)
if (best) {
try {
await indexedDb.putReplaceableEvent(best)
} catch {
/* ignore */
}
}
}, [pubkey, favoriteRelays, blockedRelays])
useEffect(() => {
void loadList()
}, [loadList])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => {
void loadList()
})
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, loadList])
const buildTagsForPublish = useCallback((): string[][] => {
const preserved = preservedTagsFromUserEmojiListEvent(listEvent)
const emojiTags = inlineEmojis
.map((e) => {
const sc = normalizeShortcode(e.shortcode)
const url = e.url.trim()
if (!sc || !url) return null
return ['emoji', sc, url] as string[]
})
.filter((row): row is string[] => row != null)
return [...preserved, ...emojiTags, ...setATags]
}, [listEvent, inlineEmojis, setATags])
const dirty = useMemo(() => {
const cur = parseEditorState(listEvent)
const sameInline =
cur.inline.length === inlineEmojis.length &&
cur.inline.every(
(e, i) =>
normalizeShortcode(e.shortcode) === normalizeShortcode(inlineEmojis[i]?.shortcode ?? '') &&
e.url.trim() === (inlineEmojis[i]?.url ?? '').trim()
)
const key = (rows: string[][]) =>
[...rows]
.map((r) => r.slice(0, 3).join('|'))
.sort()
.join('\n')
const sameA = key(cur.aRefs) === key(setATags)
return !sameInline || !sameA
}, [listEvent, inlineEmojis, setATags])
const publishList = async () => {
await checkLogin(async () => {
if (!pubkey) return
setPublishing(true)
try {
let createdAt = dayjs().unix()
if (listEvent && createdAt === listEvent.created_at) {
await new Promise((r) => setTimeout(r, 1100))
createdAt = dayjs().unix()
}
const tags = buildTagsForPublish()
const content = listEvent?.content ?? ''
const draft = createUserEmojiListDraftEvent(tags, content, createdAt)
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
setListEvent(published as Event)
await updateUserEmojiListEvent(published as Event)
const parsed = parseEditorState(published as Event)
setInlineEmojis(parsed.inline)
setSetATags(parsed.aRefs)
toast.success(t('User emoji list saved'))
} catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally {
setPublishing(false)
}
})
}
const addInlineEmoji = (e: React.FormEvent) => {
e.preventDefault()
const sc = normalizeShortcode(newShortcode)
const url = newUrl.trim()
if (!sc || !url) {
toast.error(t('User emoji inline invalid'))
return
}
setInlineEmojis((prev) => [...prev, { shortcode: sc, url }])
setNewShortcode('')
setNewUrl('')
}
const addSetRef = (e: React.FormEvent) => {
e.preventDefault()
const norm = normalizeEmojiSetATagValue(newSetRef)
if (!norm) {
toast.error(t('User emoji set ref invalid'))
return
}
const nextTag = ['a', norm]
const seen = new Set(
setATags.map((t) => (t[1] ?? '').toLowerCase())
)
if (seen.has(norm.toLowerCase())) {
toast.error(t('User emoji set ref duplicate'))
return
}
setSetATags((prev) => [...prev, nextTag])
setNewSetRef('')
}
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
await checkLogin(async () => {
setCleaning(true)
try {
if (dayjs().unix() === listEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const preserved = preservedTagsFromUserEmojiListEvent(listEvent)
let createdAt = dayjs().unix()
if (listEvent && createdAt === listEvent.created_at) {
await new Promise((r) => setTimeout(r, 1100))
createdAt = dayjs().unix()
}
const draft = createUserEmojiListDraftEvent(preserved, listEvent?.content ?? '', createdAt)
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
setListEvent(published as Event)
await updateUserEmojiListEvent(published as Event)
setInlineEmojis([])
setSetATags([])
toast.success(t('List cleaned'))
} catch (e) {
toast.error(t('Failed to clean list') + ': ' + (e instanceof Error ? e.message : String(e)))
} finally {
setCleaning(false)
setCleanConfirmOpen(false)
}
})
}, [
pubkey,
cleaning,
listEvent,
favoriteRelays,
blockedRelays,
publish,
updateUserEmojiListEvent,
checkLogin,
t
])
const openJson = useCallback(() => {
setJsonPayload({
listEvent: listEvent ?? null,
editing: { inlineEmojis, setATags },
note: 'Kind 10030: `emoji` tags (shortcode, URL) and `a` tags pointing at kind 30030 emoji sets.'
})
setJsonOpen(true)
}, [listEvent, inlineEmojis, setATags])
if (!profile || !pubkey) {
return <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={
hideTitlebar
? undefined
: t('User emoji list title', {
username: profile.username,
defaultValue: `${profile.username}'s emoji list`
})
}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={() => void loadList()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openJson()}>
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(ev) => {
ev.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="min-w-0 space-y-6 px-4 pb-8 pt-2">
<p className="text-sm text-muted-foreground leading-relaxed">{t('User emoji list intro')}</p>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('User emoji inline section')}</h3>
<ul className="space-y-2">
{inlineEmojis.length === 0 ? (
<li className="text-sm text-muted-foreground">{t('User emoji inline empty')}</li>
) : (
inlineEmojis.map((row, i) => (
<li
key={`${row.shortcode}-${i}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-2"
>
<div className="min-w-0">
<code className="text-sm">:{row.shortcode}:</code>
<div className="truncate text-xs text-muted-foreground">{row.url}</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
onClick={() => setInlineEmojis((prev) => prev.filter((_, j) => j !== i))}
aria-label={t('Remove')}
>
<Trash2 className="size-4" />
</Button>
</li>
))
)}
</ul>
<form onSubmit={addInlineEmoji} className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
<div className="grid flex-1 gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="ue-short">{t('Shortcode')}</Label>
<Input
id="ue-short"
value={newShortcode}
onChange={(ev) => setNewShortcode(ev.target.value)}
placeholder="chad_yes"
autoComplete="off"
/>
</div>
<div className="space-y-1">
<Label htmlFor="ue-url">{t('Image URL')}</Label>
<Input
id="ue-url"
value={newUrl}
onChange={(ev) => setNewUrl(ev.target.value)}
placeholder="https://…"
autoComplete="off"
/>
</div>
</div>
<Button type="submit" variant="secondary">
{t('Add')}
</Button>
</form>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('User emoji sets section')}</h3>
<p className="text-xs text-muted-foreground">{t('User emoji sets hint')}</p>
<ul className="space-y-2">
{setATags.length === 0 ? (
<li className="text-sm text-muted-foreground">{t('User emoji sets empty')}</li>
) : (
setATags.map((tag, i) => (
<li
key={`${tag[1] ?? i}-${i}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-2"
>
<code className="break-all text-xs">{tag[1]}</code>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:text-destructive"
onClick={() => setSetATags((prev) => prev.filter((_, j) => j !== i))}
aria-label={t('Remove')}
>
<Trash2 className="size-4" />
</Button>
</li>
))
)}
</ul>
<form onSubmit={addSetRef} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor="ue-aref">{t('Emoji set coordinate')}</Label>
<Input
id="ue-aref"
value={newSetRef}
onChange={(ev) => setNewSetRef(ev.target.value)}
placeholder="30030:…"
className="font-mono text-sm"
autoComplete="off"
/>
</div>
<Button type="submit" variant="secondary">
{t('Add')}
</Button>
</form>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<Button type="button" disabled={!dirty || publishing} onClick={() => void publishList()}>
{publishing ? t('loading...') : t('Publish changes')}
</Button>
</div>
</div>
</SecondaryPageLayout>
)
}
)
UserEmojiListPage.displayName = 'UserEmojiListPage'
export default UserEmojiListPage

16
src/providers/NostrProvider/index.tsx

@ -785,8 +785,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
void customEmojiService.init(null, null) void customEmojiService.init(null, null)
return return
} }
void customEmojiService.init(userEmojiListEvent, account.pubkey) void customEmojiService.init(userEmojiListEvent, account.pubkey, profileEvent)
}, [userEmojiListEvent, account?.pubkey]) }, [userEmojiListEvent, account?.pubkey, profileEvent])
/** /**
* If session restore temporarily fell back to read-only (`npub`) while the stored * If session restore temporarily fell back to read-only (`npub`) while the stored
@ -1533,6 +1533,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setInterestListEvent(stored) setInterestListEvent(stored)
} }
const updateUserEmojiListEvent = async (ev: Event) => {
try {
await indexedDb.putReplaceableEvent(ev)
} catch (e) {
logger.warn('[NostrProvider] updateUserEmojiListEvent: putReplaceableEvent failed', { error: e })
}
void replaceableEventService.updateReplaceableEventCache(ev).catch(() => {})
/** Same as profile: keep the event we just published in UI even if IDB keeps an older winner for the coordinate. */
setUserEmojiListEvent(ev)
}
const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => { const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
const stored = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) const stored = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
/** Always sync UI to IndexedDB winner (same-second updates must not leave stale list + relay sets). */ /** Always sync UI to IndexedDB winner (same-second updates must not leave stale list + relay sets). */
@ -1609,6 +1620,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateMuteListEvent, updateMuteListEvent,
updateBookmarkListEvent, updateBookmarkListEvent,
updateInterestListEvent, updateInterestListEvent,
updateUserEmojiListEvent,
updateFavoriteRelaysEvent, updateFavoriteRelaysEvent,
updateBlockedRelaysEvent, updateBlockedRelaysEvent,
updateRssFeedListEvent, updateRssFeedListEvent,

1
src/providers/nostr-context.tsx

@ -63,6 +63,7 @@ export type TNostrContext = {
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void> updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void> updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void> updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
updateUserEmojiListEvent: (userEmojiListEvent: Event) => Promise<void>
/** /**
* Re-run the full account network hydrate (relay lists + replaceable merge + prewarm), bypassing the * Re-run the full account network hydrate (relay lists + replaceable merge + prewarm), bypassing the
* 24h throttle. Resolves when the hydrate pass finishes. No-op when logged out. * 24h throttle. Resolves when the hydrate pass finishes. No-op when logged out.

4
src/routes.tsx

@ -29,6 +29,8 @@ const CacheSettingsPageLazy = lazy(() => import('./pages/secondary/CacheSettings
const RssFeedSettingsPageLazy = lazy(() => import('./pages/secondary/RssFeedSettingsPage')) const RssFeedSettingsPageLazy = lazy(() => import('./pages/secondary/RssFeedSettingsPage'))
const FollowSetsSettingsPageLazy = lazy(() => import('./pages/secondary/FollowSetsSettingsPage')) const FollowSetsSettingsPageLazy = lazy(() => import('./pages/secondary/FollowSetsSettingsPage'))
const PersonalListsSettingsPageLazy = lazy(() => import('./pages/secondary/PersonalListsSettingsPage')) const PersonalListsSettingsPageLazy = lazy(() => import('./pages/secondary/PersonalListsSettingsPage'))
const UserEmojiListPageLazy = lazy(() => import('./pages/secondary/UserEmojiListPage'))
const EmojiSetsSettingsPageLazy = lazy(() => import('./pages/secondary/EmojiSetsSettingsPage'))
const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage')) const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage'))
const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage')) const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage')) const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage'))
@ -85,12 +87,14 @@ const ROUTES = [
{ path: '/settings/translation', element: SR(TranslationPageLazy) }, { path: '/settings/translation', element: SR(TranslationPageLazy) },
{ path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) }, { path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) },
{ path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) }, { path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) },
{ path: '/settings/emoji-sets', element: SR(EmojiSetsSettingsPageLazy) },
{ path: '/settings/personal-lists', element: SR(PersonalListsSettingsPageLazy) }, { path: '/settings/personal-lists', element: SR(PersonalListsSettingsPageLazy) },
{ path: '/profile-editor', element: SR(ProfileEditorPageLazy) }, { path: '/profile-editor', element: SR(ProfileEditorPageLazy) },
{ path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/bookmarks', element: SR(BookmarkListPageLazy) }, { path: '/bookmarks', element: SR(BookmarkListPageLazy) },
{ path: '/pins', element: SR(PinListPageLazy) }, { path: '/pins', element: SR(PinListPageLazy) },
{ path: '/interests', element: SR(InterestListPageLazy) }, { path: '/interests', element: SR(InterestListPageLazy) },
{ path: '/user-emojis', element: SR(UserEmojiListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) } { path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }
] ]

25
src/services/client-query.service.ts

@ -7,6 +7,7 @@ import {
SOCIAL_KIND_BLOCKED_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_RELAY_CONNECTIONS,
MAX_CONCURRENT_SUBS_PER_RELAY, MAX_CONCURRENT_SUBS_PER_RELAY,
RELAY_FILTER_MAX_KINDS_PER_OBJECT,
RELAY_POOL_CONNECTION_TIMEOUT_MS, RELAY_POOL_CONNECTION_TIMEOUT_MS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
@ -87,9 +88,28 @@ function sanitizeETagFilter(filter: Filter): Filter | null {
return f return f
} }
/** Relays often cap kinds-per-filter; duplicate the filter with chunked `kinds` so REQs are not dropped. */
function splitFiltersByMaxKindCount(filters: Filter[]): Filter[] {
const max = RELAY_FILTER_MAX_KINDS_PER_OBJECT
if (max <= 0) return filters
const out: Filter[] = []
for (const f of filters) {
const k = f.kinds
if (!Array.isArray(k) || k.length <= max) {
out.push(f)
continue
}
for (let i = 0; i < k.length; i += max) {
out.push({ ...f, kinds: k.slice(i, i + max) })
}
}
return out
}
function sanitizeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] { function sanitizeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] {
const asArray = Array.isArray(filter) ? filter : [filter] const asArray = Array.isArray(filter) ? filter : [filter]
return asArray.map(sanitizeETagFilter).filter((f): f is Filter => !!f) const sanitized = asArray.map(sanitizeETagFilter).filter((f): f is Filter => !!f)
return splitFiltersByMaxKindCount(sanitized)
} }
export interface QueryOptions { export interface QueryOptions {
@ -255,8 +275,9 @@ export class QueryService {
): Promise<NEvent[]> { ): Promise<NEvent[]> {
const sanitizedFilters = sanitizeFiltersBeforeReq(filter) const sanitizedFilters = sanitizeFiltersBeforeReq(filter)
if (sanitizedFilters.length === 0) return [] if (sanitizedFilters.length === 0) return []
/** One chunk → pass a single Filter (compat); several (e.g. kinds split) → full array for WS + HTTP. */
const effectiveFilter: Filter | Filter[] = const effectiveFilter: Filter | Filter[] =
Array.isArray(filter) ? sanitizedFilters : sanitizedFilters[0] sanitizedFilters.length === 1 ? sanitizedFilters[0]! : sanitizedFilters
const eoseTimeout = options?.eoseTimeout ?? 500 const eoseTimeout = options?.eoseTimeout ?? 500
const globalTimeout = options?.globalTimeout ?? 10000 const globalTimeout = options?.globalTimeout ?? 10000
const replaceableRace = options?.replaceableRace ?? false const replaceableRace = options?.replaceableRace ?? false

21
src/services/client.service.ts

@ -3720,22 +3720,21 @@ class ClientService extends EventTarget {
*/ */
async fetchEmojiSetEvents(pointers: string[]): Promise<NEvent[]> { async fetchEmojiSetEvents(pointers: string[]): Promise<NEvent[]> {
if (!pointers?.length) return [] if (!pointers?.length) return []
const out: NEvent[] = [] const tasks = pointers.map(async (coord) => {
for (const coord of pointers) {
const parts = coord.split(':') const parts = coord.split(':')
if (parts.length < 3) continue if (parts.length < 3) return null
const kind = parseInt(parts[0]!, 10) const kind = parseInt(parts[0]!, 10)
const authorPk = parts[1]?.trim().toLowerCase() const authorPk = parts[1]?.trim().toLowerCase()
if (!authorPk || Number.isNaN(kind)) continue if (!authorPk || Number.isNaN(kind)) return null
const d = parts.slice(2).join(':') const d = parts.slice(2).join(':')
try { try {
const ev = await this.replaceableEventService.fetchReplaceableEvent(authorPk, kind, d) return (await this.replaceableEventService.fetchReplaceableEvent(authorPk, kind, d)) ?? null
if (ev) out.push(ev)
} catch { } catch {
/* ignore per-pointer failures */ return null
} }
} })
return out const settled = await Promise.all(tasks)
return settled.filter((ev): ev is NEvent => Boolean(ev))
} }
/** /**
@ -3754,9 +3753,9 @@ class ClientService extends EventTarget {
const capped = urls.slice(0, 20) const capped = urls.slice(0, 20)
if (capped.length === 0) return [] if (capped.length === 0) return []
return this.queryService.fetchEvents(capped, { return this.queryService.fetchEvents(capped, {
kinds: [kinds.UserEmojiList, kinds.Emojisets], kinds: [kinds.Metadata, kinds.UserEmojiList, kinds.Emojisets],
authors: [pk], authors: [pk],
limit: 80 limit: 120
}) })
} }

52
src/services/custom-emoji.service.ts

@ -41,16 +41,32 @@ class CustomEmojiService {
} }
/** /**
* Load NIP-30 emoji sets (kind 10030) and packs (30030) for the account. * Load NIP-30 custom emoji for the account: kind 0 `emoji` tags, kind 10030 list (+ `a` 30030 sets), and 30030 packs.
* Merges `userEmojiListEvent` with a relay fetch so we still load when hydrate missed the event * Merges `userEmojiListEvent` / `metadataEvent` with a relay fetch so we still load when hydrate missed events.
* (same idea as aitherboards picker: fetch author emoji kinds from read relays).
*/ */
async init(userEmojiListEvent: Event | null, accountPubkey?: string | null) { async init(
userEmojiListEvent: Event | null,
accountPubkey?: string | null,
metadataEvent?: Event | null,
/** Events we just published (or must win over a slow relay fetch), merged before inventory fetch. */
seedEvents?: Event[] | null
) {
this.reset() this.reset()
const pk = accountPubkey?.trim().toLowerCase() ?? '' const pk = accountPubkey?.trim().toLowerCase() ?? ''
const hasPk = /^[0-9a-f]{64}$/.test(pk) const hasPk = /^[0-9a-f]{64}$/.test(pk)
const byId = new Map<string, Event>() const byId = new Map<string, Event>()
for (const ev of seedEvents ?? []) {
if (!ev?.id || !hasPk) continue
if (ev.pubkey.trim().toLowerCase() !== pk) continue
if (
ev.kind === kinds.Metadata ||
ev.kind === kinds.UserEmojiList ||
ev.kind === kinds.Emojisets
) {
byId.set(ev.id, ev)
}
}
if ( if (
userEmojiListEvent && userEmojiListEvent &&
hasPk && hasPk &&
@ -66,6 +82,24 @@ class CustomEmojiService {
} }
const events = [...byId.values()] const events = [...byId.values()]
const latestMetadata =
events
.filter(
(e) =>
e.kind === kinds.Metadata && e.pubkey.trim().toLowerCase() === pk
)
.sort((a, b) => b.created_at - a.created_at)[0] ??
(metadataEvent &&
metadataEvent.kind === kinds.Metadata &&
metadataEvent.pubkey.trim().toLowerCase() === pk
? metadataEvent
: null)
if (latestMetadata) {
await this.addEmojisToIndex(getEmojisFromEvent(latestMetadata), pk)
}
if (events.length === 0) { if (events.length === 0) {
this.notifyIndexUpdate() this.notifyIndexUpdate()
return return
@ -123,8 +157,8 @@ class CustomEmojiService {
getEmojiById(id?: string): TEmoji | undefined { getEmojiById(id?: string): TEmoji | undefined {
if (!id) return undefined if (!id) return undefined
if (/^[0-9a-f]{64}$/.test(id)) return this.emojiMap.get(id)
return this.emojiMap.get(id) return Array.from(this.emojiMap.values()).find((e) => e.shortcode === id)
} }
/** Returns the emojis that the viewer themselves authored, sorted by shortcode. */ /** Returns the emojis that the viewer themselves authored, sorted by shortcode. */
@ -161,8 +195,10 @@ class CustomEmojiService {
})) }))
} }
isCustomEmojiId(shortcode: string) { isCustomEmojiId(name: string) {
return this.emojiMap.has(shortcode) if (!name) return false
if (/^[0-9a-f]{64}$/.test(name)) return this.emojiMap.has(name)
return Array.from(this.emojiMap.values()).some((e) => e.shortcode === name)
} }
private async addEmojisToIndex(emojis: TEmoji[], authorPubkeyLower: string) { private async addEmojisToIndex(emojis: TEmoji[], authorPubkeyLower: string) {

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

@ -665,6 +665,49 @@ class IndexedDbService {
}) })
} }
/** All cached kind 30030 rows for a pubkey (keys are `pubkey:d`). */
async getEmojiSetEventsForPubkey(pubkeyHex: string): Promise<Event[]> {
const pk = pubkeyHex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return []
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EMOJI_SET_EVENTS)) return []
const prefix = `${pk}:`
const range = IDBKeyRange.lowerBound(prefix)
return new Promise((resolve, reject) => {
const out: Event[] = []
const tx = this.db!.transaction(StoreNames.EMOJI_SET_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.EMOJI_SET_EVENTS)
const req = store.openCursor(range)
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
const rowKey = String(cursor.key ?? '')
if (!rowKey.startsWith(prefix)) {
tx.commit()
resolve(out)
return
}
const ev = (cursor.value as TValue<Event>)?.value
if (
ev &&
ev.kind === kinds.Emojisets &&
ev.pubkey.trim().toLowerCase() === pk
) {
out.push(ev)
}
cursor.continue()
}
req.onerror = () => {
tx.commit()
reject(req.error)
}
})
}
async getMuteDecryptedTags(id: string): Promise<string[][] | null> { async getMuteDecryptedTags(id: string): Promise<string[][] | null> {
await this.initPromise await this.initPromise
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

9
src/services/media-upload.service.ts

@ -36,14 +36,17 @@ export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted'
class MediaUploadService { class MediaUploadService {
static instance: MediaUploadService static instance: MediaUploadService
private serviceConfig: TMediaUploadServiceConfig = storage.getMediaUploadServiceConfig() /** Set in constructor so we do not read `storage` at class field init (circular import TDZ with client.service → draft-event → this module). */
private serviceConfig!: TMediaUploadServiceConfig
private nip96ServiceUploadUrlMap = new Map<string, string | undefined>() private nip96ServiceUploadUrlMap = new Map<string, string | undefined>()
private imetaTagMap = new Map<string, string[]>() private imetaTagMap = new Map<string, string[]>()
constructor() { constructor() {
if (!MediaUploadService.instance) { if (MediaUploadService.instance) {
MediaUploadService.instance = this return MediaUploadService.instance
} }
this.serviceConfig = storage.getMediaUploadServiceConfig()
MediaUploadService.instance = this
return MediaUploadService.instance return MediaUploadService.instance
} }

7
src/services/navigation.service.ts

@ -16,6 +16,7 @@ import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage' import TranslationPage from '@/pages/secondary/TranslationPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage' import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage' import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage'
import EmojiSetsSettingsPage from '@/pages/secondary/EmojiSetsSettingsPage'
import CacheSettingsPage from '@/pages/secondary/CacheSettingsPage' import CacheSettingsPage from '@/pages/secondary/CacheSettingsPage'
import PersonalListsSettingsPage from '@/pages/secondary/PersonalListsSettingsPage' import PersonalListsSettingsPage from '@/pages/secondary/PersonalListsSettingsPage'
import NotePage from '@/pages/secondary/NotePage' import NotePage from '@/pages/secondary/NotePage'
@ -45,6 +46,7 @@ export type ViewType =
| 'bookmarks' | 'bookmarks'
| 'pins' | 'pins'
| 'interests' | 'interests'
| 'user-emojis'
| 'others-relay-settings' | 'others-relay-settings'
| null | null
@ -96,6 +98,7 @@ export class URLParser {
'translation', 'translation',
'rss-feeds', 'rss-feeds',
'follow-sets', 'follow-sets',
'emoji-sets',
'cache', 'cache',
'personal-lists' 'personal-lists'
]) ])
@ -162,6 +165,8 @@ export class ComponentFactory {
return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true })
case 'follow-sets': case 'follow-sets':
return React.createElement(FollowSetsSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(FollowSetsSettingsPage, { index: 0, hideTitlebar: true })
case 'emoji-sets':
return React.createElement(EmojiSetsSettingsPage, { index: 0, hideTitlebar: true })
case 'cache': case 'cache':
return React.createElement(CacheSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(CacheSettingsPage, { index: 0, hideTitlebar: true })
case 'personal-lists': case 'personal-lists':
@ -270,6 +275,7 @@ export class NavigationService {
if (pathname.includes('/wallet')) return 'Wallet Settings' if (pathname.includes('/wallet')) return 'Wallet Settings'
if (pathname.includes('/posts')) return 'Post Settings' if (pathname.includes('/posts')) return 'Post Settings'
if (pathname.includes('/translation')) return 'Translation Settings' if (pathname.includes('/translation')) return 'Translation Settings'
if (pathname.includes('/emoji-sets')) return 'Emoji sets'
return 'Settings' return 'Settings'
} }
if (viewType === 'profile') { if (viewType === 'profile') {
@ -294,6 +300,7 @@ export class NavigationService {
if (viewType === 'bookmarks') return 'Bookmarks' if (viewType === 'bookmarks') return 'Bookmarks'
if (viewType === 'pins') return 'Pinned notes' if (viewType === 'pins') return 'Pinned notes'
if (viewType === 'interests') return 'Interests' if (viewType === 'interests') return 'Interests'
if (viewType === 'user-emojis') return 'Custom emoji list'
if (viewType === 'others-relay-settings') return 'Relays and Storage Settings' if (viewType === 'others-relay-settings') return 'Relays and Storage Settings'
return 'Page' return 'Page'
} }

Loading…
Cancel
Save