Browse Source

bug-fixes

make search more thorough
fix blossom image rendering
adjust pickers
add generic event creator
imwald
Silberengel 4 weeks ago
parent
commit
83f6cee991
  1. 33
      src/components/EmojiPicker/index.tsx
  2. 23
      src/components/EmojiPickerDialog/index.tsx
  3. 27
      src/components/NormalFeed/index.tsx
  4. 46
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 333
      src/components/NoteList/index.tsx
  6. 167
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  7. 2
      src/components/PostEditor/HighlightEditor.tsx
  8. 6
      src/components/PostEditor/Mentions.tsx
  9. 3
      src/components/PostEditor/PollEditor.tsx
  10. 101
      src/components/PostEditor/PostContent.tsx
  11. 6
      src/components/PostEditor/PostRelaySelector.tsx
  12. 3
      src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
  13. 8
      src/components/PostEditor/PostTextarea/index.tsx
  14. 17
      src/components/SearchBar/index.tsx
  15. 15
      src/components/SearchResult/index.tsx
  16. 2
      src/components/SuggestedEmojis/index.tsx
  17. 6
      src/components/TextareaWithMentionAutocomplete/index.tsx
  18. 28
      src/components/UserAvatar/index.tsx
  19. 9
      src/components/UserItem/index.tsx
  20. 16
      src/components/Username/index.tsx
  21. 23
      src/components/YoutubeEmbeddedPlayer/index.tsx
  22. 13
      src/constants.ts
  23. 3
      src/i18n/locales/de.ts
  24. 3
      src/i18n/locales/en.ts
  25. 47
      src/lib/dtag-search.ts
  26. 55
      src/lib/youtube-iframe-api.ts
  27. 30
      src/pages/primary/FollowsLatestPage/index.tsx
  28. 22
      src/pages/primary/SearchPage/index.tsx
  29. 53
      src/pages/secondary/NoteListPage/index.tsx
  30. 8
      src/providers/NostrProvider/index.tsx
  31. 14
      src/services/client-events.service.ts
  32. 48
      src/services/client.service.ts
  33. 133
      src/services/custom-emoji.service.ts
  34. 18
      src/services/gif.service.ts
  35. 71
      src/services/indexed-db.service.ts
  36. 22
      src/services/meme.service.ts
  37. 2
      src/types/index.d.ts

