From 7f83574400542151b469d0e7d3cc8c4c7193ba66 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Mar 2026 14:03:09 +0100 Subject: [PATCH] speed up embedded event searching ensure text-emojis render in previews and show up in drop-down --- src/components/Content/index.tsx | 9 +- src/components/ContentPreview/Content.tsx | 7 +- src/components/Embedded/EmbeddedNote.tsx | 21 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 6 +- .../PostTextarea/Emoji/EmojiList.tsx | 45 +++-- .../PostTextarea/Emoji/suggestion.ts | 33 +++- .../PostEditor/PostTextarea/Preview.tsx | 5 +- src/components/ProfileAbout/index.tsx | 4 +- .../TextareaWithMentionAutocomplete/index.tsx | 185 ++++++++++++++++-- src/lib/emoji-content.ts | 51 +++++ src/lib/tiptap.ts | 7 +- .../DiscussionsPage/CreateThreadDialog.tsx | 56 +++++- src/pages/secondary/NotePage/NotFound.tsx | 8 +- src/services/client.service.ts | 56 ++++-- src/services/post-editor-cache.service.ts | 21 ++ src/services/relay-info.service.ts | 6 +- 16 files changed, 445 insertions(+), 75 deletions(-) create mode 100644 src/lib/emoji-content.ts diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 2def4a88..fc3c46d8 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -10,6 +10,7 @@ import { EmbeddedWebsocketUrlParser, parseContent } from '@/lib/content-parser' +import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import logger from '@/lib/logger' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' @@ -89,7 +90,11 @@ export default function Content({ const { nodes, emojiInfos } = useMemo(() => { if (!_content) return {} - const nodes = parseContent(_content, [ + const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) + const customShortcodes = emojiInfos.map((e) => e.shortcode) + const normalized = replaceStandardEmojiShortcodesInContent(_content, customShortcodes) + + const nodes = parseContent(normalized, [ EmbeddedUrlParser, EmbeddedLNInvoiceParser, EmbeddedPaytoParser, @@ -100,8 +105,6 @@ export default function Content({ EmbeddedEmojiParser ]) - const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) - return { nodes, emojiInfos } }, [_content, event]) diff --git a/src/components/ContentPreview/Content.tsx b/src/components/ContentPreview/Content.tsx index 159f15c9..35ed8a3b 100644 --- a/src/components/ContentPreview/Content.tsx +++ b/src/components/ContentPreview/Content.tsx @@ -6,6 +6,7 @@ import { EmbeddedUrlParser, parseContent } from '@/lib/content-parser' +import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { cn } from '@/lib/utils' import { TEmoji } from '@/types' @@ -26,14 +27,16 @@ export default function Content({ }) { const { t } = useTranslation() const nodes = useMemo(() => { - return parseContent(content, [ + const customShortcodes = emojiInfos?.map((e) => e.shortcode) ?? [] + const normalized = replaceStandardEmojiShortcodesInContent(content, customShortcodes) + return parseContent(normalized, [ EmbeddedUrlParser, EmbeddedPaytoParser, EmbeddedEventParser, EmbeddedMentionParser, EmbeddedEmojiParser ]) - }, [content]) + }, [content, emojiInfos]) return ( diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index 9b9cb6d7..37847711 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -131,36 +131,35 @@ function EmbeddedNoteNotFound({ const [externalRelays, setExternalRelays] = useState([]) const [hexEventId, setHexEventId] = useState(null) - // Calculate which external relays would be tried + // Calculate which external relays would be tried when user clicks "Try external relays". + // The client's initial fetch now uses: (1) user's relays or BIG, (2) bech32 hints + author read+write, (3) SEARCHABLE. + // We treat BIG + FAST_READ as "already tried"; external = (hints + author read+write + seenOn + SEARCHABLE) minus those. useEffect(() => { const getExternalRelays = async () => { - // Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS) - // These are the relays used in the initial fetch const alreadyTriedRelaysSet = new Set() ;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => { const normalized = normalizeUrl(url) if (normalized) alreadyTriedRelaysSet.add(normalized) }) - + let hintRelays: string[] = [] let extractedHexEventId: string | null = null - - // Parse relay hints and author from bech32 ID + if (!/^[0-9a-f]{64}$/.test(noteId)) { try { const { type, data } = nip19.decode(noteId) - + if (type === 'nevent') { extractedHexEventId = data.id if (data.relays) hintRelays.push(...data.relays) if (data.author) { - const authorRelayList = await client.fetchRelayList(data.author) - hintRelays.push(...authorRelayList.write.slice(0, 6)) + const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) } } else if (type === 'naddr') { if (data.relays) hintRelays.push(...data.relays) - const authorRelayList = await client.fetchRelayList(data.pubkey) - hintRelays.push(...authorRelayList.write.slice(0, 6)) + const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) + hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4)) } else if (type === 'note') { extractedHexEventId = data } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index df1afc0f..43c3d004 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -13,6 +13,7 @@ import { getImetaInfosFromEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import Emoji from '@/components/Emoji' import { ExtendedKind, EMOJI_SHORT_CODE_REGEX, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' +import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { TEmoji } from '@/types' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' @@ -3475,9 +3476,12 @@ export default function MarkdownArticle({ processed = normalizeSetextHeaders(processed) // Normalize backticks (inline code and code blocks) processed = normalizeBackticks(processed) + // Replace standard :shortcode: with Unicode (custom emojis stay as shortcode for tag lookup) + const customShortcodes = event.tags.filter((t) => t[0] === 'emoji').map((t) => t[1]).filter(Boolean) + processed = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) // Then preprocess media links return preprocessMarkdownMediaLinks(processed) - }, [event.content]) + }, [event.content, event.tags]) // Create video poster map from imeta tags const videoPosterMap = useMemo(() => { diff --git a/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx b/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx index ccd311f0..27b836cd 100644 --- a/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx +++ b/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx @@ -2,11 +2,15 @@ import Emoji from '@/components/Emoji' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' import customEmojiService from '@/services/custom-emoji.service' +import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' export interface EmojiListProps { items: string[] command: (params: { name?: string }) => void + /** When provided, selection is controlled by parent (e.g. for plain textarea :emoji:). */ + selectedIndex?: number + onSelectIndex?: (index: number) => void } export interface EmojiListHandler { @@ -15,7 +19,10 @@ export interface EmojiListHandler { export const EmojiList = forwardRef((props, ref) => { const items = props.items ?? [] - const [selectedIndex, setSelectedIndex] = useState(0) + const isControlled = props.selectedIndex !== undefined + const [internalIndex, setInternalIndex] = useState(0) + const selectedIndex = isControlled ? props.selectedIndex! : internalIndex + const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex const selectItem = (index: number): void => { const item = items[index] @@ -24,7 +31,9 @@ export const EmojiList = forwardRef((props, re props.command({ name: item }) } - customEmojiService.updateSuggested(item) + if (customEmojiService.getEmojiById(item)) { + customEmojiService.updateSuggested(item) + } } const upHandler = (): void => { @@ -41,7 +50,9 @@ export const EmojiList = forwardRef((props, re selectItem(selectedIndex) } - useEffect(() => setSelectedIndex(items.length ? 0 : -1), [items]) + useEffect(() => { + if (!isControlled) setInternalIndex(items.length ? 0 : -1) + }, [items, isControlled]) useImperativeHandle(ref, () => { return { @@ -107,8 +118,12 @@ function EmojiListItem({ selectItem: (index: number) => void setSelectedIndex: (index: number) => void }) { - const emoji = useMemo(() => customEmojiService.getEmojiById(id), [id]) - if (!emoji) return null + const { emoji, label } = useMemo(() => { + const custom = customEmojiService.getEmojiById(id) + if (custom) return { emoji: custom as import('@/types').TEmoji, label: `:${custom.shortcode}:` } + const native = shortcodeToEmoji(id, emojis) ?? shortcodeToEmoji(id.replace(/\s+/g, '_'), emojis) + return { emoji: native?.emoji as string | undefined, label: `:${id}:` } + }, [id]) return ( ) diff --git a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts index c3180735..13f95150 100644 --- a/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Emoji/suggestion.ts @@ -4,11 +4,42 @@ import type { Editor } from '@tiptap/core' import { ReactRenderer } from '@tiptap/react' import { SuggestionKeyDownProps } from '@tiptap/suggestion' import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js' +import { emojis } from '@tiptap/extension-emoji' import { EmojiList, EmojiListHandler, EmojiListProps } from './EmojiList' +const STANDARD_EMOJI_LIMIT = 20 + +function searchStandardEmojiShortcodes(query: string): string[] { + const q = query.toLowerCase().trim() + if (!q) return [] + const seen = new Set() + const out: string[] = [] + for (const item of emojis) { + const shortcodes = item.shortcodes ?? [] + const tags = item.tags ?? [] + const name = item.name ?? '' + const match = + shortcodes.some((s) => String(s).toLowerCase().includes(q)) || + tags.some((t) => String(t).toLowerCase().includes(q)) || + name.toLowerCase().includes(q) + if (match) { + const shortcode = shortcodes[0] ?? name + if (shortcode && !seen.has(shortcode)) { + seen.add(shortcode) + out.push(shortcode) + if (out.length >= STANDARD_EMOJI_LIMIT) break + } + } + } + return out +} + const suggestion = { items: async ({ query }: { query: string }) => { - return await customEmojiService.searchEmojis(query) + const custom = await customEmojiService.searchEmojis(query) + const customSet = new Set(custom) + const standard = searchStandardEmojiShortcodes(query).filter((s) => !customSet.has(s)) + return [...custom, ...standard].slice(0, 50) }, render: () => { diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx index bc16e1a7..c660e693 100644 --- a/src/components/PostEditor/PostTextarea/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -8,6 +8,7 @@ import { cleanUrl } from '@/lib/url' import { cn } from '@/lib/utils' import { TPollCreateData } from '@/types' import { kinds, nip19 } from 'nostr-tools' +import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { useMemo } from 'react' import ContentPreview from '../../ContentPreview' import Content from '../../Content' @@ -55,6 +56,8 @@ export default function Preview({ } ) const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent) + const customShortcodes = tags.map((t) => t[1]).filter(Boolean) + const withNativeEmojis = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) // Build highlight tags if this is a highlight let highlightTags: string[][] = [] @@ -108,7 +111,7 @@ export default function Preview({ } return { - content: processed, + content: withNativeEmojis, emojiTags: tags, highlightTags, pollTags diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx index f941d1a2..4963da98 100644 --- a/src/components/ProfileAbout/index.tsx +++ b/src/components/ProfileAbout/index.tsx @@ -6,6 +6,7 @@ import { EmbeddedWebsocketUrlParser, parseContent } from '@/lib/content-parser' +import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import PaytoLink from '@/components/PaytoLink' import { EmbeddedHashtag, @@ -15,7 +16,8 @@ import { } from '../Embedded' export default function ProfileAbout({ about, className }: { about?: string; className?: string }) { - const aboutNodes = parseContent(about ?? '', [ + const normalized = replaceStandardEmojiShortcodesInContent(about ?? '', []) + const aboutNodes = parseContent(normalized, [ EmbeddedWebsocketUrlParser, EmbeddedUrlParser, EmbeddedPaytoParser, diff --git a/src/components/TextareaWithMentionAutocomplete/index.tsx b/src/components/TextareaWithMentionAutocomplete/index.tsx index ce677e35..3218df34 100644 --- a/src/components/TextareaWithMentionAutocomplete/index.tsx +++ b/src/components/TextareaWithMentionAutocomplete/index.tsx @@ -2,11 +2,16 @@ import { Textarea } from '@/components/ui/textarea' import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList' import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants' import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' +import { EmojiList } from '@/components/PostEditor/PostTextarea/Emoji/EmojiList' import client from '@/services/client.service' +import customEmojiService from '@/services/custom-emoji.service' +import { searchStandardEmojiShortcodes } from '@/lib/emoji-content' +import { createPortal } from 'react-dom' import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' const MENTION_LIMIT = 20 const MENTION_INSERT_PREFIX = 'nostr:' +const EMOJI_LIMIT = 25 export type TextareaWithMentionAutocompleteProps = Omit< React.ComponentProps, @@ -31,9 +36,16 @@ const TextareaWithMentionAutocomplete = forwardRef([]) const [mentionStart, setMentionStart] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0) + const [emojiOpen, setEmojiOpen] = useState(false) + const [emojiQuery, setEmojiQuery] = useState('') + const [emojiItems, setEmojiItems] = useState([]) + const [emojiStart, setEmojiStart] = useState(0) + const [selectedEmojiIndex, setSelectedEmojiIndex] = useState(0) const textareaRef = useRef(null) const searchTimeoutRef = useRef | null>(null) + const emojiSearchTimeoutRef = useRef | null>(null) const neventPicker = useNeventPicker() + const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null) const closeMention = useCallback(() => { setMentionOpen(false) @@ -41,6 +53,27 @@ const TextareaWithMentionAutocomplete = forwardRef { + setEmojiOpen(false) + setEmojiQuery('') + setEmojiItems([]) + }, []) + + // When value is cleared or changed from outside (e.g. Clear button), close dropdowns if they're no longer valid + useEffect(() => { + if (!value) { + closeMention() + closeEmoji() + return + } + if (mentionOpen && (value.length <= mentionStart || value[mentionStart] !== '@')) { + closeMention() + } + if (emojiOpen && (value.length <= emojiStart || value[emojiStart] !== ':')) { + closeEmoji() + } + }, [value, mentionOpen, emojiOpen, mentionStart, emojiStart, closeMention, closeEmoji]) + const insertMention = useCallback( (id: string) => { const ta = textareaRef.current @@ -76,6 +109,25 @@ const TextareaWithMentionAutocomplete = forwardRef { + const ta = textareaRef.current + if (!ta) return + const end = emojiStart + 1 + emojiQuery.length + const before = value.slice(0, emojiStart) + const after = value.slice(end) + const insert = `:${shortcode}:` + onChange(before + insert + after) + closeEmoji() + setTimeout(() => { + ta.focus() + const newPos = emojiStart + insert.length + ta.setSelectionRange(newPos, newPos) + }, 0) + }, + [value, emojiStart, emojiQuery.length, onChange, closeEmoji] + ) + useEffect(() => { if (!mentionQuery.trim()) { setMentionItems([]) @@ -109,6 +161,48 @@ const TextareaWithMentionAutocomplete = forwardRef { + if (!emojiQuery.trim()) { + setEmojiItems([]) + setEmojiOpen(false) + return + } + const q = emojiQuery.trim().toLowerCase() + if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current) + emojiSearchTimeoutRef.current = setTimeout(() => { + Promise.all([ + customEmojiService.searchEmojis(q), + Promise.resolve(searchStandardEmojiShortcodes(q, EMOJI_LIMIT)) + ]).then(([custom, standard]) => { + const customSet = new Set(custom) + const merged = [...custom, ...standard.filter((s) => !customSet.has(s))].slice(0, 50) + setEmojiItems(merged) + setEmojiOpen(merged.length > 0) + setSelectedEmojiIndex(0) + }) + }, 150) + return () => { + if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current) + } + }, [emojiQuery]) + + const open = (emojiOpen && emojiItems.length > 0) || (mentionOpen && mentionItems.length > 0) + useEffect(() => { + if (!open) { + setDropdownRect(null) + return + } + const el = textareaRef.current + if (!el) return + const update = () => { + const r = el.getBoundingClientRect() + setDropdownRect({ top: r.bottom + 4, left: r.left, width: r.width }) + } + update() + window.addEventListener('resize', update) + return () => window.removeEventListener('resize', update) + }, [open]) + const handleChange = (e: React.ChangeEvent) => { const v = e.target.value const cursor = e.target.selectionStart ?? v.length @@ -116,20 +210,52 @@ const TextareaWithMentionAutocomplete = forwardRef= 0 ? textBeforeCursor.slice(lastColon + 1) : '' + const segmentAfterAt = lastAt >= 0 ? textBeforeCursor.slice(lastAt + 1) : '' + + const inEmoji = lastColon >= 0 && !/\s/.test(segmentAfterColon) && (lastColon > lastAt || lastAt === -1) + const inMention = lastAt >= 0 && !/\s/.test(segmentAfterAt) + + if (inEmoji) { closeMention() + setEmojiStart(lastColon) + setEmojiQuery(segmentAfterColon) return } - const afterAt = textBeforeCursor.slice(lastAt + 1) - if (/\s/.test(afterAt)) { - closeMention() + if (inMention) { + closeEmoji() + setMentionStart(lastAt) + setMentionQuery(segmentAfterAt) return } - setMentionStart(lastAt) - setMentionQuery(afterAt) + closeMention() + closeEmoji() } const handleKeyDown = (e: React.KeyboardEvent) => { + if (emojiOpen && emojiItems.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedEmojiIndex((i) => (i + 1) % emojiItems.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedEmojiIndex((i) => (i + emojiItems.length - 1) % emojiItems.length) + return + } + if (e.key === 'Enter') { + e.preventDefault() + insertEmoji(emojiItems[selectedEmojiIndex]!) + return + } + if (e.key === 'Escape') { + e.preventDefault() + closeEmoji() + return + } + } if (mentionOpen && mentionItems.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault() @@ -164,6 +290,42 @@ const TextareaWithMentionAutocomplete = forwardRef + {emojiOpen && emojiItems.length > 0 && ( + name != null && insertEmoji(name)} + selectedIndex={selectedEmojiIndex} + onSelectIndex={setSelectedEmojiIndex} + /> + )} + {mentionOpen && mentionItems.length > 0 && !emojiOpen && ( + insertMention(id as string)} + selectedIndex={selectedIndex} + onSelectIndex={setSelectedIndex} + /> + )} + , + document.body + ) + : null + return (