Browse Source

speed up embedded event searching

ensure text-emojis render in previews and show up in drop-down
imwald
Silberengel 1 month ago
parent
commit
7f83574400
  1. 9
      src/components/Content/index.tsx
  2. 7
      src/components/ContentPreview/Content.tsx
  3. 15
      src/components/Embedded/EmbeddedNote.tsx
  4. 6
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 29
      src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
  6. 33
      src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
  7. 5
      src/components/PostEditor/PostTextarea/Preview.tsx
  8. 4
      src/components/ProfileAbout/index.tsx
  9. 185
      src/components/TextareaWithMentionAutocomplete/index.tsx
  10. 51
      src/lib/emoji-content.ts
  11. 7
      src/lib/tiptap.ts
  12. 56
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  13. 8
      src/pages/secondary/NotePage/NotFound.tsx
  14. 56
      src/services/client.service.ts
  15. 21
      src/services/post-editor-cache.service.ts
  16. 6
      src/services/relay-info.service.ts

9
src/components/Content/index.tsx

@ -10,6 +10,7 @@ import {
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
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 { getEmojiInfosFromEmojiTags } from '@/lib/tag'
@ -89,7 +90,11 @@ export default function Content({
const { nodes, emojiInfos } = useMemo(() => { const { nodes, emojiInfos } = useMemo(() => {
if (!_content) return {} 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, EmbeddedUrlParser,
EmbeddedLNInvoiceParser, EmbeddedLNInvoiceParser,
EmbeddedPaytoParser, EmbeddedPaytoParser,
@ -100,8 +105,6 @@ export default function Content({
EmbeddedEmojiParser EmbeddedEmojiParser
]) ])
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
return { nodes, emojiInfos } return { nodes, emojiInfos }
}, [_content, event]) }, [_content, event])

7
src/components/ContentPreview/Content.tsx

@ -6,6 +6,7 @@ import {
EmbeddedUrlParser, EmbeddedUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
@ -26,14 +27,16 @@ export default function Content({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const nodes = useMemo(() => { const nodes = useMemo(() => {
return parseContent(content, [ const customShortcodes = emojiInfos?.map((e) => e.shortcode) ?? []
const normalized = replaceStandardEmojiShortcodesInContent(content, customShortcodes)
return parseContent(normalized, [
EmbeddedUrlParser, EmbeddedUrlParser,
EmbeddedPaytoParser, EmbeddedPaytoParser,
EmbeddedEventParser, EmbeddedEventParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedEmojiParser EmbeddedEmojiParser
]) ])
}, [content]) }, [content, emojiInfos])
return ( return (
<span className={cn(className)}> <span className={cn(className)}>

15
src/components/Embedded/EmbeddedNote.tsx

@ -131,11 +131,11 @@ function EmbeddedNoteNotFound({
const [externalRelays, setExternalRelays] = useState<string[]>([]) const [externalRelays, setExternalRelays] = useState<string[]>([])
const [hexEventId, setHexEventId] = useState<string | null>(null) const [hexEventId, setHexEventId] = useState<string | null>(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(() => { useEffect(() => {
const getExternalRelays = async () => { 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<string>() const alreadyTriedRelaysSet = new Set<string>()
;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => { ;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => {
const normalized = normalizeUrl(url) const normalized = normalizeUrl(url)
@ -145,7 +145,6 @@ function EmbeddedNoteNotFound({
let hintRelays: string[] = [] let hintRelays: string[] = []
let extractedHexEventId: string | null = null let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID
if (!/^[0-9a-f]{64}$/.test(noteId)) { if (!/^[0-9a-f]{64}$/.test(noteId)) {
try { try {
const { type, data } = nip19.decode(noteId) const { type, data } = nip19.decode(noteId)
@ -154,13 +153,13 @@ function EmbeddedNoteNotFound({
extractedHexEventId = data.id extractedHexEventId = data.id
if (data.relays) hintRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
if (data.author) { if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author) const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...authorRelayList.write.slice(0, 6)) hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4))
} }
} else if (type === 'naddr') { } else if (type === 'naddr') {
if (data.relays) hintRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey) const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...authorRelayList.write.slice(0, 6)) hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4))
} else if (type === 'note') { } else if (type === 'note') {
extractedHexEventId = data extractedHexEventId = data
} }

6
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -13,6 +13,7 @@ import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import Emoji from '@/components/Emoji' import Emoji from '@/components/Emoji'
import { ExtendedKind, EMOJI_SHORT_CODE_REGEX, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' 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 { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
@ -3475,9 +3476,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)
const customShortcodes = event.tags.filter((t) => t[0] === 'emoji').map((t) => t[1]).filter(Boolean)
processed = replaceStandardEmojiShortcodesInContent(processed, customShortcodes)
// Then preprocess media links // Then preprocess media links
return preprocessMarkdownMediaLinks(processed) return preprocessMarkdownMediaLinks(processed)
}, [event.content]) }, [event.content, event.tags])
// Create video poster map from imeta tags // Create video poster map from imeta tags
const videoPosterMap = useMemo(() => { const videoPosterMap = useMemo(() => {

29
src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx

@ -2,11 +2,15 @@ import Emoji from '@/components/Emoji'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
export interface EmojiListProps { export interface EmojiListProps {
items: string[] items: string[]
command: (params: { name?: string }) => void 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 { export interface EmojiListHandler {
@ -15,7 +19,10 @@ export interface EmojiListHandler {
export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, ref) => { export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, ref) => {
const items = props.items ?? [] 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 selectItem = (index: number): void => {
const item = items[index] const item = items[index]
@ -24,8 +31,10 @@ export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, re
props.command({ name: item }) props.command({ name: item })
} }
if (customEmojiService.getEmojiById(item)) {
customEmojiService.updateSuggested(item) customEmojiService.updateSuggested(item)
} }
}
const upHandler = (): void => { const upHandler = (): void => {
if (!items.length) return if (!items.length) return
@ -41,7 +50,9 @@ export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, re
selectItem(selectedIndex) selectItem(selectedIndex)
} }
useEffect(() => setSelectedIndex(items.length ? 0 : -1), [items]) useEffect(() => {
if (!isControlled) setInternalIndex(items.length ? 0 : -1)
}, [items, isControlled])
useImperativeHandle(ref, () => { useImperativeHandle(ref, () => {
return { return {
@ -107,8 +118,12 @@ function EmojiListItem({
selectItem: (index: number) => void selectItem: (index: number) => void
setSelectedIndex: (index: number) => void setSelectedIndex: (index: number) => void
}) { }) {
const emoji = useMemo(() => customEmojiService.getEmojiById(id), [id]) const { emoji, label } = useMemo(() => {
if (!emoji) return null 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 ( return (
<button <button
@ -120,6 +135,7 @@ function EmojiListItem({
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
> >
<div className="flex gap-2 items-center truncate pointer-events-none"> <div className="flex gap-2 items-center truncate pointer-events-none">
{emoji ? (
<Emoji <Emoji
emoji={emoji} emoji={emoji}
classNames={{ classNames={{
@ -127,7 +143,10 @@ function EmojiListItem({
text: 'w-8 text-center shrink-0' text: 'w-8 text-center shrink-0'
}} }}
/> />
<span className="truncate">:{emoji.shortcode}:</span> ) : (
<span className="size-8 shrink-0 flex items-center justify-center text-muted-foreground text-xs font-mono" aria-hidden>{id.slice(0, 2)}</span>
)}
<span className="truncate">{label}</span>
</div> </div>
</button> </button>
) )

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

@ -4,11 +4,42 @@ import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react' import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps } from '@tiptap/suggestion' import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js' import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import { emojis } from '@tiptap/extension-emoji'
import { EmojiList, EmojiListHandler, EmojiListProps } from './EmojiList' 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<string>()
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 = { const suggestion = {
items: async ({ query }: { query: string }) => { 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: () => { render: () => {

5
src/components/PostEditor/PostTextarea/Preview.tsx

@ -8,6 +8,7 @@ import { cleanUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { useMemo } from 'react' import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview' import ContentPreview from '../../ContentPreview'
import Content from '../../Content' import Content from '../../Content'
@ -55,6 +56,8 @@ export default function Preview({
} }
) )
const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent) 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 // Build highlight tags if this is a highlight
let highlightTags: string[][] = [] let highlightTags: string[][] = []
@ -108,7 +111,7 @@ export default function Preview({
} }
return { return {
content: processed, content: withNativeEmojis,
emojiTags: tags, emojiTags: tags,
highlightTags, highlightTags,
pollTags pollTags

4
src/components/ProfileAbout/index.tsx

@ -6,6 +6,7 @@ import {
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import PaytoLink from '@/components/PaytoLink' import PaytoLink from '@/components/PaytoLink'
import { import {
EmbeddedHashtag, EmbeddedHashtag,
@ -15,7 +16,8 @@ import {
} from '../Embedded' } from '../Embedded'
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) { export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const aboutNodes = parseContent(about ?? '', [ const normalized = replaceStandardEmojiShortcodesInContent(about ?? '', [])
const aboutNodes = parseContent(normalized, [
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
EmbeddedUrlParser, EmbeddedUrlParser,
EmbeddedPaytoParser, EmbeddedPaytoParser,

185
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -2,11 +2,16 @@ import { Textarea } from '@/components/ui/textarea'
import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList' import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList'
import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants' import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants'
import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog'
import { EmojiList } from '@/components/PostEditor/PostTextarea/Emoji/EmojiList'
import client from '@/services/client.service' 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' import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
const MENTION_LIMIT = 20 const MENTION_LIMIT = 20
const MENTION_INSERT_PREFIX = 'nostr:' const MENTION_INSERT_PREFIX = 'nostr:'
const EMOJI_LIMIT = 25
export type TextareaWithMentionAutocompleteProps = Omit< export type TextareaWithMentionAutocompleteProps = Omit<
React.ComponentProps<typeof Textarea>, React.ComponentProps<typeof Textarea>,
@ -31,9 +36,16 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const [mentionItems, setMentionItems] = useState<string[]>([]) const [mentionItems, setMentionItems] = useState<string[]>([])
const [mentionStart, setMentionStart] = useState(0) const [mentionStart, setMentionStart] = useState(0)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [emojiOpen, setEmojiOpen] = useState(false)
const [emojiQuery, setEmojiQuery] = useState('')
const [emojiItems, setEmojiItems] = useState<string[]>([])
const [emojiStart, setEmojiStart] = useState(0)
const [selectedEmojiIndex, setSelectedEmojiIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement | null>(null) const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const neventPicker = useNeventPicker() const neventPicker = useNeventPicker()
const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null)
const closeMention = useCallback(() => { const closeMention = useCallback(() => {
setMentionOpen(false) setMentionOpen(false)
@ -41,6 +53,27 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
setMentionItems([]) setMentionItems([])
}, []) }, [])
const closeEmoji = useCallback(() => {
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( const insertMention = useCallback(
(id: string) => { (id: string) => {
const ta = textareaRef.current const ta = textareaRef.current
@ -76,6 +109,25 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
[value, mentionStart, mentionQuery.length, onChange, closeMention, neventPicker] [value, mentionStart, mentionQuery.length, onChange, closeMention, neventPicker]
) )
const insertEmoji = useCallback(
(shortcode: string) => {
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(() => { useEffect(() => {
if (!mentionQuery.trim()) { if (!mentionQuery.trim()) {
setMentionItems([]) setMentionItems([])
@ -109,6 +161,48 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
} }
}, [mentionQuery]) }, [mentionQuery])
useEffect(() => {
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<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value const v = e.target.value
const cursor = e.target.selectionStart ?? v.length const cursor = e.target.selectionStart ?? v.length
@ -116,20 +210,52 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const textBeforeCursor = v.slice(0, cursor) const textBeforeCursor = v.slice(0, cursor)
const lastAt = textBeforeCursor.lastIndexOf('@') const lastAt = textBeforeCursor.lastIndexOf('@')
if (lastAt === -1) { const lastColon = textBeforeCursor.lastIndexOf(':')
const segmentAfterColon = lastColon >= 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() closeMention()
setEmojiStart(lastColon)
setEmojiQuery(segmentAfterColon)
return return
} }
const afterAt = textBeforeCursor.slice(lastAt + 1) if (inMention) {
if (/\s/.test(afterAt)) { closeEmoji()
closeMention() setMentionStart(lastAt)
setMentionQuery(segmentAfterAt)
return return
} }
setMentionStart(lastAt) closeMention()
setMentionQuery(afterAt) closeEmoji()
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 (mentionOpen && mentionItems.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
@ -164,6 +290,42 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
} }
} }
const dropdownContent =
dropdownRect && typeof document !== 'undefined'
? createPortal(
<div
className="border rounded-lg bg-background shadow-lg overflow-hidden"
role="listbox"
style={{
position: 'fixed',
top: dropdownRect.top,
left: dropdownRect.left,
width: dropdownRect.width,
maxWidth: 'min(400px, 95vw)',
zIndex: 10000
}}
>
{emojiOpen && emojiItems.length > 0 && (
<EmojiList
items={emojiItems}
command={({ name }) => name != null && insertEmoji(name)}
selectedIndex={selectedEmojiIndex}
onSelectIndex={setSelectedEmojiIndex}
/>
)}
{mentionOpen && mentionItems.length > 0 && !emojiOpen && (
<MentionList
items={mentionItems}
command={({ id }) => insertMention(id as string)}
selectedIndex={selectedIndex}
onSelectIndex={setSelectedIndex}
/>
)}
</div>,
document.body
)
: null
return ( return (
<div className="relative"> <div className="relative">
<Textarea <Textarea
@ -173,16 +335,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
{mentionOpen && mentionItems.length > 0 && ( {dropdownContent}
<div className="absolute left-0 right-0 top-full z-50 mt-1" role="listbox">
<MentionList
items={mentionItems}
command={({ id }) => insertMention(id as string)}
selectedIndex={selectedIndex}
onSelectIndex={setSelectedIndex}
/>
</div>
)}
</div> </div>
) )
}) })

51
src/lib/emoji-content.ts

@ -0,0 +1,51 @@
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
const STANDARD_EMOJI_LIMIT = 20
/**
* Returns standard emoji shortcodes matching the query (for autocomplete).
*/
export function searchStandardEmojiShortcodes(query: string, limit = STANDARD_EMOJI_LIMIT): string[] {
const q = query.toLowerCase().trim()
if (!q) return []
const seen = new Set<string>()
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 >= limit) break
}
}
}
return out
}
/**
* Replaces standard (non-custom) :shortcode: in content with their Unicode emoji
* so they render correctly in all content fields (preview, feed, note page, etc.).
* Custom shortcodes (e.g. from event emoji tags) are left as-is so they render via emoji tags.
*/
export function replaceStandardEmojiShortcodesInContent(
content: string,
customShortcodes?: Set<string> | string[]
): string {
const customSet = customShortcodes instanceof Set
? customShortcodes
: new Set(customShortcodes ?? [])
return content.replace(/:([a-zA-Z0-9_\-\s]+):/g, (match, shortcode: string) => {
const trimmed = shortcode.trim()
if (customSet.has(trimmed)) return match
const native = shortcodeToEmoji(trimmed, emojis) ?? shortcodeToEmoji(trimmed.replace(/\s+/g, '_'), emojis)
return native?.emoji ?? match
})
}

7
src/lib/tiptap.ts

@ -54,8 +54,9 @@ function _parseEditorJsonToText(node?: JSONContent): string {
function parseEmojiNodeName(name?: string): string { function parseEmojiNodeName(name?: string): string {
if (!name) return '' if (!name) return ''
if (customEmojiService.isCustomEmojiId(name)) { if (customEmojiService.isCustomEmojiId(name)) {
return `:${name}:` const custom = customEmojiService.getEmojiById(name)
return custom ? `:${custom.shortcode}:` : `:${name}:`
} }
const emoji = shortcodeToEmoji(name, emojis) const native = shortcodeToEmoji(name, emojis) ?? shortcodeToEmoji(name.replace(/\s+/g, '_'), emojis)
return emoji ? (emoji.emoji ?? '') : '' return native?.emoji ?? `:${name}:`
} }

56
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react' import { Hash, X, Users, Film, Image, Zap, Settings, Book, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react'
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -31,6 +31,7 @@ import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import Uploader from '@/components/PostEditor/Uploader' import Uploader from '@/components/PostEditor/Uploader'
import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog' import { NeventPickerProvider } from '@/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
// Utility functions for thread creation // Utility functions for thread creation
function extractImagesFromContent(content: string): string[] { function extractImagesFromContent(content: string): string[] {
@ -198,7 +199,16 @@ export default function CreateThreadDialog({
return combined return combined
}, [dynamicTopics]) }, [dynamicTopics])
// Initialize selected relays using the centralized relay selection service // Stable refs for relay lists so we don't re-run init when parent context identity changes
const writeRelays = relayList?.write ?? []
const readRelays = relayList?.read ?? []
const writeKey = writeRelays.join(',')
const readKey = readRelays.join(',')
const favoriteKey = favoriteRelays.join(',')
const blockedKey = blockedRelays.join(',')
const relaySetsKey = relaySets.map(s => `${s.id}:${s.relayUrls.join(',')}`).join(';')
// Initialize selected relays using the centralized relay selection service (once per meaningful change)
useEffect(() => { useEffect(() => {
const initializeRelays = async () => { const initializeRelays = async () => {
setIsLoadingRelays(true) setIsLoadingRelays(true)
@ -215,8 +225,8 @@ export default function CreateThreadDialog({
} }
const result = await relaySelectionService.selectRelays({ const result = await relaySelectionService.selectRelays({
userWriteRelays: relayList?.write || [], userWriteRelays: writeRelays,
userReadRelays: relayList?.read || [], userReadRelays: readRelays,
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
relaySets, relaySets,
@ -239,7 +249,34 @@ export default function CreateThreadDialog({
} }
initializeRelays() initializeRelays()
}, [initialRelay, availableRelays, relaySets, favoriteRelays, blockedRelays, relayList, pubkey]) }, [initialRelay, availableRelays, writeKey, readKey, favoriteKey, blockedKey, relaySetsKey, pubkey])
// Load cached thread draft when dialog opens
useEffect(() => {
const draft = postEditorCache.getThreadDraft()
if (draft) {
setTitle(draft.title)
setContent(draft.content)
setSelectedTopic(draft.topic)
}
}, [])
// Persist draft when title, content, or topic change (debounced)
useEffect(() => {
if (!title && !content.trim()) return
const t = setTimeout(() => {
postEditorCache.setThreadDraft({ title, content, topic: selectedTopic })
}, 500)
return () => clearTimeout(t)
}, [title, content, selectedTopic])
const handleClearDraft = useCallback(() => {
setTitle('')
setContent('')
setSelectedTopic(initialTopic)
setErrors({})
postEditorCache.clearThreadDraft()
}, [initialTopic])
const handleRelayCheckedChange = (checked: boolean, url: string) => { const handleRelayCheckedChange = (checked: boolean, url: string) => {
if (checked) { if (checked) {
@ -467,6 +504,7 @@ export default function CreateThreadDialog({
showSimplePublishSuccess(t('Thread published')) showSimplePublishSuccess(t('Thread published'))
} }
postEditorCache.clearThreadDraft()
onThreadCreated(publishedEvent) onThreadCreated(publishedEvent)
onClose() onClose()
} else { } else {
@ -1026,6 +1064,14 @@ export default function CreateThreadDialog({
> >
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button
type="button"
variant="outline"
onClick={handleClearDraft}
disabled={isSubmitting}
>
{t('Clear')}
</Button>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}

8
src/pages/secondary/NotePage/NotFound.tsx

@ -47,13 +47,13 @@ export default function NotFound({
extractedHexEventId = data.id extractedHexEventId = data.id
if (data.relays) hintRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
if (data.author) { if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author) const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...authorRelayList.write.slice(0, 6)) hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4))
} }
} else if (type === 'naddr') { } else if (type === 'naddr') {
if (data.relays) hintRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey) const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...authorRelayList.write.slice(0, 6)) hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4))
} else if (type === 'note') { } else if (type === 'note') {
extractedHexEventId = data extractedHexEventId = data
} }

56
src/services/client.service.ts

@ -1353,8 +1353,12 @@ class ClientService extends EventTarget {
onevent?.(evt) onevent?.(evt)
events.push(evt) events.push(evt)
// As soon as one relay returns results, give others 2s then resolve (keeps reqs fast) const filters = Array.isArray(filter) ? filter : [filter]
if (events.length === 1 && !firstResultGraceTimeoutId) { const maxLimit = Math.max(...filters.map((f) => (f.limit ?? 0) as number), 0)
const isSingleEventFetch = maxLimit === 1
// Only use "first result grace" for single-event fetches (e.g. by id). For multi-result
// (e.g. GIF list, feed) wait for EOSE so we aggregate from all relays.
if (isSingleEventFetch && events.length === 1 && !firstResultGraceTimeoutId) {
firstResultGraceTimeoutId = setTimeout(() => { firstResultGraceTimeoutId = setTimeout(() => {
firstResultGraceTimeoutId = null firstResultGraceTimeoutId = null
resolveWithEvents() resolveWithEvents()
@ -1362,7 +1366,6 @@ class ClientService extends EventTarget {
} }
// Check if we're looking for a specific event ID (limit: 1 with ids filter) // Check if we're looking for a specific event ID (limit: 1 with ids filter)
const filters = Array.isArray(filter) ? filter : [filter]
const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0) const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0)
const hasLimitOne = filters.some(f => f.limit === 1) const hasLimitOne = filters.some(f => f.limit === 1)
@ -1490,6 +1493,13 @@ class ClientService extends EventTarget {
return events return events
} }
/**
* Fetch a single event by id (hex, note1, nevent1, naddr1).
* Relay order: (1) session/DataLoader cache (2) buildInitialRelayList (user's FAST_READ + favorite + read) or BIG_RELAY_URLS
* (3) for nevent/naddr: bech32 relay hints + author's read (inbox) + author's write (outbox) from kind 10002
* (4) if still missing and filter has authors: author's read+write again in tryHarderToFetchEvent
* (5) SEARCHABLE_RELAY_URLS as final fallback. Author relays are used so embedded notes load from the author's relays.
*/
async fetchEvent(id: string): Promise<NEvent | undefined> { async fetchEvent(id: string): Promise<NEvent | undefined> {
let hexId: string | undefined let hexId: string | undefined
if (/^[0-9a-f]{64}$/.test(id)) { if (/^[0-9a-f]{64}$/.test(id)) {
@ -1549,7 +1559,7 @@ class ClientService extends EventTarget {
break break
case 'nevent': case 'nevent':
filter = { ids: [data.id] } filter = { ids: [data.id] }
if (data.relays) relays = data.relays if (data.relays) relays = [...data.relays]
if (data.author) author = data.author if (data.author) author = data.author
break break
case 'naddr': case 'naddr':
@ -1562,21 +1572,42 @@ class ClientService extends EventTarget {
if (data.identifier) { if (data.identifier) {
filter['#d'] = [data.identifier] filter['#d'] = [data.identifier]
} }
if (data.relays) relays = data.relays if (data.relays) relays = [...data.relays]
} }
} }
if (!filter) { if (!filter) {
throw new Error('Invalid id') throw new Error('Invalid id')
} }
// For nevent/naddr with author: include author's read (inbox) + write (outbox) relays so we try them in the same round as bech32 hints
const emptyRelayList: TRelayList = { read: [], write: [], originalRelays: [] }
let authorRelayList: TRelayList | undefined
if (author) {
authorRelayList = await this.fetchRelayList(author).catch(() => emptyRelayList)
const r = authorRelayList.read ?? []
const w = authorRelayList.write ?? []
relays = [...relays, ...r.slice(0, 4), ...w.slice(0, 4)]
relays = Array.from(
new Set(relays.map((url) => normalizeUrl(url)).filter(Boolean))
) as string[]
}
let event: NEvent | undefined let event: NEvent | undefined
if (filter.ids?.length) { if (filter.ids?.length) {
event = await this.fetchEventById(relays, filter.ids[0]) event = await this.fetchEventById(relays, filter.ids[0])
} else if (filter.authors?.length) {
event = await this.tryHarderToFetchEvent(relays, filter, false)
} }
if (!event && author) { if (!event && author && authorRelayList) {
const relayList = await this.fetchRelayList(author) const r = authorRelayList.read ?? []
event = await this.tryHarderToFetchEvent(relayList.write.slice(0, 5), filter) const w = authorRelayList.write ?? []
const authorRelays = [...r.slice(0, 3), ...w.slice(0, 3)]
.map((url) => normalizeUrl(url))
.filter(Boolean)
if (authorRelays.length) {
event = await this.tryHarderToFetchEvent(authorRelays, filter)
}
} }
if (event && event.id !== id) { if (event && event.id !== id) {
@ -1592,10 +1623,13 @@ class ClientService extends EventTarget {
alreadyFetchedFromBigRelays = false alreadyFetchedFromBigRelays = false
) { ) {
if (!relayUrls.length && filter.authors?.length) { if (!relayUrls.length && filter.authors?.length) {
const relayList = await this.fetchRelayList(filter.authors[0]) const relayList = await this.fetchRelayList(filter.authors[0]).catch(() => ({ read: [] as string[], write: [] as string[] }))
const read = (relayList.read ?? []).slice(0, 3)
const write = (relayList.write ?? []).slice(0, 3)
relayUrls = alreadyFetchedFromBigRelays relayUrls = alreadyFetchedFromBigRelays
? relayList.write.filter((url) => !BIG_RELAY_URLS.includes(url)).slice(0, 4) ? [...read, ...write.filter((url) => !BIG_RELAY_URLS.includes(url))]
: relayList.write.slice(0, 4) : [...read, ...write]
relayUrls = Array.from(new Set(relayUrls.map((url) => normalizeUrl(url)).filter(Boolean))) as string[]
} else if (!relayUrls.length && !alreadyFetchedFromBigRelays) { } else if (!relayUrls.length && !alreadyFetchedFromBigRelays) {
relayUrls = BIG_RELAY_URLS relayUrls = BIG_RELAY_URLS
} }

21
src/services/post-editor-cache.service.ts

@ -9,11 +9,20 @@ type TPostSettings = {
addClientTag?: boolean addClientTag?: boolean
} }
/** Cached draft for the Discussions "Create Thread" dialog (kind 11). */
export type TThreadDraft = {
title: string
content: string
topic: string
}
class PostEditorCacheService { class PostEditorCacheService {
static instance: PostEditorCacheService static instance: PostEditorCacheService
private postContentCache: Map<string, Content> = new Map() private postContentCache: Map<string, Content> = new Map()
private postSettingsCache: Map<string, TPostSettings> = new Map() private postSettingsCache: Map<string, TPostSettings> = new Map()
private static THREAD_DRAFT_KEY = 'create-thread'
private threadDraftCache: TThreadDraft | null = null
constructor() { constructor() {
if (!PostEditorCacheService.instance) { if (!PostEditorCacheService.instance) {
@ -78,6 +87,18 @@ class PostEditorCacheService {
generateCacheKey(defaultContent: string = '', parentEvent?: Event): string { generateCacheKey(defaultContent: string = '', parentEvent?: Event): string {
return parentEvent ? parentEvent.id : defaultContent return parentEvent ? parentEvent.id : defaultContent
} }
getThreadDraft(): TThreadDraft | null {
return this.threadDraftCache
}
setThreadDraft(draft: TThreadDraft): void {
this.threadDraftCache = draft
}
clearThreadDraft(): void {
this.threadDraftCache = null
}
} }
const instance = new PostEditorCacheService() const instance = new PostEditorCacheService()

6
src/services/relay-info.service.ts

@ -194,20 +194,20 @@ class RelayInfoService {
*/ */
private maybePublishNip66Discovery(relayInfo: TRelayInfo): void { private maybePublishNip66Discovery(relayInfo: TRelayInfo): void {
if (!isNip66MonitorEnabled()) { if (!isNip66MonitorEnabled()) {
logger.info('NIP-66: skip 30166 (publishing is handled by server cron)', { url: relayInfo.url }) logger.debug('NIP-66: skip 30166 (publishing is handled by server cron)', { url: relayInfo.url })
return return
} }
const key = relayInfo.url const key = relayInfo.url
const now = Date.now() const now = Date.now()
const last = this.lastNip66PublishByUrl.get(key) ?? 0 const last = this.lastNip66PublishByUrl.get(key) ?? 0
if (now - last < RelayInfoService.NIP66_PUBLISH_INTERVAL_MS) { if (now - last < RelayInfoService.NIP66_PUBLISH_INTERVAL_MS) {
logger.info('NIP-66: skip 30166 (throttled, 1h per relay)', { url: relayInfo.url, nextInMin: Math.ceil((RelayInfoService.NIP66_PUBLISH_INTERVAL_MS - (now - last)) / 60000) }) logger.debug('NIP-66: skip 30166 (throttled, 1h per relay)', { url: relayInfo.url, nextInMin: Math.ceil((RelayInfoService.NIP66_PUBLISH_INTERVAL_MS - (now - last)) / 60000) })
return return
} }
const event = buildAndSignDiscoveryEvent(relayInfo) const event = buildAndSignDiscoveryEvent(relayInfo)
if (!event) { if (!event) {
logger.info('NIP-66: skip 30166 (build/sign failed)', { url: relayInfo.url }) logger.debug('NIP-66: skip 30166 (build/sign failed)', { url: relayInfo.url })
return return
} }

Loading…
Cancel
Save