From 83f6cee99172b103adddf56bc02915edb46b87d5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 4 Apr 2026 10:32:27 +0200 Subject: [PATCH] bug-fixes make search more thorough fix blossom image rendering adjust pickers add generic event creator --- src/components/EmojiPicker/index.tsx | 33 +- src/components/EmojiPickerDialog/index.tsx | 23 +- src/components/NormalFeed/index.tsx | 27 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 46 ++- src/components/NoteList/index.tsx | 333 +++++++++++++++--- .../NoteOptions/EditOrCloneEventDialog.tsx | 167 ++++++--- src/components/PostEditor/HighlightEditor.tsx | 2 + src/components/PostEditor/Mentions.tsx | 6 + src/components/PostEditor/PollEditor.tsx | 3 +- src/components/PostEditor/PostContent.tsx | 101 ++++-- .../PostEditor/PostRelaySelector.tsx | 6 + .../PostTextarea/Emoji/suggestion.ts | 3 +- .../PostEditor/PostTextarea/index.tsx | 8 +- src/components/SearchBar/index.tsx | 17 +- src/components/SearchResult/index.tsx | 15 +- src/components/SuggestedEmojis/index.tsx | 2 +- .../TextareaWithMentionAutocomplete/index.tsx | 6 +- src/components/UserAvatar/index.tsx | 28 +- src/components/UserItem/index.tsx | 9 +- src/components/Username/index.tsx | 16 +- .../YoutubeEmbeddedPlayer/index.tsx | 23 +- src/constants.ts | 13 + src/i18n/locales/de.ts | 3 + src/i18n/locales/en.ts | 3 + src/lib/dtag-search.ts | 47 +++ src/lib/youtube-iframe-api.ts | 55 +++ src/pages/primary/FollowsLatestPage/index.tsx | 30 +- src/pages/primary/SearchPage/index.tsx | 22 +- src/pages/secondary/NoteListPage/index.tsx | 53 ++- src/providers/NostrProvider/index.tsx | 8 +- src/services/client-events.service.ts | 18 +- src/services/client.service.ts | 48 ++- src/services/custom-emoji.service.ts | 133 +++++-- src/services/gif.service.ts | 18 +- src/services/indexed-db.service.ts | 71 ++++ src/services/meme.service.ts | 22 +- src/types/index.d.ts | 2 + 37 files changed, 1173 insertions(+), 247 deletions(-) create mode 100644 src/lib/dtag-search.ts create mode 100644 src/lib/youtube-iframe-api.ts diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx index 7df81692..8d9b1d7c 100644 --- a/src/components/EmojiPicker/index.tsx +++ b/src/components/EmojiPicker/index.tsx @@ -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, { 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({ }) { 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 ( diff --git a/src/components/EmojiPickerDialog/index.tsx b/src/components/EmojiPickerDialog/index.tsx index 6c8c40bb..29ec71e0 100644 --- a/src/components/EmojiPickerDialog/index.tsx +++ b/src/components/EmojiPickerDialog/index.tsx @@ -28,6 +28,7 @@ export default function EmojiPickerDialog({ {children} { const t = e.target as HTMLElement | null if (t?.closest?.('[data-vaul-overlay]')) return @@ -37,13 +38,15 @@ export default function EmojiPickerDialog({ Emoji Picker - { - e.stopPropagation() - setOpen(false) - onEmojiClick?.(emoji) - }} - /> +
+ { + e.stopPropagation() + setOpen(false) + onEmojiClick?.(emoji) + }} + /> +
) @@ -52,7 +55,11 @@ export default function EmojiPickerDialog({ return ( {children} - + { e.stopPropagation() diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index ca616f76..6aeab324 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -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 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 diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 1bc0532b..1cb0e806 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -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( 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 ( +
+ { + e.stopPropagation() + if (imageIndex !== undefined) { + openLightbox(imageIndex) + } + }} + /> +
+ ) + } + const hashtagsInContent = new Set() const footnotes = new Map() const citations: Array<{ id: string; type: string; citationId: string }> = [] @@ -3250,6 +3288,9 @@ function parseMarkdownContentMarked( ) } + if (isImage(cleaned) && isSafeMediaUrl(cleaned)) { + return renderStandaloneHttpsImageBlock(cleaned, `${key}-line-img-${lineIdx}`) + } if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { return (

@@ -3387,6 +3428,9 @@ function parseMarkdownContentMarked( ) } + if (isImage(cleaned) && isSafeMediaUrl(cleaned)) { + return renderStandaloneHttpsImageBlock(cleaned, `${key}-para-img`) + } if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { return (

diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 625017b8..22c2d699 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -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 { 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 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 .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() + 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> + 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( 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( 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( const [newEvents, setNewEvents] = useState([]) const [hasMore, setHasMore] = useState(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(undefined) const [refreshCount, setRefreshCount] = useState(0) const [showCount, setShowCount] = useState(SHOW_COUNT) @@ -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( areAlgoRelays, clientSideKindFilter, seeAllFeedEvents, - showKinds, + effectiveShowKinds, useFilterAsIs ] ) @@ -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( 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( 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( 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( feedFullSearchEvents, newEvents, shouldHideEvent, - showKinds, + effectiveShowKinds, showKind1OPs, showKind1Replies, showKind1111, @@ -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( } } 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( !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( 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( // 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( } 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( 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( onRelaySubscribeWaveComplete: (rows) => { if (!effectActive) return setFeedSubscribeRelayOutcomes(rows) + if (progressiveWarmupQueryRef.current?.trim()) { + setProgressiveLayersSearching(false) + } } } ) @@ -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( const snapshotKeyForCleanup = sessionSnapshotIdentityKey return () => { effectActive = false + setProgressiveLayersSearching(false) followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current = null setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) @@ -1825,7 +2039,8 @@ const NoteList = forwardRef( showAllKinds, withKindFilter, onSingleRelayKindlessEmpty, - mapLiveSubRequestsForTimeline + mapLiveSubRequestsForTimeline, + progressiveWarmupQuery ]) useEffect(() => { @@ -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( 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( clientSideKindFilter, startLogin, pubkey, - showKinds, + effectiveShowKinds, showKind1OPs, showKind1Replies, showKind1111 @@ -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( 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( const listSourceEvents = timelineEventsForFilter const feedFullSearchActive = feedFullSearchEvents !== null + const progressiveWarmupTrimmed = progressiveWarmupQuery?.trim() const showRelaySubscribeWavePendingBanner = !oneShotFetch && !feedFullSearchActive && @@ -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 ? (

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 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([['', '']]) 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({ 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({ _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({ 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({ } 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 ( @@ -267,7 +315,9 @@ export default function EditOrCloneEventDialog({ {title} - {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.')} @@ -284,14 +334,31 @@ export default function EditOrCloneEventDialog({
- + {isCreate ? ( + <> + setCreateKindInput(e.target.value)} + className="font-mono text-sm" + /> +

+ {t('Integer from 0 to 40000')} +

+ + ) : ( + + )}
@@ -367,12 +434,20 @@ export default function EditOrCloneEventDialog({
- {storage.getAddClientTag() ? ( -
- -
- ) : null} - + {previewEvent ? ( + <> + {storage.getAddClientTag() ? ( +
+ +
+ ) : null} + + + ) : ( +

+ {t('Enter a valid event kind (integer 0–40000).')} +

+ )}
@@ -391,7 +466,11 @@ export default function EditOrCloneEventDialog({ - diff --git a/src/components/PostEditor/HighlightEditor.tsx b/src/components/PostEditor/HighlightEditor.tsx index 22998948..3e2c9291 100644 --- a/src/components/PostEditor/HighlightEditor.tsx +++ b/src/components/PostEditor/HighlightEditor.tsx @@ -112,8 +112,10 @@ export default function HighlightEditor({
{t('Highlight Settings')}
))} -
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index a676e542..63ca848c 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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 { X, Highlighter, FileText, + HelpCircle, Quote, StickyNote, Upload, @@ -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({ { 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([]) const [isNsfw, setIsNsfw] = useState(false) @@ -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({ 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({ )} + )} - { - textareaRef.current?.insertText(gifUrl) - }} - > - - - { - textareaRef.current?.insertText(memeUrl) - }} - > - - } /> @@ -2968,6 +2965,7 @@ export default function PostContent({ + {/* 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({ textareaRef.current?.insertEmoji(emoji) }} > - )} + { + textareaRef.current?.insertText(gifUrl) + }} + > + + + { + textareaRef.current?.insertText(memeUrl) + }} + > + + + textareaRef.current?.insertText(text)} variant="ghost" /> - -
+
) diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 2316730f..5acaf7af 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -431,12 +431,16 @@ export default function PostRelaySelector({ {selectableRelays.length > 0 && (