33
src/components/EmojiPicker/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { parseEmojiPickerUnified } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider'
import customEmojiService from '@/services/custom-emoji.service'
@ -9,6 +10,7 @@ import EmojiPickerReact, { @@ -9,6 +10,7 @@ import EmojiPickerReact, {
SuggestionMode,
Theme
} from 'emoji-picker-react'
import { useEffect, useMemo, useState } from 'react'
export { EMOJI_PICKER_REACTIONS } from '@/lib/like-reaction-emojis'
@ -25,13 +27,40 @@ export default function EmojiPicker({ @@ -25,13 +27,40 @@ export default function EmojiPicker({
}) {
const { themeSetting } = useTheme()
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const [viewportW, setViewportW] = useState(
() => (typeof window !== 'undefined' ? window.innerWidth : 390)
)
const [viewportH, setViewportH] = useState(
() => (typeof window !== 'undefined' ? window.innerHeight : 700)
)
useEffect(() => {
const onResize = () => {
setViewportW(window.innerWidth)
setViewportH(window.innerHeight)
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
const [customEmojiTick, setCustomEmojiTick] = useState(0)
useEffect(() => customEmojiService.subscribeIndexUpdate(() => setCustomEmojiTick((t) => t + 1)), [])
const customEmojis = useMemo(
() => customEmojiService.getAllCustomEmojisForPicker(pubkey ?? null),
[pubkey, customEmojiTick]
)
const pickerWidth = isSmallScreen ? Math.max(260, viewportW - 24) : 350
const pickerHeight = isSmallScreen
? Math.max(280, Math.min(Math.round(viewportH * 0.52), 460))
: 450
return (
<EmojiPickerReact
theme={
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT
}
width={isSmallScreen ? '100%' : 350}
width={pickerWidth}
height={pickerHeight}
autoFocusSearch={false}
emojiStyle={EmojiStyle.NATIVE}
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW}
@ -50,7 +79,7 @@ export default function EmojiPicker({ @@ -50,7 +79,7 @@ export default function EmojiPicker({
const emoji = parseEmojiPickerUnified(data.unified)
onEmojiClick(emoji, e)
}}
customEmojis={customEmojiService.getAllCustomEmojisForPicker()}
customEmojis={customEmojis}
{...(reactionsDefaultOpen !== undefined ? { reactionsDefaultOpen } : {})}
{...(reactions !== undefined ? { reactions } : {})}
/>

23
src/components/EmojiPickerDialog/index.tsx

@ -28,6 +28,7 @@ export default function EmojiPickerDialog({ @@ -28,6 +28,7 @@ export default function EmojiPickerDialog({
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
portalContainer={portalContainer}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2"
onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return
@ -37,13 +38,15 @@ export default function EmojiPickerDialog({ @@ -37,13 +38,15 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader>
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()
setOpen(false)
onEmojiClick?.(emoji)
}}
/>
<div className="flex w-full max-w-[100vw] min-w-0 min-h-0 shrink flex-col items-stretch overflow-x-hidden pb-1">
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()
setOpen(false)
onEmojiClick?.(emoji)
}}
/>
</div>
</DrawerContent>
</Drawer>
)
@ -52,7 +55,11 @@ export default function EmojiPickerDialog({ @@ -52,7 +55,11 @@ export default function EmojiPickerDialog({
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit" portalContainer={portalContainer}>
<DropdownMenuContent
side="top"
className="p-0 w-[min(100vw-1rem,350px)] max-w-[calc(100vw-1rem)] overflow-hidden"
portalContainer={portalContainer}
>
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()

27
src/components/NormalFeed/index.tsx

@ -7,6 +7,7 @@ import storage from '@/services/local-storage.service' @@ -7,6 +7,7 @@ import storage from '@/services/local-storage.service'
import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import KindFilter from '../KindFilter'
@ -56,6 +57,16 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -56,6 +57,16 @@ const NormalFeed = forwardRef<TNoteListRef, {
onSingleRelayKindlessEmpty?: () => void
/** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */
feedTopNotice?: ReactNode
/** Passed through to {@link NoteList} (d-tag browse one-shot). */
oneShotFetch?: boolean
progressiveWarmupQuery?: string
progressiveWarmupMatch?: (ev: Event) => boolean
/** Union into kind picker kinds for REQ + UI when set (e.g. document kinds on search / d-tag feeds). */
progressiveDocumentKinds?: readonly number[]
oneShotAfterMergeComparator?: (a: Event, b: Event) => number
extraShouldHideEvent?: (ev: Event) => boolean
/** Override default cap for merged one-shot batches (wide d-tag / search merges). */
oneShotMergedCap?: number
}>(function NormalFeed(
{
subRequests,
@ -77,7 +88,14 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -77,7 +88,14 @@ const NormalFeed = forwardRef<TNoteListRef, {
showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName,
onSingleRelayKindlessEmpty,
feedTopNotice
feedTopNotice,
oneShotFetch = false,
progressiveWarmupQuery,
progressiveWarmupMatch,
progressiveDocumentKinds,
oneShotAfterMergeComparator,
extraShouldHideEvent,
oneShotMergedCap
},
ref
) {
@ -249,6 +267,13 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -249,6 +267,13 @@ const NormalFeed = forwardRef<TNoteListRef, {
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
feedTopNotice={feedTopNotice}
oneShotFetch={oneShotFetch}
progressiveWarmupQuery={progressiveWarmupQuery}
progressiveWarmupMatch={progressiveWarmupMatch}
progressiveDocumentKinds={progressiveDocumentKinds}
oneShotAfterMergeComparator={oneShotAfterMergeComparator}
extraShouldHideEvent={extraShouldHideEvent}
oneShotMergedCap={oneShotMergedCap}
/>
</div>
</>

46
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -26,7 +26,7 @@ import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' @@ -26,7 +26,7 @@ import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji } from '@/types'
import { TEmoji, TImetaInfo } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
@ -2958,6 +2958,44 @@ function parseMarkdownContentMarked( @@ -2958,6 +2958,44 @@ function parseMarkdownContentMarked(
containingEvent
} = options
/** 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 => {
if (containingEvent) {
const infos = getImetaInfosFromEvent(containingEvent)
const hit = infos.find((i) => cleanUrl(i.url) === cleaned)
if (hit) return { ...hit, url: cleaned }
}
return { url: cleaned, pubkey: eventPubkey }
}
const renderStandaloneHttpsImageBlock = (cleaned: string, reactKey: string) => {
let imageIndex = imageIndexMap.get(cleaned)
if (imageIndex === undefined && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned)
if (identifier) {
imageIndex = imageIndexMap.get(`__img_id:${identifier}`)
}
}
return (
<div key={reactKey} className="my-2 block max-w-[400px] mx-auto">
<Image
image={imetaInfoForStandaloneImageUrl(cleaned)}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg block w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (imageIndex !== undefined) {
openLightbox(imageIndex)
}
}}
/>
</div>
)
}
const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>()
const citations: Array<{ id: string; type: string; citationId: string }> = []
@ -3250,6 +3288,9 @@ function parseMarkdownContentMarked( @@ -3250,6 +3288,9 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isImage(cleaned) && isSafeMediaUrl(cleaned)) {
return renderStandaloneHttpsImageBlock(cleaned, `${key}-line-img-${lineIdx}`)
}
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return (
<p key={`${key}-line-inline-link-${lineIdx}`} className="mb-1 last:mb-0">
@ -3387,6 +3428,9 @@ function parseMarkdownContentMarked( @@ -3387,6 +3428,9 @@ function parseMarkdownContentMarked(
</div>
)
}
if (isImage(cleaned) && isSafeMediaUrl(cleaned)) {
return renderStandaloneHttpsImageBlock(cleaned, `${key}-para-img`)
}
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return (
<p key={`${key}-inline-link`} className="mb-1 last:mb-0">

333
src/components/NoteList/index.tsx

@ -25,6 +25,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -25,6 +25,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import {
getSessionFeedSnapshot,
hardReloadPreservingFeedSnapshots,
@ -48,7 +49,9 @@ import { @@ -48,7 +49,9 @@ import {
useMemo,
useRef,
useState,
type ReactNode
type Dispatch,
type ReactNode,
type SetStateAction
} from 'react'
import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action'
@ -104,6 +107,8 @@ const MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE = 2500 @@ -104,6 +107,8 @@ const MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE = 2500
const ONE_SHOT_MERGED_CAP =100
/** Max events kept after merging parallel full-search REQ results across relays. */
const FEED_FULL_SEARCH_MERGE_CAP = 400
/** Cap archive cursor time so progressive search does not monopolize the main thread; pub-store hits are unchanged. */
const PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS = 3_200
/** Client-side feed time window units (Day.js `.subtract` names). */
type TFeedClientTimeUnit = 'minute' | 'day' | 'week' | 'month' | 'year'
@ -130,6 +135,94 @@ function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): E @@ -130,6 +135,94 @@ function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): E
.slice(0, cap)
}
/** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No cap. */
function mergeProgressiveSearchEvents(
prev: Event[],
incoming: Event[],
afterSort?: (a: Event, b: Event) => number
): Event[] {
const byId = new Map<string, Event>()
for (const e of prev) {
byId.set(e.id, e)
}
for (const e of incoming) {
const o = byId.get(e.id)
if (!o) {
byId.set(e.id, e)
} else if (e.created_at > o.created_at) {
byId.set(e.id, e)
}
}
const arr = Array.from(byId.values())
if (afterSort) {
arr.sort(afterSort)
} else {
arr.sort((a, b) => b.created_at - a.created_at)
}
return arr
}
function mergeKindsForProgressiveWarmup(
showKindsFromPicker: number[],
progressiveDocumentKinds: readonly number[] | undefined
): number[] {
const base = showKindsFromPicker.length > 0 ? showKindsFromPicker : [kinds.ShortTextNote]
if (!progressiveDocumentKinds?.length) return base
return Array.from(new Set([...base, ...progressiveDocumentKinds])).sort((a, b) => a - b)
}
type ProgressiveSearchLocalLayerOpts = {
warmQ: string
isStale: () => boolean
kindsForWarm: number[]
warmMatch?: (ev: Event) => boolean
afterSort?: (a: Event, b: Event) => number
setEvents: Dispatch<SetStateAction<Event[]>>
setLoading: (loading: boolean) => void
}
/** In-memory session hits only (sync). Relay / IndexedDB run in parallel via {@link kickProgressiveSearchLocalLayers}. */
function applyProgressiveSessionSearchLayer(params: ProgressiveSearchLocalLayerOpts): void {
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
let boot = client.getSessionEventsMatchingSearch(warmQ, cap, kindsForWarm)
if (warmMatch) boot = boot.filter(warmMatch)
const sortCreated = (evs: Event[]) => [...evs].sort((a, b) => b.created_at - a.created_at)
const finalizeOrder = (evs: Event[]) => (afterSort ? [...evs].sort(afterSort) : sortCreated(evs))
if (!isStale() && boot.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, finalizeOrder(boot), afterSort))
setLoading(false)
}
}
function startProgressiveIdbSearchLayer(params: ProgressiveSearchLocalLayerOpts): void {
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
void (async () => {
try {
const idbE = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
warmQ,
cap,
kindsForWarm,
{ archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS }
)
if (isStale()) return
const idbUse = warmMatch ? idbE.filter(warmMatch) : idbE
if (idbUse.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, idbUse, afterSort))
setLoading(false)
}
} catch {
/* ignore */
}
})()
}
function kickProgressiveSearchLocalLayers(params: ProgressiveSearchLocalLayerOpts): void {
applyProgressiveSessionSearchLayer(params)
startProgressiveIdbSearchLayer(params)
}
/** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */
function timelineFilterHasNonKindScope(f: Filter): boolean {
const search = f.search
@ -333,6 +426,22 @@ const NoteList = forwardRef( @@ -333,6 +426,22 @@ const NoteList = forwardRef(
revealBatchSize,
/** When set with {@link oneShotFetch}, logs fetch + filter diagnostics to the console (e.g. faux spells). */
oneShotDebugLabel,
/**
* When set, session cache + IndexedDB are scanned for this string before relay REQ completes, merged into the
* timeline immediately (optional {@link progressiveWarmupMatch} narrows rows). Used for NIP-50 search + d-tag browse.
*/
progressiveWarmupQuery,
/** Optional extra filter for {@link progressiveWarmupQuery} hits (e.g. d-tag substring semantics). */
progressiveWarmupMatch,
/**
* Union these kinds into {@link showKinds} for REQ mapping, UI kind gates, progressive warmup, and load-more
* narrowing (e.g. long-form / publication kinds on d-tag + NIP-50 search feeds).
*/
progressiveDocumentKinds,
/**
* When set with {@link oneShotFetch}, sort merged one-shot results with this comparator (e.g. exact d-tag first).
*/
oneShotAfterMergeComparator,
/**
* When true (default), show the 🔍 client-side filter bar (search / from me / time window).
* Set false on feeds where it should stay hidden (e.g. main following).
@ -388,6 +497,10 @@ const NoteList = forwardRef( @@ -388,6 +497,10 @@ const NoteList = forwardRef(
oneShotMergedCap?: number
revealBatchSize?: number
oneShotDebugLabel?: string
progressiveWarmupQuery?: string
progressiveWarmupMatch?: (ev: Event) => boolean
progressiveDocumentKinds?: readonly number[]
oneShotAfterMergeComparator?: (a: Event, b: Event) => number
oneShotGlobalTimeoutMs?: number
oneShotEoseTimeoutMs?: number
oneShotFirstRelayGraceMs?: number | false
@ -418,6 +531,8 @@ const NoteList = forwardRef( @@ -418,6 +531,8 @@ const NoteList = forwardRef(
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
/** Session/IDB/relay layers still running for {@link progressiveWarmupQuery} feeds (drives “Looking for more…”). */
const [progressiveLayersSearching, setProgressiveLayersSearching] = useState(false)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshCount, setRefreshCount] = useState(0)
const [showCount, setShowCount] = useState(SHOW_COUNT)
@ -521,9 +636,14 @@ const NoteList = forwardRef( @@ -521,9 +636,14 @@ const NoteList = forwardRef(
[followingFeedDeltaSubRequests]
)
const effectiveShowKinds = useMemo(() => {
if (!progressiveDocumentKinds?.length) return showKinds
return Array.from(new Set([...showKinds, ...progressiveDocumentKinds])).sort((a, b) => a - b)
}, [showKinds, progressiveDocumentKinds])
const mapLiveSubRequestsForTimeline = useCallback(
(requests: TFeedSubRequest[]) => {
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]
const defaultKinds = effectiveShowKinds.length > 0 ? effectiveShowKinds : [kinds.ShortTextNote]
const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs
return requests.map(({ urls, filter }) => {
const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
@ -576,7 +696,7 @@ const NoteList = forwardRef( @@ -576,7 +696,7 @@ const NoteList = forwardRef(
areAlgoRelays,
clientSideKindFilter,
seeAllFeedEvents,
showKinds,
effectiveShowKinds,
useFilterAsIs
]
)
@ -695,9 +815,9 @@ const NoteList = forwardRef( @@ -695,9 +815,9 @@ const NoteList = forwardRef(
// Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds
// Use sorted array and JSON.stringify to create a stable key that only changes when content changes
const showKindsKey = useMemo(() => {
if (!showKinds || showKinds.length === 0) return ''
return JSON.stringify([...showKinds].sort((a, b) => a - b))
}, [showKinds])
if (!effectiveShowKinds || effectiveShowKinds.length === 0) return ''
return JSON.stringify([...effectiveShowKinds].sort((a, b) => a - b))
}, [effectiveShowKinds])
/**
* Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows.
@ -737,6 +857,16 @@ const NoteList = forwardRef( @@ -737,6 +857,16 @@ const NoteList = forwardRef(
const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds
const effectiveShowKindsRef = useRef(effectiveShowKinds)
effectiveShowKindsRef.current = effectiveShowKinds
const progressiveDocumentKindsRef = useRef(progressiveDocumentKinds)
progressiveDocumentKindsRef.current = progressiveDocumentKinds
const progressiveWarmupQueryRef = useRef(progressiveWarmupQuery)
progressiveWarmupQueryRef.current = progressiveWarmupQuery
const progressiveWarmupMatchRef = useRef(progressiveWarmupMatch)
progressiveWarmupMatchRef.current = progressiveWarmupMatch
const oneShotAfterMergeComparatorRef = useRef(oneShotAfterMergeComparator)
oneShotAfterMergeComparatorRef.current = oneShotAfterMergeComparator
const seeAllFeedEventsRef = useRef(seeAllFeedEvents)
seeAllFeedEventsRef.current = seeAllFeedEvents
const allowKindlessRelayExploreRef = useRef(allowKindlessRelayExplore)
@ -828,7 +958,7 @@ const NoteList = forwardRef( @@ -828,7 +958,7 @@ const NoteList = forwardRef(
for (let i = 0; i < maxScan && out.length < target; i++) {
const evt = timelineEventsForFilter[i]
if (applyKindPickerInUi) {
if (!showKinds.includes(evt.kind)) continue
if (!effectiveShowKinds.includes(evt.kind)) continue
if (evt.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(evt)
if (isReply && !showKind1Replies) continue
@ -902,7 +1032,7 @@ const NoteList = forwardRef( @@ -902,7 +1032,7 @@ const NoteList = forwardRef(
return newEvents.filter((event: Event) => {
if (applyKindPickerInUi) {
if (!showKinds.includes(event.kind)) return false
if (!effectiveShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return false
@ -926,7 +1056,7 @@ const NoteList = forwardRef( @@ -926,7 +1056,7 @@ const NoteList = forwardRef(
feedFullSearchEvents,
newEvents,
shouldHideEvent,
showKinds,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111,
@ -1420,20 +1550,40 @@ const NoteList = forwardRef( @@ -1420,20 +1550,40 @@ const NoteList = forwardRef(
/**
* Kindless relay REQ: when {@link showAllKinds} is true (explorer / "All Events"), keep the full batch;
* otherwise narrow to {@link showKinds} so the merged timeline matches {@link applyKindPickerInUi}.
* otherwise narrow to effectiveShowKinds so the merged timeline matches {@link applyKindPickerInUi}.
*/
const narrowLiveBatch = (evs: Event[]) => {
if (seeAllFeedEventsRef.current) return evs
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => showKinds.includes(e.kind))
return evs.filter((e) => effectiveShowKinds.includes(e.kind))
}
if (oneShotFetch) {
setHasMore(false)
try {
if (timelineEffectStale()) return undefined
const warmQOneShot = progressiveWarmupQueryRef.current?.trim()
if (warmQOneShot) {
setProgressiveLayersSearching(true)
kickProgressiveSearchLocalLayers({
warmQ: warmQOneShot,
isStale: () => !effectActive || timelineEffectStale(),
kindsForWarm: mergeKindsForProgressiveWarmup(
showKindsRef.current,
progressiveDocumentKindsRef.current
),
warmMatch: progressiveWarmupMatchRef.current,
afterSort: oneShotAfterMergeComparatorRef.current,
setEvents,
setLoading
})
}
if (timelineEffectStale()) {
if (warmQOneShot) setProgressiveLayersSearching(false)
return undefined
}
const firstRelayGraceResolved =
oneShotFirstRelayGraceMs === undefined
? FIRST_RELAY_RESULT_GRACE_MS
@ -1460,9 +1610,11 @@ const NoteList = forwardRef( @@ -1460,9 +1610,11 @@ const NoteList = forwardRef(
}
}
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
let merged = [...byId.values()]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, cap)
const isProgressiveLayers = !!progressiveWarmupQueryRef.current?.trim()
let relayOnly = [...byId.values()].sort((a, b) => b.created_at - a.created_at)
if (!isProgressiveLayers) {
relayOnly = relayOnly.slice(0, cap)
}
if (
useFilterAsIs &&
clientSideKindFilter &&
@ -1470,39 +1622,69 @@ const NoteList = forwardRef( @@ -1470,39 +1622,69 @@ const NoteList = forwardRef(
!seeAllFeedEventsRef.current &&
(!allowKindlessRelayExplore || !showAllKinds)
) {
merged = merged.filter((e) => showKinds.includes(e.kind))
relayOnly = relayOnly.filter((e) => effectiveShowKinds.includes(e.kind))
}
if (sessionSnap?.length && !userPulledRefresh) {
merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP)
const mergeCmp = oneShotAfterMergeComparatorRef.current
if (isProgressiveLayers) {
setEvents((prev) => {
let next = mergeProgressiveSearchEvents(prev, relayOnly, mergeCmp)
if (sessionSnap?.length && !userPulledRefresh) {
next = mergeProgressiveSearchEvents(next, sessionSnap, mergeCmp)
}
if (mergeCmp) {
next = [...next].sort(mergeCmp)
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
let merged = relayOnly
if (sessionSnap?.length && !userPulledRefresh) {
merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP)
}
if (oneShotDebugLabel) {
const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts,
rawTotal,
dedupedCount: byId.size,
afterCap: merged.length,
cap,
filterAuthors: f0?.authors,
filterKinds: f0?.kinds,
filterLimit: f0?.limit,
...(rawTotal === 0
? {
emptyHint:
'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses kinds 20/21/22/1222 only).'
}
: {})
})
}
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
}
if (oneShotDebugLabel) {
if (oneShotDebugLabel && isProgressiveLayers) {
const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, {
logger.info(`[${oneShotDebugLabel}] one-shot progressive relay merge`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts,
rawTotal,
dedupedCount: byId.size,
afterCap: merged.length,
cap,
filterAuthors: f0?.authors,
filterKinds: f0?.kinds,
filterLimit: f0?.limit,
...(rawTotal === 0
? {
emptyHint:
'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses kinds 20/21/22/1222 only).'
}
: {})
filterLimit: f0?.limit
})
}
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'one_shot_fetch',
mergedCount: merged.length,
mergedCount: relayOnly.length,
mergedWithPriorSession: !!(sessionSnap?.length && !userPulledRefresh)
}
} catch (err) {
@ -1516,10 +1698,15 @@ const NoteList = forwardRef( @@ -1516,10 +1698,15 @@ const NoteList = forwardRef(
mergedCount: 0,
fetchThrew: true
}
setEvents([])
if (!progressiveWarmupQueryRef.current?.trim()) {
setEvents([])
}
}
} finally {
if (effectActive) {
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
@ -1563,6 +1750,27 @@ const NoteList = forwardRef( @@ -1563,6 +1750,27 @@ const NoteList = forwardRef(
// New REQ wave (incl. delta relays with same feed key): outcomes stay stale until this wave ends.
setFeedSubscribeRelayOutcomes([])
const warmQLive = progressiveWarmupQueryRef.current?.trim()
if (warmQLive) {
setProgressiveLayersSearching(true)
kickProgressiveSearchLocalLayers({
warmQ: warmQLive,
isStale: () => !effectActive || timelineEffectStale(),
kindsForWarm: mergeKindsForProgressiveWarmup(
showKindsRef.current,
progressiveDocumentKindsRef.current
),
warmMatch: progressiveWarmupMatchRef.current,
afterSort: oneShotAfterMergeComparatorRef.current,
setEvents,
setLoading
})
}
if (timelineEffectStale()) {
if (warmQLive) setProgressiveLayersSearching(false)
return undefined
}
timelineSubscribePromise = client.subscribeTimeline(
mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{
@ -1601,19 +1809,17 @@ const NoteList = forwardRef( @@ -1601,19 +1809,17 @@ const NoteList = forwardRef(
}
if (batch.length > 0) {
if (narrowed.length > 0) {
if (preserveTimelineOnSubRequestsChange) {
setEvents((prev) => {
const next = mergeEventBatchesById(prev, narrowed, eventCap)
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
setEvents((prev) => {
const next = mergeEventBatchesById(prev, narrowed, eventCap)
lastEventsForTimelinePrefetchRef.current = next
return next
})
}
setEvents((prev) => {
const next = progressiveWarmupQueryRef.current?.trim()
? mergeProgressiveSearchEvents(
prev,
narrowed,
oneShotAfterMergeComparatorRef.current
)
: mergeEventBatchesById(prev, narrowed, eventCap)
lastEventsForTimelinePrefetchRef.current = next
return next
})
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false)
@ -1705,12 +1911,13 @@ const NoteList = forwardRef( @@ -1705,12 +1911,13 @@ const NoteList = forwardRef(
if (!seeAllFeedEventsRef.current && withKindFilterRef.current) {
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !showKinds.includes(event.kind)) return
if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !effectiveShowKindsRef.current.includes(event.kind))
return
if (
clientSideKindFilterRef.current &&
useFilterAsIsRef.current &&
!showKinds.includes(event.kind)
!effectiveShowKindsRef.current.includes(event.kind)
)
return
if (event.kind === kinds.ShortTextNote) {
@ -1743,6 +1950,9 @@ const NoteList = forwardRef( @@ -1743,6 +1950,9 @@ const NoteList = forwardRef(
onRelaySubscribeWaveComplete: (rows) => {
if (!effectActive) return
setFeedSubscribeRelayOutcomes(rows)
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
}
}
)
@ -1763,6 +1973,9 @@ const NoteList = forwardRef( @@ -1763,6 +1973,9 @@ const NoteList = forwardRef(
return closer
} catch (_error) {
setLoading(false)
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
if (effectActive) {
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
@ -1784,6 +1997,7 @@ const NoteList = forwardRef( @@ -1784,6 +1997,7 @@ const NoteList = forwardRef(
const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => {
effectActive = false
setProgressiveLayersSearching(false)
followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null
setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current)
@ -1825,7 +2039,8 @@ const NoteList = forwardRef( @@ -1825,7 +2039,8 @@ const NoteList = forwardRef(
showAllKinds,
withKindFilter,
onSingleRelayKindlessEmpty,
mapLiveSubRequestsForTimeline
mapLiveSubRequestsForTimeline,
progressiveWarmupQuery
])
useEffect(() => {
@ -1870,7 +2085,7 @@ const NoteList = forwardRef( @@ -1870,7 +2085,7 @@ const NoteList = forwardRef(
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs
return evs.filter((e) => showKindsRef.current.includes(e.kind))
return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
}
void (async () => {
@ -1936,11 +2151,12 @@ const NoteList = forwardRef( @@ -1936,11 +2151,12 @@ const NoteList = forwardRef(
const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !showKinds.includes(event.kind)) return
if (!useFilterAsIsRef.current && !effectiveShowKindsRef.current.includes(event.kind))
return
if (
clientSideKindFilterRef.current &&
useFilterAsIsRef.current &&
!showKinds.includes(event.kind)
!effectiveShowKindsRef.current.includes(event.kind)
)
return
if (event.kind === kinds.ShortTextNote) {
@ -2004,7 +2220,7 @@ const NoteList = forwardRef( @@ -2004,7 +2220,7 @@ const NoteList = forwardRef(
clientSideKindFilter,
startLogin,
pubkey,
showKinds,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111
@ -2289,7 +2505,7 @@ const NoteList = forwardRef( @@ -2289,7 +2505,7 @@ const NoteList = forwardRef(
!seeAllFeedEventsRef.current &&
(!allowKindlessRelayExploreRef.current || !showAllKindsRef.current)
let toAppend = narrowLoadMore
? fetchBatch.filter((e) => showKindsRef.current.includes(e.kind))
? fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
: fetchBatch
if (
@ -2301,7 +2517,7 @@ const NoteList = forwardRef( @@ -2301,7 +2517,7 @@ const NoteList = forwardRef(
for (let depth = 0; depth < 8 && toAppend.length === 0; depth++) {
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break
toAppend = fetchBatch.filter((e) => showKindsRef.current.includes(e.kind))
toAppend = fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
if (toAppend.length > 0) break
skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
}
@ -2745,6 +2961,7 @@ const NoteList = forwardRef( @@ -2745,6 +2961,7 @@ const NoteList = forwardRef(
const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null
const progressiveWarmupTrimmed = progressiveWarmupQuery?.trim()
const showRelaySubscribeWavePendingBanner =
!oneShotFetch &&
!feedFullSearchActive &&
@ -2753,7 +2970,11 @@ const NoteList = forwardRef( @@ -2753,7 +2970,11 @@ const NoteList = forwardRef(
timelineKey != null &&
feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady
const relayWavePendingBannerEl = showRelaySubscribeWavePendingBanner ? (
const showProgressiveLayersPendingBanner =
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive
const showLookingForMoreEventsBanner =
showRelaySubscribeWavePendingBanner || showProgressiveLayersPendingBanner
const relayWavePendingBannerEl = showLookingForMoreEventsBanner ? (
<div
className="mb-2 rounded border border-border/40 bg-muted/15 px-3 py-1.5 text-center text-xs text-muted-foreground"
role="status"

167
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -19,7 +19,7 @@ import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' @@ -19,7 +19,7 @@ import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle'
import ClientTag from '@/components/ClientTag'
import { ExtendedKind } from '@/constants'
import { applyImwaldAttributionTags, stripImwaldAttributionTags } from '@/lib/draft-event'
import { applyImwaldAttributionTags } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event'
import logger from '@/lib/logger'
import {
@ -54,6 +54,17 @@ function tagsFromRows(rows: string[][]): string[][] { @@ -54,6 +54,17 @@ function tagsFromRows(rows: string[][]): string[][] {
return out
}
const MAX_CUSTOM_EVENT_KIND = 40000
/** Integer kind in [0, 40000], or null if invalid / empty. */
function parseEventKindInput(s: string): number | null {
const trimmed = s.trim()
if (trimmed === '') return null
const n = Number(trimmed)
if (!Number.isInteger(n) || n < 0 || n > MAX_CUSTOM_EVENT_KIND) return null
return n
}
function StaticEventPreview({ event, className }: { event: Event; className?: string }) {
const k = event.kind
const wrap = (node: ReactNode) => (
@ -89,46 +100,67 @@ function StaticEventPreview({ event, className }: { event: Event; className?: st @@ -89,46 +100,67 @@ function StaticEventPreview({ event, className }: { event: Event; className?: st
export type TEditOrCloneMode = 'edit' | 'clone'
export default function EditOrCloneEventDialog({
open,
onOpenChange,
sourceEvent,
mode
}: {
open: boolean
onOpenChange: (open: boolean) => void
sourceEvent: Event
mode: TEditOrCloneMode
}) {
export type EditOrCloneEventDialogProps =
| {
open: boolean
onOpenChange: (open: boolean) => void
mode: 'create'
}
| {
open: boolean
onOpenChange: (open: boolean) => void
mode: TEditOrCloneMode
sourceEvent: Event
}
export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProps) {
const { open, onOpenChange, mode } = props
const isCreate = mode === 'create'
const sourceEvent = !isCreate ? props.sourceEvent : null
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const [content, setContent] = useState(sourceEvent.content)
const [content, setContent] = useState(() => sourceEvent?.content ?? '')
const [createKindInput, setCreateKindInput] = useState('1')
const [tagRows, setTagRows] = useState<string[][]>([['', '']])
const [activeTab, setActiveTab] = useState('edit')
const [publishing, setPublishing] = useState(false)
const prevOpenRef = useRef(false)
const kind = sourceEvent.kind
const parsedCreateKind = useMemo(
() => (isCreate ? parseEventKindInput(createKindInput) : null),
[isCreate, createKindInput]
)
const kind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent!.kind
useEffect(() => {
if (open && !prevOpenRef.current) {
setContent(sourceEvent.content)
setTagRows(
sourceEvent.tags?.length
? sourceEvent.tags.map((row) => [...row])
: [['', '']]
)
if (isCreate) {
setCreateKindInput('1')
setContent('')
setTagRows([['', '']])
} else if (sourceEvent) {
setContent(sourceEvent.content)
setTagRows(
sourceEvent.tags?.length
? sourceEvent.tags.map((row) => [...row])
: [['', '']]
)
}
setActiveTab('edit')
}
prevOpenRef.current = open
}, [open, sourceEvent])
}, [open, isCreate, sourceEvent])
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows])
const previewEvent = useMemo(() => {
if (isCreate && parsedCreateKind === null) return null
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
const now = Math.floor(Date.now() / 1000)
const base: TDraftEvent = {
kind,
kind: k,
content,
tags: normalizedTags,
created_at: now
@ -137,17 +169,21 @@ export default function EditOrCloneEventDialog({ @@ -137,17 +169,21 @@ export default function EditOrCloneEventDialog({
addClientTag: storage.getAddClientTag()
})
return createFakeEvent({
kind,
kind: k,
content,
tags: withAttribution.tags,
pubkey: pubkey ?? '',
created_at: now
})
}, [kind, content, normalizedTags, pubkey])
}, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags, pubkey])
const buildDraftJson = useCallback(() => {
if (isCreate && parsedCreateKind === null) {
return t('Enter a valid event kind (integer 0–40000).')
}
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
const base: TDraftEvent = {
kind,
kind: k,
content,
tags: normalizedTags,
created_at: dayjs().unix()
@ -164,7 +200,7 @@ export default function EditOrCloneEventDialog({ @@ -164,7 +200,7 @@ export default function EditOrCloneEventDialog({
_note: t('id and sig are assigned when you publish')
}
return JSON.stringify(draft, null, 2)
}, [pubkey, kind, content, normalizedTags, t])
}, [isCreate, parsedCreateKind, sourceEvent, pubkey, content, normalizedTags, t])
const draftJson = activeTab === 'json' ? buildDraftJson() : ''
@ -203,10 +239,18 @@ export default function EditOrCloneEventDialog({ @@ -203,10 +239,18 @@ export default function EditOrCloneEventDialog({
const handlePublish = async () => {
await checkLogin(async () => {
if (!pubkey) return
if (isCreate) {
const k = parseEventKindInput(createKindInput)
if (k === null) {
showPublishingError(t('Kind must be an integer from 0 to 40000.'))
return
}
}
setPublishing(true)
try {
const publishKind = isCreate ? parseEventKindInput(createKindInput)! : sourceEvent!.kind
const draft = {
kind,
kind: publishKind,
content,
tags: normalizedTags,
created_at: dayjs().unix()
@ -259,7 +303,11 @@ export default function EditOrCloneEventDialog({ @@ -259,7 +303,11 @@ export default function EditOrCloneEventDialog({
}
const title =
mode === 'edit' ? t('Edit this event') : t('Clone or fork this event')
mode === 'edit'
? t('Edit this event')
: mode === 'clone'
? t('Clone or fork this event')
: t('Create custom event')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -267,7 +315,9 @@ export default function EditOrCloneEventDialog({ @@ -267,7 +315,9 @@ export default function EditOrCloneEventDialog({
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14">
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only">
{t('Edit content and tags, then publish a new signed event.')}
{isCreate
? t('Set kind, content, and tags, then publish.')
: t('Edit content and tags, then publish a new signed event.')}
</DialogDescription>
</DialogHeader>
@ -284,14 +334,31 @@ export default function EditOrCloneEventDialog({ @@ -284,14 +334,31 @@ export default function EditOrCloneEventDialog({
<div className="space-y-4 pb-2">
<div className="space-y-1">
<label className="text-sm font-medium">{t('Event kind')}</label>
<Input
type="number"
value={kind}
disabled
readOnly
className="font-mono text-sm"
aria-readonly
/>
{isCreate ? (
<>
<Input
type="number"
min={0}
max={MAX_CUSTOM_EVENT_KIND}
step={1}
value={createKindInput}
onChange={(e) => setCreateKindInput(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{t('Integer from 0 to 40000')}
</p>
</>
) : (
<Input
type="number"
value={kind}
disabled
readOnly
className="font-mono text-sm"
aria-readonly
/>
)}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">{t('Note content')}</label>
@ -367,12 +434,20 @@ export default function EditOrCloneEventDialog({ @@ -367,12 +434,20 @@ export default function EditOrCloneEventDialog({
<TabsContent value="preview" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3">
<div className="space-y-1.5">
{storage.getAddClientTag() ? (
<div className="flex min-h-[1.125rem] items-center px-0.5">
<ClientTag event={previewEvent} />
</div>
) : null}
<StaticEventPreview event={previewEvent} />
{previewEvent ? (
<>
{storage.getAddClientTag() ? (
<div className="flex min-h-[1.125rem] items-center px-0.5">
<ClientTag event={previewEvent} />
</div>
) : null}
<StaticEventPreview event={previewEvent} />
</>
) : (
<p className="text-sm text-muted-foreground">
{t('Enter a valid event kind (integer 0–40000).')}
</p>
)}
</div>
</ScrollArea>
</TabsContent>
@ -391,7 +466,11 @@ export default function EditOrCloneEventDialog({ @@ -391,7 +466,11 @@ export default function EditOrCloneEventDialog({
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button type="button" onClick={handlePublish} disabled={publishing || !pubkey}>
<Button
type="button"
onClick={handlePublish}
disabled={publishing || !pubkey || (isCreate && parsedCreateKind === null)}
>
{publishing ? t('Loading...') : t('Publish')}
</Button>
</DialogFooter>

2
src/components/PostEditor/HighlightEditor.tsx

@ -112,8 +112,10 @@ export default function HighlightEditor({ @@ -112,8 +112,10 @@ export default function HighlightEditor({
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t('Highlight Settings')}</div>
<Button
type="button"
variant="ghost"
size="icon"
title={t('Close highlight editor')}
className="h-6 w-6"
onClick={() => setIsHighlight(false)}
>

6
src/components/PostEditor/Mentions.tsx

@ -64,8 +64,14 @@ export default function Mentions({ @@ -64,8 +64,14 @@ export default function Mentions({
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
className="px-3"
variant="ghost"
title={
potentialMentions.length === 0
? t('Mentions')
: `${t('Mentions')} (${mentions.length}/${potentialMentions.length})`
}
disabled={potentialMentions.length === 0}
onClick={(e) => e.stopPropagation()}
>

3
src/components/PostEditor/PollEditor.tsx

@ -68,6 +68,7 @@ export default function PollEditor({ @@ -68,6 +68,7 @@ export default function PollEditor({
type="button"
variant="ghost-destructive"
size="icon"
title={t('Remove option')}
onClick={() => handleRemoveOption(index)}
disabled={options.length <= 2}
>
@ -75,7 +76,7 @@ export default function PollEditor({ @@ -75,7 +76,7 @@ export default function PollEditor({
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={handleAddOption}>
<Button type="button" variant="outline" title={t('Add Option')} onClick={handleAddOption}>
{t('Add Option')}
</Button>
</div>

101
src/components/PostEditor/PostContent.tsx

@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
@ -59,6 +60,7 @@ import { @@ -59,6 +60,7 @@ import {
X,
Highlighter,
FileText,
HelpCircle,
Quote,
StickyNote,
Upload,
@ -109,6 +111,7 @@ import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDi @@ -109,6 +111,7 @@ import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDi
import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons'
import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor'
import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog'
export default function PostContent({
defaultContent = '',
@ -198,6 +201,7 @@ export default function PostContent({ @@ -198,6 +201,7 @@ export default function PostContent({
{ file: File; progress: number; cancel: () => void }[]
>([])
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [createCustomEventOpen, setCreateCustomEventOpen] = useState(false)
const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag())
const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false)
@ -2078,6 +2082,7 @@ export default function PostContent({ @@ -2078,6 +2082,7 @@ export default function PostContent({
variant="outline"
role="combobox"
aria-expanded={threadGroupPopoverOpen}
title={t('Select group...')}
className="h-9 w-full justify-between bg-background font-normal"
>
{threadSelectedGroup ? threadSelectedGroup : t('Select group...')}
@ -2150,6 +2155,7 @@ export default function PostContent({ @@ -2150,6 +2155,7 @@ export default function PostContent({
type="button"
variant="ghost"
size="sm"
title={threadShowReadingsPanel ? t('Hide') : t('Configure')}
onClick={() => setThreadShowReadingsPanel(!threadShowReadingsPanel)}
className="ml-auto"
>
@ -2817,26 +2823,17 @@ export default function PostContent({ @@ -2817,26 +2823,17 @@ export default function PostContent({
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
title={t('Create event with custom kind')}
onClick={() => checkLogin(() => setCreateCustomEventOpen(true))}
>
<HelpCircle className="h-4 w-4" />
</Button>
</>
)}
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => {
textareaRef.current?.insertText(memeUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
</>
}
/>
@ -2968,6 +2965,7 @@ export default function PostContent({ @@ -2968,6 +2965,7 @@ export default function PostContent({
<ImageUp />
</Button>
</Uploader>
<Separator orientation="vertical" className="h-6 shrink-0" />
{/* I'm not sure why, but after triggering the virtual keyboard,
opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */}
@ -2978,18 +2976,39 @@ export default function PostContent({ @@ -2978,18 +2976,39 @@ export default function PostContent({
textareaRef.current?.insertEmoji(emoji)
}}
>
<Button variant="ghost" size="icon">
<Button type="button" variant="ghost" size="icon" title={t('Insert emoji')}>
<Smile />
</Button>
</EmojiPickerDialog>
)}
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => {
textareaRef.current?.insertText(memeUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
<Separator orientation="vertical" className="h-6 shrink-0" />
<MentionAndEventToolbarButtons
insertAtCursor={(text) => textareaRef.current?.insertText(text)}
variant="ghost"
/>
<Button
type="button"
variant="ghost"
size="icon"
title={t('More options')}
className={showMoreOptions ? 'bg-accent' : ''}
onClick={() => setShowMoreOptions((pre) => !pre)}
>
@ -3005,7 +3024,9 @@ export default function PostContent({ @@ -3005,7 +3024,9 @@ export default function PostContent({
/>
<div className="flex gap-2 items-center max-sm:hidden">
<Button
type="button"
variant="outline"
title={t('Clear')}
onClick={(e) => {
e.stopPropagation()
handleClear()
@ -3014,7 +3035,9 @@ export default function PostContent({ @@ -3014,7 +3035,9 @@ export default function PostContent({
{t('Clear')}
</Button>
<Button
type="button"
variant="secondary"
title={t('Cancel')}
onClick={(e) => {
e.stopPropagation()
close()
@ -3022,7 +3045,20 @@ export default function PostContent({ @@ -3022,7 +3045,20 @@ export default function PostContent({
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
<Button
type="submit"
title={
parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')
}
disabled={!canPost}
onClick={post}
>
{posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)}
@ -3049,8 +3085,10 @@ export default function PostContent({ @@ -3049,8 +3085,10 @@ export default function PostContent({
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button
type="button"
className="w-full"
variant="outline"
title={t('Clear')}
onClick={(e) => {
e.stopPropagation()
handleClear()
@ -3059,8 +3097,10 @@ export default function PostContent({ @@ -3059,8 +3097,10 @@ export default function PostContent({
{t('Clear')}
</Button>
<Button
type="button"
className="w-full"
variant="secondary"
title={t('Cancel')}
onClick={(e) => {
e.stopPropagation()
close()
@ -3068,7 +3108,21 @@ export default function PostContent({ @@ -3068,7 +3108,21 @@ export default function PostContent({
>
{t('Cancel')}
</Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
<Button
className="w-full"
type="submit"
title={
parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')
}
disabled={!canPost}
onClick={post}
>
{posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)}
@ -3157,6 +3211,11 @@ export default function PostContent({ @@ -3157,6 +3211,11 @@ export default function PostContent({
</div>
</DialogContent>
</Dialog>
<EditOrCloneEventDialog
open={createCustomEventOpen}
onOpenChange={setCreateCustomEventOpen}
mode="create"
/>
</NeventPickerProvider>
</div>
)

6
src/components/PostEditor/PostRelaySelector.tsx

@ -431,12 +431,16 @@ export default function PostRelaySelector({ @@ -431,12 +431,16 @@ export default function PostRelaySelector({
{selectableRelays.length > 0 && (
<div className="flex gap-2 mb-2">
<button
type="button"
title={t('Select All')}
onClick={handleSelectAll}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('Select All')}
</button>
<button
type="button"
title={t('Clear All')}
onClick={handleClearAll}
className="text-xs text-muted-foreground hover:text-foreground"
>
@ -528,6 +532,7 @@ export default function PostRelaySelector({ @@ -528,6 +532,7 @@ export default function PostRelaySelector({
<Button
variant="outline"
size="sm"
title={triggerText}
className="h-8 px-3 text-xs justify-between min-w-0 flex-1"
>
<div className="flex items-center gap-2 min-w-0">
@ -564,6 +569,7 @@ export default function PostRelaySelector({ @@ -564,6 +569,7 @@ export default function PostRelaySelector({
<Button
variant="outline"
size="sm"
title={triggerText}
className="h-8 px-3 text-xs justify-between min-w-0 flex-1"
>
<div className="flex items-center gap-2 min-w-0">

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

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core'
@ -36,7 +37,7 @@ function searchStandardEmojiShortcodes(query: string): string[] { @@ -36,7 +37,7 @@ function searchStandardEmojiShortcodes(query: string): string[] {
const suggestion = {
items: async ({ query }: { query: string }) => {
const custom = await customEmojiService.searchEmojis(query)
const custom = await customEmojiService.searchEmojis(query, client.pubkey ?? null)
const customSet = new Set(custom)
const standard = searchStandardEmojiShortcodes(query).filter((s) => !customSet.has(s))
return [...custom, ...standard].slice(0, 50)

8
src/components/PostEditor/PostTextarea/index.tsx

@ -247,8 +247,12 @@ const PostTextarea = forwardRef< @@ -247,8 +247,12 @@ const PostTextarea = forwardRef<
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<TabsList className="w-auto justify-start">
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
<TabsTrigger value="json">{t('Json')}</TabsTrigger>
<TabsTrigger value="preview" title={t('Preview')}>
{t('Preview')}
</TabsTrigger>
<TabsTrigger value="json" title={t('Json')}>
{t('Json')}
</TabsTrigger>
</TabsList>
{headerActions && (
<div className="flex gap-1 items-center flex-wrap">

17
src/components/SearchBar/index.tsx

@ -151,9 +151,10 @@ const SearchBar = forwardRef< @@ -151,9 +151,10 @@ const SearchBar = forwardRef<
...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []),
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
...profiles.map((profile) => ({
type: 'profile',
type: 'profile' as const,
search: profile.npub,
input: profile.username
input: profile.username,
profile
})),
...(profiles.length >= 5 ? [{ type: 'profiles', search }] : [])
] as TSearchParams[])
@ -180,9 +181,10 @@ const SearchBar = forwardRef< @@ -180,9 +181,10 @@ const SearchBar = forwardRef<
if (option.type === 'profile') {
return (
<ProfileItem
key={index}
key={`profile-${option.search}`}
selected={selectedIndex === index}
userId={option.search}
prefetchedProfile={option.profile}
onClick={() => updateSearch(option)}
/>
)
@ -451,10 +453,12 @@ function NoteItem({ @@ -451,10 +453,12 @@ function NoteItem({
function ProfileItem({
userId,
prefetchedProfile,
onClick,
selected
}: {
userId: string
prefetchedProfile?: TSearchParams['profile']
onClick?: () => void
selected?: boolean
}) {
@ -463,7 +467,12 @@ function ProfileItem({ @@ -463,7 +467,12 @@ function ProfileItem({
className={cn('px-2 hover:bg-accent rounded-md cursor-pointer', selected && 'bg-accent')}
onClick={onClick}
>
<UserItem pubkey={userId} hideFollowButton className="pointer-events-none" />
<UserItem
pubkey={userId}
hideFollowButton
className="pointer-events-none"
prefetchedProfile={prefetchedProfile}
/>
</div>
)
}

15
src/components/SearchResult/index.tsx

@ -1,4 +1,10 @@ @@ -1,4 +1,10 @@
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
NIP_SEARCH_DOCUMENT_KINDS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { compareEventsForDTagQuery } from '@/lib/dtag-search'
import { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed'
import Profile from '../Profile'
@ -51,7 +57,12 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -51,7 +57,12 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
if (searchParams.type === 'notes') {
return (
<NormalFeed
subRequests={[{ urls: searchRelays, filter: { search: searchParams.search } }]}
subRequests={[
{ urls: searchRelays, filter: { search: searchParams.search, kinds: [...NIP_SEARCH_DOCUMENT_KINDS] } }
]}
progressiveWarmupQuery={searchParams.search}
progressiveDocumentKinds={NIP_SEARCH_DOCUMENT_KINDS}
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(searchParams.search, a, b)}
/>
)
}

2
src/components/SuggestedEmojis/index.tsx

@ -15,7 +15,7 @@ export default function SuggestedEmojis({ @@ -15,7 +15,7 @@ export default function SuggestedEmojis({
onMoreButtonClick: () => void
}) {
const [suggestedEmojis, setSuggestedEmojis] =
useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS)
useState<(string | TEmoji)[]>(() => [...DEFAULT_SUGGESTED_EMOJIS])
useEffect(() => {
try {

6
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
searchNpubsForMention,
type PickerSearchMode
} from '@/services/mention-event-search.service'
import { useNostr } from '@/providers/NostrProvider'
import customEmojiService from '@/services/custom-emoji.service'
import { searchStandardEmojiShortcodes } from '@/lib/emoji-content'
import { createPortal } from 'react-dom'
@ -52,6 +53,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -52,6 +53,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mentionQueryRef = useRef(mentionQuery)
const neventPicker = useNeventPicker()
const { pubkey } = useNostr()
mentionQueryRef.current = mentionQuery
const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null)
@ -200,7 +202,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -200,7 +202,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current)
emojiSearchTimeoutRef.current = setTimeout(() => {
Promise.all([
customEmojiService.searchEmojis(q),
customEmojiService.searchEmojis(q, pubkey ?? null),
Promise.resolve(searchStandardEmojiShortcodes(q, EMOJI_LIMIT))
]).then(([custom, standard]) => {
const customSet = new Set(custom)
@ -213,7 +215,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -213,7 +215,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
return () => {
if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current)
}
}, [emojiQuery])
}, [emojiQuery, pubkey])
const open = (emojiOpen && emojiItems.length > 0) || (mentionOpen && mentionItems.length > 0)
useEffect(() => {

28
src/components/UserAvatar/index.tsx

@ -4,6 +4,7 @@ import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' @@ -4,6 +4,7 @@ import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager'
import type { TProfile } from '@/types'
import { useMemo, useState, useEffect, useRef, type RefObject } from 'react'
/** Only defer network fetches for typical profile picture URLs (not data:, blob:, etc.). */
@ -75,13 +76,23 @@ const UserAvatarSizeCnMap = { @@ -75,13 +76,23 @@ const UserAvatarSizeCnMap = {
export default function UserAvatar({
userId,
className,
size = 'normal'
size = 'normal',
prefetchedProfile
}: {
userId: string
className?: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
/** Same pubkey as userId; use avatar from search/cache until fetch completes. */
prefetchedProfile?: TProfile
}) {
const { profile } = useFetchProfile(userId)
const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile
}, [userId, prefetchedProfile, fetchedProfile])
const { navigateToProfile } = useSmartProfileNavigationOptional()
// Extract pubkey from userId if it's npub/nprofile format
@ -169,13 +180,22 @@ export default function UserAvatar({ @@ -169,13 +180,22 @@ export default function UserAvatar({
export function SimpleUserAvatar({
userId,
size = 'normal',
className
className,
prefetchedProfile
}: {
userId: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string
prefetchedProfile?: TProfile
}) {
const { profile } = useFetchProfile(userId)
const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile
}, [userId, prefetchedProfile, fetchedProfile])
// Always generate default avatar from userId/pubkey, even if profile isn't loaded yet
const pubkey = useMemo(() => {
if (!userId) return ''

9
src/components/UserItem/index.tsx

@ -4,22 +4,27 @@ import UserAvatar from '@/components/UserAvatar' @@ -4,22 +4,27 @@ import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import type { TProfile } from '@/types'
export default function UserItem({
pubkey,
hideFollowButton,
className
className,
prefetchedProfile
}: {
pubkey: string
hideFollowButton?: boolean
className?: string
/** When the caller already loaded this profile (e.g. search index / DB), show it immediately. */
prefetchedProfile?: TProfile | null
}) {
return (
<div className={cn('flex gap-2 items-center h-14', className)}>
<UserAvatar userId={pubkey} className="shrink-0" />
<UserAvatar userId={pubkey} prefetchedProfile={prefetchedProfile ?? undefined} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username
userId={pubkey}
prefetchedProfile={prefetchedProfile ?? undefined}
className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4"
/>

16
src/components/Username/index.tsx

@ -4,6 +4,7 @@ import { toProfile } from '@/lib/link' @@ -4,6 +4,7 @@ import { toProfile } from '@/lib/link'
import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager'
import type { TProfile } from '@/types'
import { useMemo } from 'react'
export default function Username({
@ -13,7 +14,8 @@ export default function Username({ @@ -13,7 +14,8 @@ export default function Username({
skeletonClassName,
withoutSkeleton = false,
style,
onNavigate
onNavigate,
prefetchedProfile
}: {
userId: string
showAt?: boolean
@ -22,8 +24,16 @@ export default function Username({ @@ -22,8 +24,16 @@ export default function Username({
withoutSkeleton?: boolean
style?: React.CSSProperties
onNavigate?: () => void
prefetchedProfile?: TProfile
}) {
const { profile, isFetching } = useFetchProfile(userId)
const { profile: fetchedProfile, isFetching } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile
}, [userId, prefetchedProfile, fetchedProfile])
const { navigateToProfile } = useSmartProfileNavigationOptional()
// Get pubkey from userId (works even if profile isn't loaded)
@ -34,7 +44,7 @@ export default function Username({ @@ -34,7 +44,7 @@ export default function Username({
// Never block on profile fetch when we can already show npub/hex fallback (feeds batch-fetch profiles).
const canShowWithoutProfile = Boolean(pubkey)
if (isFetching && !withoutSkeleton && !canShowWithoutProfile) {
if (isFetching && !withoutSkeleton && !canShowWithoutProfile && !profile) {
return (
<div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} />

23
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { ensureYouTubeIframeApi } from '@/lib/youtube-iframe-api'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service'
@ -37,21 +38,12 @@ export default function YoutubeEmbeddedPlayer({ @@ -37,21 +38,12 @@ export default function YoutubeEmbeddedPlayer({
useEffect(() => {
if (!videoId || !containerRef.current || (!mustLoad && !display)) return
if (!window.YT) {
const script = document.createElement('script')
script.src = 'https://www.youtube.com/iframe_api'
document.body.appendChild(script)
let cancelled = false
window.onYouTubeIframeAPIReady = () => {
initPlayer()
}
} else {
initPlayer()
}
function initPlayer() {
void ensureYouTubeIframeApi().then(() => {
if (cancelled || !containerRef.current) return
try {
if (!videoId || !containerRef.current || !window.YT.Player) return
if (!videoId || !window.YT?.Player) return
playerRef.current = new window.YT.Player(containerRef.current, {
videoId: videoId,
playerVars: {
@ -74,13 +66,14 @@ export default function YoutubeEmbeddedPlayer({ @@ -74,13 +66,14 @@ export default function YoutubeEmbeddedPlayer({
} catch (error) {
logger.error('Failed to initialize YouTube player', { error })
setError(true)
return
}
}
})
return () => {
cancelled = true
if (playerRef.current) {
playerRef.current.destroy()
playerRef.current = null
}
}
}, [videoId, display, mustLoad])

13
src/constants.ts

@ -531,6 +531,19 @@ export function isDocumentRelayKind(kind: number): boolean { @@ -531,6 +531,19 @@ export function isDocumentRelayKind(kind: number): boolean {
return DOCUMENT_RELAY_KIND_SET.has(kind)
}
/**
* Long-form, wiki, and publication kinds always included in NIP-50 / d-tag search REQ and progressive local warmup.
* Kind 30041 (`PUBLICATION_CONTENT`) is deprioritized in `compareEventsForDTagQuery` unless the `d` tag is an exact
* match (case-insensitive).
*/
export const NIP_SEARCH_DOCUMENT_KINDS: readonly number[] = [
kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT
]
export function relayFilterIncludesDocumentRelayKind(filter: Filter): boolean {
const k = filter.kinds
if (k === undefined) return false

3
src/i18n/locales/de.ts

@ -764,6 +764,8 @@ export default { @@ -764,6 +764,8 @@ export default {
'Im Gegensatz zu regulären Notizen werden Umfragen nicht weit verbreitet unterstützt und werden möglicherweise nicht in anderen Clients angezeigt.',
'Option {{number}}': 'Option {{number}}',
'Add Option': 'Option hinzufügen',
'Remove option': 'Option entfernen',
'Close highlight editor': 'Highlight-Editor schließen',
'Allow multiple choices': 'Mehrfachauswahl erlauben',
'End Date (optional)': 'Enddatum (optional)',
'Clear end date': 'Enddatum löschen',
@ -1044,6 +1046,7 @@ export default { @@ -1044,6 +1046,7 @@ export default {
'Trending on the Default Relays': 'Trending auf den Standard-Relays',
'Latest from your follows': 'Neuestes von deinen Follows',
'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows',
'Search page title': 'Nostr durchsuchen',
'Follows latest page title': 'Neuestes von Follows',
'Follows latest page description':
'Aktuelle Notizen von Leuten, denen du folgst (ohne Konto: unsere kuratierte Liste). Wir führen Outbox-Relays aus ihren NIP-65-Listen mit deinen Favoriten zusammen und laden in Stapeln. Zeile aufklappen für Notizen oder Profil antippen.',

3
src/i18n/locales/en.ts

@ -761,6 +761,8 @@ export default { @@ -761,6 +761,8 @@ export default {
'Unlike regular notes, polls are not widely supported and may not display on other clients.',
'Option {{number}}': 'Option {{number}}',
'Add Option': 'Add Option',
'Remove option': 'Remove option',
'Close highlight editor': 'Close highlight editor',
'Allow multiple choices': 'Allow multiple choices',
'End Date (optional)': 'End Date (optional)',
'Clear end date': 'Clear end date',
@ -1042,6 +1044,7 @@ export default { @@ -1042,6 +1044,7 @@ export default {
'Trending on the Default Relays': 'Trending on the Default Relays',
'Latest from your follows': 'Latest from your follows',
'Latest from our recommended follows': 'Latest from our recommended follows',
'Search page title': 'Search Nostr',
'Follows latest page title': 'Latest from follows',
'Follows latest page description':
'Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.',

47
src/lib/dtag-search.ts

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
import { ExtendedKind } from '@/constants'
import type { Event } from 'nostr-tools'
export function getDTagValue(event: Event): string | undefined {
const t = event.tags.find((x) => x[0] === 'd' && x[1])?.[1]
return t
}
/** d-tag contains needle or note content contains needle (case-insensitive). */
export function eventMatchesDTagLooseQuery(needle: string, event: Event): boolean {
const q = needle.trim().toLowerCase()
if (!q) return true
const d = getDTagValue(event)?.toLowerCase() ?? ''
if (d.includes(q)) return true
if ((event.content ?? '').toLowerCase().includes(q)) return true
return false
}
/** Sort key: exact d-tag match first, then prefix, substring, then non-d / content-only. */
export function dTagMatchRank(needle: string, dVal: string | undefined): number {
if (!dVal) return 4
const nl = needle.trim().toLowerCase()
const dl = dVal.toLowerCase()
if (dl === nl) return 0
if (dl.startsWith(nl)) return 1
if (dl.includes(nl)) return 2
return 3
}
/** For merged lists: better d-tag match first; tie-break newest first. Kind 30041 sinks unless `d` equals the needle. */
export function compareEventsForDTagQuery(needle: string, a: Event, b: Event): number {
const nl = needle.trim().toLowerCase()
const ra = dTagMatchRank(needle, getDTagValue(a))
const rb = dTagMatchRank(needle, getDTagValue(b))
if (nl.length > 0) {
const kCh = ExtendedKind.PUBLICATION_CONTENT
const aExact = getDTagValue(a)?.toLowerCase() === nl
const bExact = getDTagValue(b)?.toLowerCase() === nl
const aBottom = a.kind === kCh && !aExact
const bBottom = b.kind === kCh && !bExact
if (aBottom !== bBottom) return aBottom ? 1 : -1
}
if (ra !== rb) return ra - rb
return b.created_at - a.created_at
}

55
src/lib/youtube-iframe-api.ts

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
/**
* Loads YouTubes iframe API exactly once. Multiple {@link YoutubeEmbeddedPlayer} instances must not each inject
* `https://www.youtube.com/iframe_api` that produced many duplicate &lt;script&gt; tags and broken callbacks.
*/
let iframeApiReadyPromise: Promise<void> | null = null
const IFRAME_API_URL = 'https://www.youtube.com/iframe_api'
function hasYtPlayer(): boolean {
return !!(window as Window & { YT?: { Player?: unknown } }).YT?.Player
}
function scriptAlreadyPresent(): boolean {
return !!document.querySelector(`script[src="${IFRAME_API_URL}"], script[src*="youtube.com/iframe_api"]`)
}
export function ensureYouTubeIframeApi(): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
if (hasYtPlayer()) return Promise.resolve()
if (!iframeApiReadyPromise) {
iframeApiReadyPromise = new Promise<void>((resolve) => {
const tryResolve = () => {
if (hasYtPlayer()) resolve()
}
const chainReady = () => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
tryResolve()
}
}
if (scriptAlreadyPresent()) {
chainReady()
const poll = () => {
tryResolve()
if (hasYtPlayer()) return
requestAnimationFrame(poll)
}
poll()
return
}
chainReady()
const script = document.createElement('script')
script.src = IFRAME_API_URL
script.async = true
document.body.appendChild(script)
})
}
return iframeApiReadyPromise
}

30
src/pages/primary/FollowsLatestPage/index.tsx

@ -2,6 +2,7 @@ import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' @@ -2,6 +2,7 @@ import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { UsersRound } from 'lucide-react'
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,21 +28,13 @@ const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref @@ -27,21 +28,13 @@ const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref
<PrimaryPageLayout
ref={layoutRef}
pageName="follows-latest"
titlebar={null}
titlebar={<FollowsLatestPageTitlebar onRefresh={bumpRefresh} />}
displayScrollToTopButton
>
<div className="min-w-0 pt-4 px-4 pb-8">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<h1 className="text-2xl font-bold tracking-tight">{t('Follows latest page title')}</h1>
<p className="max-w-prose text-sm text-muted-foreground leading-relaxed">
{t('Follows latest page description')}
</p>
</div>
<div className="shrink-0 self-start sm:self-center">
<RefreshButton onClick={bumpRefresh} />
</div>
</div>
<p className="mb-4 max-w-prose text-sm text-muted-foreground leading-relaxed">
{t('Follows latest page description')}
</p>
<LatestFromFollowsSection refreshKey={refreshKey} variant="page" />
</div>
</PrimaryPageLayout>
@ -50,3 +43,16 @@ const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref @@ -50,3 +43,16 @@ const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref
FollowsLatestPage.displayName = 'FollowsLatestPage'
export default FollowsLatestPage
function FollowsLatestPageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<UsersRound className="size-5" />
<div className="app-chrome-title">{t('Follows latest page title')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div>
)
}

22
src/pages/primary/SearchPage/index.tsx

@ -6,9 +6,10 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' @@ -6,9 +6,10 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useNostr } from '@/providers/NostrProvider'
import { TPageRef, TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react'
import { BookOpen, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef<TPageRef>((_props, ref) => {
const { current, display } = usePrimaryPage()
@ -54,14 +55,10 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => { @@ -54,14 +55,10 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
<PrimaryPageLayout
ref={layoutRef}
pageName="search"
titlebar={null}
titlebar={<SearchPageTitlebar onRefresh={bumpResults} />}
displayScrollToTopButton
>
<div className="min-w-0 pt-4 px-4 pb-4">
<div className="mb-4 flex items-center justify-between gap-2">
<div className="text-2xl font-bold">Search Nostr</div>
<RefreshButton onClick={bumpResults} />
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative order-2 sm:order-1">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
@ -93,3 +90,16 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => { @@ -93,3 +90,16 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
})
SearchPage.displayName = 'SearchPage'
export default SearchPage
function SearchPageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<Search className="size-5" />
<div className="app-chrome-title">{t('Search page title')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div>
)
}

53
src/pages/secondary/NoteListPage/index.tsx

@ -3,13 +3,14 @@ import type { TNoteListRef } from '@/components/NoteList' @@ -3,13 +3,14 @@ import type { TNoteListRef } from '@/components/NoteList'
import NormalFeed from '@/components/NormalFeed'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import { isSocialKindBlockedKind, SEARCHABLE_RELAY_URLS } from '@/constants'
import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants'
import {
augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox
} from '@/lib/favorites-feed-relays'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager'
@ -191,30 +192,31 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -191,30 +192,31 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
setSubRequests([])
}
} else {
// D-tag search - filter events by d-tag value
// D-tag browse: NIP-50 search + exact #d REQ (merged), substring match client-side, exact d-tag sorted first
setTitle(`D-Tag: ${domain}`)
setData({
type: 'dtag',
dtag: domain,
kinds: kinds.length > 0 ? kinds : undefined
})
// Filter by d-tag - we'll need to fetch events that have this d-tag
// For replaceable events, the d-tag is in the 'd' tag position
const filter: any = {
'#d': [domain]
}
if (kinds.length > 0) {
filter.kinds = kinds
}
const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
readUrlOpts
)
const mergedReqKinds = Array.from(
new Set([...NIP_SEARCH_DOCUMENT_KINDS, ...(kinds.length > 0 ? kinds : [])])
).sort((a, b) => a - b)
const kindFilter = { kinds: mergedReqKinds }
setSubRequests([
{
filter,
urls: getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
readUrlOpts
)
filter: { search: domain, ...kindFilter },
urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])]
},
{
filter: { '#d': [domain], ...kindFilter },
urls: relayUrls
}
])
}
@ -296,7 +298,22 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid @@ -296,7 +298,22 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
</div>
)
} else if (data) {
content = <NormalFeed ref={feedRef} subRequests={subRequests} />
content =
data.type === 'dtag' && data.dtag ? (
<NormalFeed
ref={feedRef}
subRequests={subRequests}
oneShotFetch
progressiveWarmupQuery={data.dtag}
progressiveWarmupMatch={(ev) => eventMatchesDTagLooseQuery(data.dtag!, ev)}
progressiveDocumentKinds={NIP_SEARCH_DOCUMENT_KINDS}
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(data.dtag!, a, b)}
extraShouldHideEvent={(ev) => !eventMatchesDTagLooseQuery(data.dtag!, ev)}
oneShotMergedCap={400}
/>
) : (
<NormalFeed ref={feedRef} subRequests={subRequests} />
)
}
const titlebarExtras = controls

8
src/providers/NostrProvider/index.tsx

@ -805,8 +805,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -805,8 +805,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}, [account])
useEffect(() => {
customEmojiService.init(userEmojiListEvent)
}, [userEmojiListEvent])
if (!account?.pubkey) {
void customEmojiService.init(null, null)
return
}
void customEmojiService.init(userEmojiListEvent, account.pubkey)
}, [userEmojiListEvent, account?.pubkey])
/**
* If session restore temporarily fell back to read-only (`npub`) while the stored

14
src/services/client-events.service.ts

@ -454,14 +454,22 @@ export class EventService { @@ -454,14 +454,22 @@ export class EventService {
*/
getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] {
const results: NEvent[] = []
const queryLower = query.toLowerCase()
const queryTrim = query.trim()
const queryLower = queryTrim.toLowerCase()
for (const [, event] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(event)) continue
if (allowedKinds && !allowedKinds.includes(event.kind)) continue
const content = event.content.toLowerCase()
if (content.includes(queryLower)) {
if (queryTrim === '') {
results.push(event)
if (results.length >= limit) break
continue
}
const content = (event.content ?? '').toLowerCase()
const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(queryLower) || tagsStr.includes(queryLower)) {
results.push(event)
if (results.length >= limit) break
}

48
src/services/client.service.ts

@ -3364,9 +3364,51 @@ class ClientService extends EventTarget { @@ -3364,9 +3364,51 @@ class ClientService extends EventTarget {
return this.replaceableEventService.forceRefreshProfileAndPaymentInfoCache(pubkey)
}
async fetchEmojiSetEvents(_pointers: string[]) {
// Implementation would use replaceableEventService
return []
/**
* Resolve `a` tags (kind:pubkey:d) pointing at kind 30030 emoji packs into events.
*/
async fetchEmojiSetEvents(pointers: string[]): Promise<NEvent[]> {
if (!pointers?.length) return []
const out: NEvent[] = []
for (const coord of pointers) {
const parts = coord.split(':')
if (parts.length < 3) continue
const kind = parseInt(parts[0]!, 10)
const authorPk = parts[1]?.trim().toLowerCase()
if (!authorPk || Number.isNaN(kind)) continue
const d = parts.slice(2).join(':')
try {
const ev = await this.replaceableEventService.fetchReplaceableEvent(authorPk, kind, d)
if (ev) out.push(ev)
} catch {
/* ignore per-pointer failures */
}
}
return out
}
/**
* Kind 10030 (user emoji list) + 30030 (emoji packs) for an author used to populate the custom emoji picker.
*/
async fetchAuthorEmojiInventory(pubkey: string): Promise<NEvent[]> {
const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return []
const relayList = await this.fetchRelayList(pk)
const urls = dedupeNormalizeRelayUrlsOrdered([
...relayList.write.map((u) => normalizeUrl(u) || u),
...relayList.read.map((u) => normalizeUrl(u) || u),
...relayList.httpRead.map((u) => normalizeHttpRelayUrl(u) || u),
...relayList.httpWrite.map((u) => normalizeHttpRelayUrl(u) || u),
...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u)
]).filter(Boolean)
const capped = urls.slice(0, 20)
if (capped.length === 0) return []
return this.queryService.fetchEvents(capped, {
kinds: [kinds.UserEmojiList, kinds.Emojisets],
authors: [pk],
limit: 80
})
}
/** =========== Following favorite relays =========== */

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

@ -6,15 +6,18 @@ import { sha256 } from '@noble/hashes/sha2' @@ -6,15 +6,18 @@ import { sha256 } from '@noble/hashes/sha2'
import { SkinTones } from 'emoji-picker-react'
import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
import FlexSearch from 'flexsearch'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
class CustomEmojiService {
static instance: CustomEmojiService
private emojiMap = new Map<string, TEmoji>()
private emojiIndex = new FlexSearch.Index({
/** Hex pubkey (lowercase) of the event that introduced each custom emoji into the index. */
private emojiAuthorById = new Map<string, string>()
private emojiIndex: FlexSearch.Index = new FlexSearch.Index({
tokenize: 'full'
})
private indexUpdateListeners = new Set<() => void>()
constructor() {
if (!CustomEmojiService.instance) {
@ -23,23 +26,94 @@ class CustomEmojiService { @@ -23,23 +26,94 @@ class CustomEmojiService {
return CustomEmojiService.instance
}
async init(userEmojiListEvent: Event | null) {
if (!userEmojiListEvent) return
/** Subscribe to runs after {@link init} finishes loading emoji sets (picker can refresh custom list). */
subscribeIndexUpdate(fn: () => void): () => void {
this.indexUpdateListeners.add(fn)
return () => this.indexUpdateListeners.delete(fn)
}
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent)
await this.addEmojisToIndex(emojis)
private notifyIndexUpdate() {
this.indexUpdateListeners.forEach((f) => f())
}
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers)
await Promise.allSettled(
emojiSetEvents.map(async (event) => {
if (!event || (event as any) instanceof Error) return
private reset() {
this.emojiMap.clear()
this.emojiAuthorById.clear()
this.emojiIndex = new FlexSearch.Index({ tokenize: 'full' })
}
/**
* Load NIP-30 emoji sets (kind 10030) and packs (30030) for the account.
* Merges `userEmojiListEvent` with a relay fetch so we still load when hydrate missed the event
* (same idea as aitherboards picker: fetch author emoji kinds from read relays).
*/
async init(userEmojiListEvent: Event | null, accountPubkey?: string | null) {
this.reset()
const pk = accountPubkey?.trim().toLowerCase() ?? ''
const hasPk = /^[0-9a-f]{64}$/.test(pk)
const byId = new Map<string, Event>()
if (
userEmojiListEvent &&
hasPk &&
userEmojiListEvent.pubkey.trim().toLowerCase() === pk
) {
byId.set(userEmojiListEvent.id, userEmojiListEvent)
}
if (hasPk) {
const remote = await client.fetchAuthorEmojiInventory(pk).catch(() => [] as Event[])
for (const ev of remote) {
byId.set(ev.id, ev)
}
}
const events = [...byId.values()]
if (events.length === 0) {
this.notifyIndexUpdate()
return
}
await this.addEmojisToIndex(getEmojisFromEvent(event))
const listEvents = events
.filter((e) => e.kind === kinds.UserEmojiList)
.sort((a, b) => b.created_at - a.created_at)
const latestList = listEvents[0] ?? null
const packEvents = events.filter((e) => e.kind === kinds.Emojisets)
if (latestList) {
const authorPk = latestList.pubkey.toLowerCase()
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(latestList)
await this.addEmojisToIndex(emojis, authorPk)
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers)
await Promise.allSettled(
emojiSetEvents.map(async (event) => {
if (!event || (event as any) instanceof Error) return
await this.addEmojisToIndex(getEmojisFromEvent(event), event.pubkey.toLowerCase())
})
)
}
await Promise.allSettled(
packEvents.map(async (pack) => {
await this.addEmojisToIndex(getEmojisFromEvent(pack), pack.pubkey.toLowerCase())
})
)
this.notifyIndexUpdate()
}
async searchEmojis(query: string = ''): Promise<string[]> {
private sortEmojiIdsForViewer(ids: string[], viewerPubkeyLower: string): string[] {
if (!viewerPubkeyLower) return ids
const own: string[] = []
const rest: string[] = []
for (const id of ids) {
if (this.emojiAuthorById.get(id) === viewerPubkeyLower) own.push(id)
else rest.push(id)
}
return [...own, ...rest]
}
async searchEmojis(query: string = '', viewerPubkey?: string | null): Promise<string[]> {
const v = viewerPubkey?.toLowerCase() ?? ''
if (!query) {
const idSet = new Set<string>()
getSuggested()
@ -48,18 +122,17 @@ class CustomEmojiService { @@ -48,18 +122,17 @@ class CustomEmojiService {
.forEach((item) => {
if (item && typeof item !== 'string') {
const id = this.getEmojiId(item)
if (!idSet.has(id)) {
idSet.add(id)
}
idSet.add(id)
}
})
for (const key of this.emojiMap.keys()) {
idSet.add(key)
}
return Array.from(idSet)
return this.sortEmojiIdsForViewer(Array.from(idSet), v)
}
const results = await this.emojiIndex.searchAsync(query)
return results.filter((id) => typeof id === 'string') as string[]
const filtered = results.filter((id) => typeof id === 'string') as string[]
return this.sortEmojiIdsForViewer(filtered, v)
}
getEmojiById(id?: string): TEmoji | undefined {
@ -68,23 +141,37 @@ class CustomEmojiService { @@ -68,23 +141,37 @@ class CustomEmojiService {
return this.emojiMap.get(id)
}
getAllCustomEmojisForPicker() {
return Array.from(this.emojiMap.values()).map((emoji) => ({
id: `:${emoji.shortcode}:${emoji.url}`,
imgUrl: emoji.url,
names: [emoji.shortcode]
getAllCustomEmojisForPicker(viewerPubkey?: string | null) {
const v = viewerPubkey?.toLowerCase() ?? ''
const rows = Array.from(this.emojiMap.entries()).map(([hashId, emoji]) => ({
row: {
id: `:${emoji.shortcode}:${emoji.url}`,
imgUrl: emoji.url,
names: [emoji.shortcode] as [string]
},
author: this.emojiAuthorById.get(hashId) ?? ''
}))
rows.sort((a, b) => {
if (v) {
const aOwn = a.author === v ? 0 : 1
const bOwn = b.author === v ? 0 : 1
if (aOwn !== bOwn) return aOwn - bOwn
}
return a.row.names[0].localeCompare(b.row.names[0])
})
return rows.map((r) => r.row)
}
isCustomEmojiId(shortcode: string) {
return this.emojiMap.has(shortcode)
}
private async addEmojisToIndex(emojis: TEmoji[]) {
private async addEmojisToIndex(emojis: TEmoji[], authorPubkeyLower: string) {
await Promise.allSettled(
emojis.map(async (emoji) => {
const id = this.getEmojiId(emoji)
this.emojiMap.set(id, emoji)
this.emojiAuthorById.set(id, authorPubkeyLower)
await this.emojiIndex.addAsync(id, emoji.shortcode)
})
)

18
src/services/gif.service.ts

@ -45,6 +45,19 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean { @@ -45,6 +45,19 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean {
return true
}
/** Own GIFs/memes first, then newest first (picker grids). */
export function sortGifsForPicker(gifs: GifMetadata[], userPubkey: string | null): GifMetadata[] {
const u = userPubkey?.toLowerCase() ?? ''
return [...gifs].sort((a, b) => {
if (u) {
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0
if (aOwn !== bOwn) return bOwn - aOwn
}
return b.createdAt - a.createdAt
})
}
/** Normalize a GIF URL for deduplication: strip fragment and query, lowercase. */
function normalizeGifUrl(url: string): string {
try {
@ -237,7 +250,7 @@ export async function fetchGifs( @@ -237,7 +250,7 @@ export async function fetchGifs(
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {
return cached.gifs.slice(0, limit) as GifMetadata[]
return sortGifsForPicker(cached.gifs as GifMetadata[], userPubkey).slice(0, limit)
}
}
@ -312,8 +325,7 @@ export async function fetchGifs( @@ -312,8 +325,7 @@ export async function fetchGifs(
}
const gifs = Array.from(byUrl.values()).map((v) => v.gif)
gifs.sort((a, b) => b.createdAt - a.createdAt)
const result = gifs.slice(0, limit)
const result = sortGifsForPicker(gifs, userPubkey).slice(0, limit)
if (result.length >= MIN_GIF_CACHE_ENTRIES && !searchQuery) {
await indexedDb.setGifCache(result, Date.now())

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

@ -1170,6 +1170,77 @@ class IndexedDbService { @@ -1170,6 +1170,77 @@ class IndexedDbService {
})
}
/**
* Publication store + hot {@link StoreNames.EVENT_ARCHIVE}: events whose kind is allowed and content or any tag
* value matches the query (case-insensitive). Used to show local hits before NIP-50 relay results.
*/
async getCachedAndArchivedEventsMatchingLocalSearch(
query: string,
limit: number,
allowedKinds: number[],
options?: { archiveScanMaxMs?: number }
): Promise<Event[]> {
const fromPub = await this.getCachedEventsForSearch(query, limit, allowedKinds)
if (fromPub.length >= limit) return fromPub.slice(0, limit)
const q = query.trim().toLowerCase()
if (!q || allowedKinds.length === 0) return fromPub
const kindSet = new Set(allowedKinds)
const seen = new Set(fromPub.map((e) => e.id))
const rest: Event[] = []
const scanStart = Date.now()
const archiveScanMaxMs = options?.archiveScanMaxMs
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) {
return fromPub
}
await new Promise<void>((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
const store = transaction.objectStore(StoreNames.EVENT_ARCHIVE)
const request = store.openCursor()
request.onsuccess = () => {
if (
archiveScanMaxMs !== undefined &&
Date.now() - scanStart >= archiveScanMaxMs
) {
transaction.commit()
resolve()
return
}
const cursor = (request as IDBRequest<IDBCursorWithValue>).result
if (!cursor || fromPub.length + rest.length >= limit) {
transaction.commit()
resolve()
return
}
const row = cursor.value as TArchivedEventRow
const ev = row?.value
if (ev && kindSet.has(ev.kind) && !seen.has(ev.id)) {
const content = (ev.content ?? '').toLowerCase()
const tagsStr = (ev.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(q) || tagsStr.includes(q)) {
seen.add(ev.id)
rest.push(ev)
}
}
cursor.continue()
}
request.onerror = (e) => {
transaction.commit()
reject(idbEventToError(e))
}
}).catch((e: unknown) => {
logger.warn('[indexedDb] getCachedAndArchivedEventsMatchingLocalSearch archive scan failed', { e })
})
return [...fromPub, ...rest].slice(0, limit)
}
async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts
await this.initPromise

22
src/services/meme.service.ts

@ -52,6 +52,19 @@ function isStaticMemeUrl(mimeType: string | undefined, url: string): boolean { @@ -52,6 +52,19 @@ function isStaticMemeUrl(mimeType: string | undefined, url: string): boolean {
return /\.(jpe?g|png|webp)(\?|$)/i.test(url)
}
/** Own templates first, then newest first (picker grid). */
export function sortMemesForPicker(memes: MemeMetadata[], userPubkey: string | null): MemeMetadata[] {
const u = userPubkey?.toLowerCase() ?? ''
return [...memes].sort((a, b) => {
if (u) {
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0
if (aOwn !== bOwn) return bOwn - aOwn
}
return b.createdAt - a.createdAt
})
}
function normalizeMemeUrl(url: string): string {
try {
const withoutFragment = url.split('#')[0].trim()
@ -262,7 +275,7 @@ export async function fetchMemes( @@ -262,7 +275,7 @@ export async function fetchMemes(
cached.memes.length >= MIN_MEME_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {
return cached.memes.slice(0, limit) as MemeMetadata[]
return sortMemesForPicker(cached.memes as MemeMetadata[], userPubkey).slice(0, limit)
}
}
@ -308,7 +321,7 @@ export async function fetchMemes( @@ -308,7 +321,7 @@ export async function fetchMemes(
)
} catch (err) {
if (!searchQuery && staleFallback?.length) {
return staleFallback.slice(0, limit) as MemeMetadata[]
return sortMemesForPicker(staleFallback as MemeMetadata[], userPubkey).slice(0, limit)
}
throw err
}
@ -335,11 +348,10 @@ export async function fetchMemes( @@ -335,11 +348,10 @@ export async function fetchMemes(
}
const memes = Array.from(byUrl.values()).map((v) => v.meme)
memes.sort((a, b) => b.createdAt - a.createdAt)
let result = memes.slice(0, limit)
let result = sortMemesForPicker(memes, userPubkey).slice(0, limit)
if (!searchQuery && result.length === 0 && staleFallback?.length) {
result = staleFallback.slice(0, limit)
result = sortMemesForPicker(staleFallback as MemeMetadata[], userPubkey).slice(0, limit)
}
if (result.length > 0 && !searchQuery) {

2
src/types/index.d.ts vendored

@ -235,6 +235,8 @@ export type TSearchParams = { @@ -235,6 +235,8 @@ export type TSearchParams = {
type: TSearchType
search: string
input?: string
/** Present for profile rows from typeahead; avoids redundant fetch and shows cached avatar/name immediately. */
profile?: TProfile
}
export type TNotificationStyle =

Loading…
Cancel
Save