diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 4243f3df..434829bf 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -24,6 +24,7 @@ import { prefetchAuthorNip30EmojisForPubkeys } from '@/lib/nip30-author-emojis' import { shouldFilterEvent } from '@/lib/event-filtering' import { isRelayUrlStrictSupersetIdentityKey, + isSpellSubRequestsFilterSuperset, isSpellSubRequestsSameFiltersDifferentRelays } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' @@ -748,6 +749,12 @@ const NoteList = forwardRef( * Use a larger value for slow feeds (e.g. notifications `#p` across many relays). */ timelineLoadingSafetyTimeoutMs, + /** + * When true, live `onNew` events merge into the visible timeline immediately (home feed behavior). + * Default false on Spells faux feeds: new rows go to {@link NewNotesButton} until the user scrolls near the top. + * Enable for notifications so mentions/replies appear without tapping “Show n new notes”. + */ + mergeLiveEventsImmediately = false, /** * With {@link useFilterAsIs}: omit relay `kinds` when the subrequest filter has none. Kindless relay feeds * merge the full batch; {@link withKindFilter} + {@link showAllKinds} control whether {@link showKinds} @@ -865,6 +872,7 @@ const NoteList = forwardRef( spellFeedInstrumentToken?: number onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void timelineLoadingSafetyTimeoutMs?: number + mergeLiveEventsImmediately?: boolean clientSideKindFilter?: boolean oneShotFetch?: boolean oneShotMergedCap?: number @@ -1333,6 +1341,8 @@ const NoteList = forwardRef( withKindFilterRef.current = withKindFilter const hostPrimaryPageNameRef = useRef(hostPrimaryPageName) hostPrimaryPageNameRef.current = hostPrimaryPageName + const mergeLiveEventsImmediatelyRef = useRef(mergeLiveEventsImmediately) + mergeLiveEventsImmediatelyRef.current = mergeLiveEventsImmediately const gridLayoutRef = useRef(gridLayout) gridLayoutRef.current = gridLayout @@ -1444,6 +1454,61 @@ const NoteList = forwardRef( shouldHideEventRef.current = shouldHideEvent }, [shouldHideEvent]) + /** Paint the author's own publishes into the open feed without waiting for relay echo or "new notes". */ + useEffect(() => { + const onOwnPublish = (data: globalThis.Event) => { + const evt = (data as CustomEvent).detail + if (!evt?.id || !pubkey || evt.pubkey !== pubkey) return + if (shouldHideEventRef.current(evt)) return + + const mapped = stripNostrLandAggrFromTimelineSubRequests( + feedSubscriptionKey, + mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current) + ).filter((req) => req.urls.length > 0) + if (mapped.length === 0) return + if ( + !mapped.some(({ filter }) => + eventMatchesSubRequestFilterWithWindow(evt, filter as Filter) + ) + ) { + return + } + + const narrowed = narrowLiveBatchUsingRefs([evt]) + if (narrowed.length === 0) return + + if (eventsRef.current.some((e) => e.id === evt.id)) return + + const cap = allowKindlessRelayExplore + ? RELAY_EXPLORE_LIMIT + : areAlgoRelays + ? ALGO_LIMIT + : LIMIT + setEvents((oldEvents) => { + if (oldEvents.some((e) => e.id === evt.id)) return oldEvents + const next = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(oldEvents, narrowed, cap, areAlgoRelays) + ) + lastEventsForTimelinePrefetchRef.current = next + return next + }) + setNewEvents((pending) => pending.filter((e) => e.id !== evt.id)) + setLoading(false) + client.prefetchEmbeddedEventsForParents(narrowed, { + relayHintsOnly: relayAuthoritativeFeedOnlyRef.current + }) + } + + client.addEventListener('newEvent', onOwnPublish) + return () => client.removeEventListener('newEvent', onOwnPublish) + }, [ + pubkey, + feedSubscriptionKey, + allowKindlessRelayExplore, + areAlgoRelays, + shouldHideEvent + ]) + const { items: filteredEvents, bufferExhaustedForVisibleQuota } = useMemo(() => { const idSet = new Set() const out: Event[] = [] @@ -2256,7 +2321,8 @@ const NoteList = forwardRef( !feedScopeChanged && prevSubKey != null && (isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || - isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey)) + isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey) || + isSpellSubRequestsFilterSuperset(prevSubKey, subRequestsKey)) const keepExistingTimelineEvents = preserveTimelineOnSubRequestsChange && @@ -2266,7 +2332,8 @@ const NoteList = forwardRef( (prevSubKey === subRequestsKey || isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || (mergeTimelineWhenSubRequestFiltersMatch && - isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey))) + (isSpellSubRequestsSameFiltersDifferentRelays(prevSubKey, subRequestsKey) || + isSpellSubRequestsFilterSuperset(prevSubKey, subRequestsKey)))) prevSubRequestsKeyForTimelineRef.current = subRequestsKey /** False after cleanup so stale timeline callbacks cannot overwrite state after switching feeds (e.g. Spells discussions → notifications). */ @@ -3516,12 +3583,15 @@ const NoteList = forwardRef( } } if (shouldHideEventRef.current(event)) return + const isOwnPublish = Boolean(pubkey && event.pubkey === pubkey) const route: 'profile' | 'home' | 'pending' = - (pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event) - ? 'profile' - : hostPrimaryPageNameRef.current === 'feed' - ? 'home' - : 'pending' + mergeLiveEventsImmediatelyRef.current || isOwnPublish + ? 'home' + : eventMatchesProfileTimelineRequest(event) + ? 'profile' + : hostPrimaryPageNameRef.current === 'feed' + ? 'home' + : 'pending' liveOnNewPendingRef.current.push({ event, route }) scheduleLiveOnNewFlush() }, diff --git a/src/lib/spell-feed-request-identity.test.ts b/src/lib/spell-feed-request-identity.test.ts new file mode 100644 index 00000000..4e62d3b7 --- /dev/null +++ b/src/lib/spell-feed-request-identity.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + computeSpellSubRequestsIdentityKey, + isSpellSubRequestsFilterSuperset +} from './spell-feed-request-identity' +import type { TFeedSubRequest } from '@/types' + +describe('isSpellSubRequestsFilterSuperset', () => { + it('detects when new shards add thread-watch filters', () => { + const base: TFeedSubRequest[] = [ + { + urls: ['wss://relay.example/'], + filter: { limit: 200, '#p': ['abc'.repeat(32)] } + } + ] + const expanded: TFeedSubRequest[] = [ + ...base, + { + urls: ['wss://relay.example/'], + filter: { kinds: [1], limit: 200, '#e': ['d'.repeat(64)] } + } + ] + const prevKey = computeSpellSubRequestsIdentityKey(base) + const nextKey = computeSpellSubRequestsIdentityKey(expanded) + expect(isSpellSubRequestsFilterSuperset(prevKey, nextKey)).toBe(true) + expect(isSpellSubRequestsFilterSuperset(nextKey, prevKey)).toBe(false) + }) +}) diff --git a/src/lib/spell-feed-request-identity.ts b/src/lib/spell-feed-request-identity.ts index dbf1a1c9..f9ded131 100644 --- a/src/lib/spell-feed-request-identity.ts +++ b/src/lib/spell-feed-request-identity.ts @@ -95,3 +95,21 @@ export function isSpellSubRequestsSameFiltersDifferentRelays( return false } } + +/** + * True when `nextKey` keeps every prior REQ filter and adds more shards (e.g. notifications spell gains + * `#e` / `#a` subrequests after thread-watch lists load from relays). + */ +export function isSpellSubRequestsFilterSuperset(prevKey: string | null, nextKey: string): boolean { + if (!prevKey || prevKey === nextKey) return false + try { + type Item = { urls: string[]; filter: string } + const prev = JSON.parse(prevKey) as Item[] + const next = JSON.parse(nextKey) as Item[] + if (!Array.isArray(prev) || !Array.isArray(next) || next.length < prev.length) return false + const nextFilters = new Set(next.map((item) => item.filter)) + return prev.every((item) => nextFilters.has(item.filter)) + } catch { + return false + } +} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index de0293a1..cd57187c 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1063,8 +1063,13 @@ const SpellsPage = forwardRef(function SpellsPage( subRequests={subRequests} feedSubscriptionKey={spellFeedSubscriptionKey} hostPrimaryPageName="spells" - preserveTimelineOnSubRequestsChange={spellFauxMergeTimeline} - mergeTimelineWhenSubRequestFiltersMatch={spellFauxMergeTimeline} + preserveTimelineOnSubRequestsChange={ + spellFauxMergeTimeline || selectedFauxSpell === 'notifications' + } + mergeTimelineWhenSubRequestFiltersMatch={ + spellFauxMergeTimeline || selectedFauxSpell === 'notifications' + } + mergeLiveEventsImmediately={selectedFauxSpell === 'notifications'} showKinds={ selectedFauxSpell === 'notifications' ? NOTIFICATION_SPELL_KINDS : showKinds }