From 6200f6624d4300f3dcef9db4030c79e43d770449 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 11 May 2026 21:41:02 +0200 Subject: [PATCH] bug-fixes added likes to profile page --- src/components/NoteList/index.tsx | 52 ++- src/components/NoteStats/LikeButton.tsx | 31 +- src/components/NoteStats/Likes.tsx | 14 +- src/components/NoteStats/ReplyButton.tsx | 15 +- src/components/NoteStats/RepostButton.tsx | 15 +- src/components/NoteStats/ZapButton.tsx | 23 +- src/components/NoteStats/index.tsx | 42 ++- src/components/Profile/ProfileLikedFeed.tsx | 298 ++++++++++++++++++ src/components/Profile/ProfileMediaFeed.tsx | 10 +- src/components/Profile/index.tsx | 23 +- src/components/ReplyNoteList/index.tsx | 86 ++--- src/constants.ts | 11 +- src/hooks/useProfileAuthorFeedSubRequests.ts | 23 +- src/hooks/useProfilePins.tsx | 40 ++- src/hooks/useProfileTimeline.tsx | 4 +- src/i18n/locales/en.ts | 4 + src/lib/favorites-feed-relays.ts | 27 +- src/lib/feed-local-event-match.test.ts | 44 +++ src/lib/feed-local-event-match.ts | 43 +++ src/lib/home-feed-relays.ts | 27 ++ src/lib/relay-url-priority.test.ts | 33 +- src/pages/primary/NoteListPage/RelaysFeed.tsx | 3 +- src/pages/primary/NoteListPage/index.tsx | 2 +- .../primary/SpellsPage/RelayThreadHeatMap.tsx | 16 +- .../SpellsPage/TopicKeywordHeatMap.tsx | 15 +- .../primary/SpellsPage/useSpellsPageFeed.ts | 30 +- src/providers/FeedProvider.test.ts | 19 ++ src/providers/FeedProvider.tsx | 20 +- src/services/client-events.service.ts | 15 + src/services/client.service.ts | 48 +++ src/services/indexed-db.service.ts | 98 ++++++ src/services/note-stats.service.ts | 85 +++-- 32 files changed, 991 insertions(+), 225 deletions(-) create mode 100644 src/components/Profile/ProfileLikedFeed.tsx create mode 100644 src/lib/feed-local-event-match.test.ts create mode 100644 src/lib/feed-local-event-match.ts create mode 100644 src/lib/home-feed-relays.ts create mode 100644 src/providers/FeedProvider.test.ts diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 69b4a6eb..45865c3a 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -491,7 +491,8 @@ function buildNoteListMappedFilterForFullSearch( f = finalFilter } } else if (seeAllNoSpell) { - const { kinds: _omitKinds, ...rest } = filter + const rest = { ...filter } + delete rest.kinds f = { ...rest, limit: options.areAlgoRelays ? ALGO_LIMIT : LIMIT @@ -2026,6 +2027,12 @@ const NoteList = forwardRef( return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests) } + const eventMatchesProfileTimelineRequest = (event: Event) => + hostPrimaryPageNameRef.current === 'profile' && + mappedSubRequests.some(({ filter }) => + eventMatchesSubRequestFilterWithWindow(event, filter as Filter) + ) + const eventCapEarly = allowKindlessRelayExplore ? RELAY_EXPLORE_LIMIT : areAlgoRelays @@ -2036,15 +2043,15 @@ const NoteList = forwardRef( hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0 /** - * IndexedDB + session peek (inside {@link ClientService.getTimelineDiskSnapshotEvents}) without blocking - * relay REQ/subscribe. Merges the same way as live {@link onEvents} so rows appear as soon as disk resolves. + * Session + IndexedDB hydration without blocking relay REQ/subscribe. Merges the same way as live + * {@link onEvents} so rows appear as soon as local sources resolve. */ const startNonBlockingTimelineDiskPrime = () => { if (oneShotFetch || mappedSubRequests.length === 0) return if (isSpellPageLocalWarmup) return const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> void client - .getTimelineDiskSnapshotEvents(diskReq) + .getLocalFeedEvents(diskReq) .then((diskRaw) => { if (!effectActive || timelineEffectStale()) return const diskNarrowed = narrowLiveBatch(diskRaw) @@ -2406,7 +2413,7 @@ const NoteList = forwardRef( filter: TSubRequestFilter }> void client - .getTimelineDiskSnapshotEvents(diskReqOneShot) + .getLocalFeedEvents(diskReqOneShot) .then((diskRaw) => { if (!effectActive || timelineEffectStale()) return if (diskRaw.length === 0) return @@ -2498,10 +2505,7 @@ const NoteList = forwardRef( return next }) } else { - let merged = relayOnly - if (sessionSnap?.length && !userPulledRefresh) { - merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP) - } + const capForOneShot = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP if (oneShotDebugLabel) { const f0 = mappedSubRequests[0]?.filter logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, { @@ -2510,7 +2514,7 @@ const NoteList = forwardRef( dedupedCount: runtimeSnapshot.rawCount, hiddenByRuntime: runtimeSnapshot.hiddenCount, emptyReason: runtimeSnapshot.emptyReason, - afterCap: merged.length, + afterCap: relayOnly.length, cap, filterAuthors: f0?.authors, filterKinds: f0?.kinds, @@ -2523,9 +2527,17 @@ const NoteList = forwardRef( : {}) }) } - const collapsed = collapseDuplicateNip18RepostTimelineRows(merged) - setEvents(collapsed) - lastEventsForTimelinePrefetchRef.current = collapsed + setEvents((prev) => { + const base = + sessionSnap?.length && !userPulledRefresh + ? mergeEventBatchesById(sessionSnap, prev, capForOneShot) + : prev + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(base, relayOnly, capForOneShot, areAlgoRelays) + ) + lastEventsForTimelinePrefetchRef.current = merged + return merged + }) } if (oneShotDebugLabel && isProgressiveLayers) { const f0 = mappedSubRequests[0]?.filter @@ -2863,7 +2875,7 @@ const NoteList = forwardRef( } } if (shouldHideEventRef.current(event)) return - if (pubkey && event.pubkey === pubkey) { + if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileTimelineRequest(event)) { setEvents((oldEvents) => { const boot = timelineMergeBootstrapRef.current const base = boot !== null ? boot : oldEvents @@ -2955,7 +2967,7 @@ const NoteList = forwardRef( // skeleton until the first onEvents(..., eosed) — that can freeze the feed indefinitely. setLoading(false) return closer - } catch (_error) { + } catch { setLoading(false) if (progressiveWarmupQueryRef.current?.trim()) { setProgressiveLayersSearching(false) @@ -3088,6 +3100,12 @@ const NoteList = forwardRef( return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind)) } + const eventMatchesProfileDeltaRequest = (event: Event) => + hostPrimaryPageNameRef.current === 'profile' && + mappedDelta.some(({ filter }) => + eventMatchesSubRequestFilterWithWindow(event, filter as Filter) + ) + void (async () => { try { const { closer, timelineKey: deltaTk } = await client.subscribeTimeline( @@ -3194,7 +3212,7 @@ const NoteList = forwardRef( } } if (shouldHideEventRef.current(event)) return - if (pubkey && event.pubkey === pubkey) { + if ((pubkey && event.pubkey === pubkey) || eventMatchesProfileDeltaRequest(event)) { setEvents((oldEvents) => { if (oldEvents.some((e) => e.id === event.id)) return oldEvents if ( @@ -3811,7 +3829,7 @@ const NoteList = forwardRef( void run() }) } - } catch (_error) { + } catch { // On error, don't set hasMore to false - might be temporary network issue consecutiveEmptyRef.current += 1 // Only stop after MANY consecutive errors - be very patient with network issues diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 00d0717c..9b8d5b6f 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -25,6 +25,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { eventService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' +import type { TNoteStats } from '@/services/note-stats.service' import storage from '@/services/local-storage.service' import { TEmoji } from '@/types' import { SmilePlus } from 'lucide-react' @@ -43,7 +44,19 @@ import { import { LoginRequiredError } from '@/lib/nostr-errors' import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed' -export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { +type LikeButtonProps = { + event: Event + hideCount?: boolean + noteStats?: Partial + isReplyToDiscussion?: boolean +} + +export function LikeButtonWithStats({ + event, + hideCount = false, + noteStats, + isReplyToDiscussion: isReplyToDiscussionProp +}: LikeButtonProps) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { pubkey, publish, checkLogin } = useNostr() @@ -51,10 +64,9 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const [liking, setLiking] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) - const noteStats = useNoteStatsById(event.id) const isDiscussion = event.kind === ExtendedKind.DISCUSSION const inQuietMode = shouldHideInteractions(event) - const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) + const isReplyToDiscussion = isReplyToDiscussionProp ?? false const showDiscussionVotes = isDiscussion || isReplyToDiscussion const statsLoaded = noteStats?.updatedAt != null @@ -344,3 +356,16 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; ) } + +export default function LikeButton({ event, hideCount = false }: LikeButtonProps) { + const noteStats = useNoteStatsById(event.id) + const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) + return ( + + ) +} diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 7e590385..e936835d 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' import noteStatsService from '@/services/note-stats.service' +import type { TNoteStats } from '@/services/note-stats.service' import storage from '@/services/local-storage.service' import { TEmoji } from '@/types' import { Event } from 'nostr-tools' @@ -16,11 +17,15 @@ import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji' import Username from '../Username' import logger from '@/lib/logger' -export default function Likes({ event }: { event: Event }) { +type LikesProps = { + event: Event + noteStats?: Partial +} + +export function LikesWithStats({ event, noteStats }: LikesProps) { const inQuietMode = shouldHideInteractions(event) const { pubkey, checkLogin, publish } = useNostr() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() - const noteStats = useNoteStatsById(event.id) const [liking, setLiking] = useState(null) const longPressTimerRef = useRef(null) const [isLongPressing, setIsLongPressing] = useState(null) @@ -213,3 +218,8 @@ export default function Likes({ event }: { event: Event }) { ) } + +export default function Likes({ event }: LikesProps) { + const noteStats = useNoteStatsById(event.id) + return +} diff --git a/src/components/NoteStats/ReplyButton.tsx b/src/components/NoteStats/ReplyButton.tsx index eb0f3401..b0960214 100644 --- a/src/components/NoteStats/ReplyButton.tsx +++ b/src/components/NoteStats/ReplyButton.tsx @@ -2,6 +2,7 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' +import type { TNoteStats } from '@/services/note-stats.service' import { MessageCircle } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo, useState } from 'react' @@ -9,10 +10,15 @@ import { useTranslation } from 'react-i18next' import PostEditor from '../PostEditor' import { formatCount } from './utils' -export default function ReplyButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { +type ReplyButtonProps = { + event: Event + hideCount?: boolean + noteStats?: Partial +} + +export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: ReplyButtonProps) { const { t } = useTranslation() const { pubkey, checkLogin } = useNostr() - const noteStats = useNoteStatsById(event.id) const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { replyCount, hasReplied } = useMemo(() => { const hasReplied = pubkey @@ -58,3 +64,8 @@ export default function ReplyButton({ event, hideCount = false }: { event: Event ) } + +export default function ReplyButton({ event, hideCount = false }: ReplyButtonProps) { + const noteStats = useNoteStatsById(event.id) + return +} diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index f82060c2..f1c81ee9 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -16,6 +16,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' import noteStatsService from '@/services/note-stats.service' +import type { TNoteStats } from '@/services/note-stats.service' import storage from '@/services/local-storage.service' import { PencilLine, Repeat } from 'lucide-react' import { Event } from 'nostr-tools' @@ -26,13 +27,18 @@ import PostEditor from '../PostEditor' import { formatCount } from './utils' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' -export default function RepostButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { +type RepostButtonProps = { + event: Event + hideCount?: boolean + noteStats?: Partial +} + +export function RepostButtonWithStats({ event, hideCount = false, noteStats }: RepostButtonProps) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { publish, checkLogin, pubkey } = useNostr() const { relays: statsRelays } = useNoteStatsRelayHints() - const noteStats = useNoteStatsById(event.id) as import('@/services/note-stats.service').TNoteStats | undefined const [reposting, setReposting] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -192,3 +198,8 @@ export default function RepostButton({ event, hideCount = false }: { event: Even ) } + +export default function RepostButton({ event, hideCount = false }: RepostButtonProps) { + const noteStats = useNoteStatsById(event.id) + return +} diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx index eb239b23..191118fe 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/NoteStats/ZapButton.tsx @@ -9,6 +9,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata' import { kinds } from 'nostr-tools' import lightning from '@/services/lightning.service' import noteStatsService from '@/services/note-stats.service' +import type { TNoteStats } from '@/services/note-stats.service' import { Zap } from 'lucide-react' import { Event } from 'nostr-tools' import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react' @@ -16,10 +17,15 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import ZapDialog from '../ZapDialog' -export default function ZapButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { +type ZapButtonProps = { + event: Event + hideCount?: boolean + noteStats?: Partial +} + +export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapButtonProps) { const { t } = useTranslation() const { checkLogin, pubkey } = useNostr() - const noteStats = useNoteStatsById(event.id) const { defaultZapSats, defaultZapComment, quickZap } = useZap() const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null) const [openZapDialog, setOpenZapDialog] = useState(false) @@ -35,14 +41,20 @@ export default function ZapButton({ event, hideCount = false }: { event: Event; const isLongPressRef = useRef(false) useEffect(() => { + setDisable(true) + let cancelled = false replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((profileEvent) => { + if (cancelled) return const profile = profileEvent ? getProfileFromEvent(profileEvent) : undefined if (!profile) return if (pubkey === profile.pubkey) return const lightningAddress = getLightningAddressFromProfile(profile) if (lightningAddress) setDisable(false) }) - }, [event]) + return () => { + cancelled = true + } + }, [event.pubkey, pubkey]) const handleZap = async () => { try { @@ -166,6 +178,11 @@ export default function ZapButton({ event, hideCount = false }: { event: Event; ) } +export default function ZapButton({ event, hideCount = false }: ZapButtonProps) { + const noteStats = useNoteStatsById(event.id) + return +} + function formatAmount(amount: number) { if (amount < 1000) return amount if (amount < 1000000) return `${Math.round(amount / 100) / 10}k` diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 639d992f..f54662ff 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' +import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays' import noteStatsService from '@/services/note-stats.service' import { ExtendedKind } from '@/constants' @@ -11,12 +12,12 @@ import logger from '@/lib/logger' import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' import BookmarkButton from '../BookmarkButton' -import LikeButton from './LikeButton' -import Likes from './Likes' -import ReplyButton from './ReplyButton' -import RepostButton from './RepostButton' +import { LikeButtonWithStats } from './LikeButton' +import { LikesWithStats } from './Likes' +import { ReplyButtonWithStats } from './ReplyButton' +import { RepostButtonWithStats } from './RepostButton' import SeenOnButton from './SeenOnButton' -import ZapButton from './ZapButton' +import { ZapButtonWithStats } from './ZapButton' export default function NoteStats({ event, @@ -38,6 +39,7 @@ export default function NoteStats({ }) { const { isSmallScreen } = useScreenSize() const { pubkey } = useNostr() + const noteStats = useNoteStatsById(event.id) const { relays: hintRelays, key: hintRelaysKey } = useNoteStatsRelayHints() const { relayUrls: rssUrlThreadRelays, key: rssUrlThreadRelaysKey } = useRssUrlThreadQueryRelays() const [loading, setLoading] = useState(false) @@ -79,7 +81,7 @@ export default function NoteStats({
e.stopPropagation()}> {displayTopZapsAndLikes && ( <> - {showLikesPills && } + {showLikesPills && } )}
- + {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && ( - + )} - + {!isRssArticleRoot && !isZapPoll && ( - + )} {!isRssArticleRoot && } @@ -108,20 +115,25 @@ export default function NoteStats({
e.stopPropagation()}> {displayTopZapsAndLikes && ( <> - {showLikesPills && } + {showLikesPills && } )}
- + {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && ( - + )} - + {!isRssArticleRoot && !isZapPoll && ( - + )}
diff --git a/src/components/Profile/ProfileLikedFeed.tsx b/src/components/Profile/ProfileLikedFeed.tsx new file mode 100644 index 00000000..a4ad7c8e --- /dev/null +++ b/src/components/Profile/ProfileLikedFeed.tsx @@ -0,0 +1,298 @@ +import NoteCard from '@/components/NoteCard' +import { Skeleton } from '@/components/ui/skeleton' +import { ExtendedKind } from '@/constants' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import { generateBech32IdFromATag, getFirstHexEventIdFromETags } from '@/lib/tag' +import { relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' +import { RefreshCw } from 'lucide-react' +import { kinds, type Event } from 'nostr-tools' +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { useTranslation } from 'react-i18next' +import { useProfileTimeline } from '@/hooks/useProfileTimeline' + +const INITIAL_SHOW_COUNT = 25 +const LOAD_MORE_COUNT = 25 +const LIKED_REACTION_KINDS = [kinds.Reaction, ExtendedKind.EXTERNAL_REACTION] + +type ReactionTargetRef = { + key: string + fetchId: string + hexId?: string + relayHints: string[] +} + +type LikedTarget = { + reaction: Event + target: Event +} + +function isPositiveReaction(event: Event): boolean { + return event.content.trim() !== '-' +} + +function relayHintsForReactionTarget(event: Event, tag?: string[]): string[] { + const hints = new Set(relayHintsFromEventTags(event)) + const tagHint = tag?.[2]?.trim() + if (tagHint) hints.add(tagHint) + return [...hints] +} + +function reactionTargetRef(event: Event): ReactionTargetRef | null { + const hexId = getFirstHexEventIdFromETags(event.tags) + if (hexId) { + return { + key: `e:${hexId.toLowerCase()}`, + fetchId: hexId, + hexId, + relayHints: relayHintsForReactionTarget(event) + } + } + + const addressTag = event.tags.find((tag) => (tag[0] === 'a' || tag[0] === 'A') && tag[1]) + if (!addressTag) return null + const bech32Id = generateBech32IdFromATag(addressTag) + if (!bech32Id) return null + return { + key: `a:${addressTag[1]}`, + fetchId: bech32Id, + relayHints: relayHintsForReactionTarget(event, addressTag) + } +} + +function samePubkey(a: string, b: string): boolean { + try { + return hexPubkeysEqual(normalizeHexPubkey(a), normalizeHexPubkey(b)) + } catch { + return a === b + } +} + +function newestReactionTargets(reactions: Event[]): Array<{ reaction: Event; targetRef: ReactionTargetRef }> { + const byTarget = new Map() + for (const reaction of reactions) { + if (!isPositiveReaction(reaction)) continue + const targetRef = reactionTargetRef(reaction) + if (!targetRef) continue + const existing = byTarget.get(targetRef.key) + if ( + !existing || + reaction.created_at > existing.reaction.created_at || + (reaction.created_at === existing.reaction.created_at && reaction.id > existing.reaction.id) + ) { + byTarget.set(targetRef.key, { reaction, targetRef }) + } + } + return [...byTarget.values()].sort((a, b) => b.reaction.created_at - a.reaction.created_at) +} + +const ProfileLikedFeed = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { + const { t } = useTranslation() + const { isEventDeleted } = useDeletedEvent() + const [isRefreshing, setIsRefreshing] = useState(false) + const [isResolvingTargets, setIsResolvingTargets] = useState(false) + const [likedTargets, setLikedTargets] = useState([]) + const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) + const bottomRef = useRef(null) + + const reactionKinds = useMemo(() => [...LIKED_REACTION_KINDS], []) + const cacheKey = useMemo(() => `${pubkey}-profile-liked-v1`, [pubkey]) + const { events: reactionEvents, isLoading, refresh } = useProfileTimeline({ + pubkey, + cacheKey, + kinds: reactionKinds, + limit: 200 + }) + + const targetRefs = useMemo(() => newestReactionTargets(reactionEvents), [reactionEvents]) + + useEffect(() => { + setShowCount(INITIAL_SHOW_COUNT) + }, [pubkey]) + + useEffect(() => { + if (!isLoading && !isResolvingTargets) { + setIsRefreshing(false) + } + }, [isLoading, isResolvingTargets]) + + useImperativeHandle( + ref, + () => ({ + refresh: () => { + setIsRefreshing(true) + refresh() + } + }), + [refresh] + ) + + useEffect(() => { + let cancelled = false + const viewerPubkey = pubkey + const toLikedTarget = (row: { reaction: Event; target: Event }): LikedTarget | null => { + if (samePubkey(row.target.pubkey, viewerPubkey)) return null + if (isEventDeleted(row.target)) return null + return row + } + + const cachedRows = targetRefs + .map(({ reaction, targetRef }) => { + const cached = targetRef.hexId ? client.peekSessionCachedEvent(targetRef.hexId) : undefined + return cached ? toLikedTarget({ reaction, target: cached }) : null + }) + .filter((row): row is LikedTarget => !!row) + + setLikedTargets(cachedRows) + if (targetRefs.length === 0) { + setIsResolvingTargets(false) + return () => { + cancelled = true + } + } + + setIsResolvingTargets(true) + void (async () => { + try { + const hexIds = targetRefs.map(({ targetRef }) => targetRef.hexId).filter((id): id is string => !!id) + if (hexIds.length > 0) { + const [archived, publications] = await Promise.all([ + indexedDb.getArchivedEventsByIds(hexIds), + Promise.all(hexIds.map((id) => indexedDb.getEventFromPublicationStore(id))) + ]) + if (cancelled) return + const localById = new Map() + for (const event of archived) localById.set(event.id, event) + for (const event of publications) { + if (event) localById.set(event.id, event) + } + const localResolved = targetRefs + .map(({ reaction, targetRef }) => { + const target = targetRef.hexId ? localById.get(targetRef.hexId) : undefined + return target ? toLikedTarget({ reaction, target }) : null + }) + .filter((row): row is LikedTarget => !!row) + if (localResolved.length > 0) { + setLikedTargets((prev) => { + const byTargetId = new Map(prev.map((row) => [row.target.id, row])) + for (const row of localResolved) byTargetId.set(row.target.id, row) + return [...byTargetId.values()].sort((a, b) => b.reaction.created_at - a.reaction.created_at) + }) + } + } + + const missingHexIds = targetRefs + .map(({ targetRef }) => targetRef.hexId) + .filter((id): id is string => !!id && !client.peekSessionCachedEvent(id)) + if (missingHexIds.length > 0) { + await client.prefetchHexEventIds(missingHexIds) + } + + const resolved = await Promise.all( + targetRefs.map(async ({ reaction, targetRef }) => { + const target = + (targetRef.hexId ? client.peekSessionCachedEvent(targetRef.hexId) : undefined) ?? + await client.fetchEvent(targetRef.fetchId, { relayHints: targetRef.relayHints }) + if (!target) return null + return toLikedTarget({ reaction, target }) + }) + ) + if (cancelled) return + const byTargetId = new Map() + for (const row of resolved) { + if (!row) continue + const existing = byTargetId.get(row.target.id) + if ( + !existing || + row.reaction.created_at > existing.reaction.created_at || + (row.reaction.created_at === existing.reaction.created_at && row.reaction.id > existing.reaction.id) + ) { + byTargetId.set(row.target.id, row) + } + } + setLikedTargets([...byTargetId.values()].sort((a, b) => b.reaction.created_at - a.reaction.created_at)) + } finally { + if (!cancelled) setIsResolvingTargets(false) + } + })() + + return () => { + cancelled = true + } + }, [targetRefs, pubkey, isEventDeleted]) + + const displayedTargets = useMemo( + () => likedTargets.slice(0, showCount), + [likedTargets, showCount] + ) + + useEffect(() => { + if (!bottomRef.current || displayedTargets.length >= likedTargets.length) return + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && displayedTargets.length < likedTargets.length) { + setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, likedTargets.length)) + } + }, + { threshold: 0.1 } + ) + observer.observe(bottomRef.current) + return () => observer.disconnect() + }, [displayedTargets.length, likedTargets.length]) + + if ((isLoading || isResolvingTargets) && likedTargets.length === 0) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + } + + if (likedTargets.length === 0) { + return ( +
+ {t('No liked posts yet')} +
+ ) + } + + return ( +
+ {isRefreshing && ( +
+ + {t('Refreshing liked posts...')} +
+ )} +
+ {displayedTargets.map(({ target }) => ( + + ))} +
+ {displayedTargets.length < likedTargets.length && ( +
+
{t('Loading more...')}
+
+ )} +
+ ) +}) + +ProfileLikedFeed.displayName = 'ProfileLikedFeed' + +export default ProfileLikedFeed diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx index 153619b1..eb83f8af 100644 --- a/src/components/Profile/ProfileMediaFeed.tsx +++ b/src/components/Profile/ProfileMediaFeed.tsx @@ -1,7 +1,6 @@ import NoteList, { type TNoteListRef } from '@/components/NoteList' import { buildAuthorInboxOutboxRelayUrls } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' -import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' import { PROFILE_MEDIA_TAB_KINDS } from '@/constants' import { buildProfileMediaSubRequests } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { normalizeUrl } from '@/lib/url' @@ -99,10 +98,11 @@ const ProfileMediaFeed = forwardRef(({ pubkey return buildProfileMediaSubRequests(authorRelayUrls, blockedRelays, pk) }, [pubkey, authorRelayUrls, blockedRelays]) - const feedSubscriptionKey = useMemo( - () => computeSpellSubRequestsIdentityKey(subRequests), - [subRequests] - ) + const feedSubscriptionKey = useMemo(() => { + const pk = pubkey?.trim() + if (!pk) return 'profile-media-empty' + return `profile-media-${normalizeHexPubkey(pk)}` + }, [pubkey]) useEffect(() => { const pk = pubkey?.trim() diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 2a4d9fc1..05cadc5a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -58,6 +58,7 @@ import logger from '@/lib/logger' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' import ProfileFeedWithPins from './ProfileFeedWithPins' +import ProfileLikedFeed from './ProfileLikedFeed' import ProfileMediaFeed from './ProfileMediaFeed' import ProfilePublicationsFeed from './ProfilePublicationsFeed' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -196,7 +197,8 @@ export default function Profile({ const postsFeedRef = useRef<{ refresh: () => void }>(null) const mediaFeedRef = useRef(null) const publicationsFeedRef = useRef<{ refresh: () => void }>(null) - const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications'>('posts') + const likedFeedRef = useRef<{ refresh: () => void }>(null) + const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications' | 'liked'>('posts') const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey, publish, checkLogin } = useNostr() @@ -370,6 +372,7 @@ export default function Profile({ postsFeedRef.current?.refresh() mediaFeedRef.current?.refresh() publicationsFeedRef.current?.refresh() + likedFeedRef.current?.refresh() } } return () => { @@ -394,8 +397,14 @@ export default function Profile({ setProfileFeedTab('posts') }, [profile?.pubkey]) + useEffect(() => { + if (!isSelf && profileFeedTab === 'liked') { + setProfileFeedTab('posts') + } + }, [isSelf, profileFeedTab]) + /** - * Radix {@link TabsContent} unmounts inactive panels, so media / publications feeds can miss the same + * Radix {@link TabsContent} unmounts inactive panels, so media / publications / liked feeds can miss the same * warm-up window as Posts or show a frozen first paint. Re-run their refresh path when the tab becomes active * (after refs attach — {@link useLayoutEffect}). */ @@ -404,6 +413,8 @@ export default function Profile({ mediaFeedRef.current?.refresh() } else if (profileFeedTab === 'publications') { publicationsFeedRef.current?.refresh() + } else if (profileFeedTab === 'liked') { + likedFeedRef.current?.refresh() } }, [profileFeedTab]) @@ -702,7 +713,7 @@ export default function Profile({ { - if (v === 'posts' || v === 'media' || v === 'publications') { + if (v === 'posts' || v === 'media' || v === 'publications' || (isSelf && v === 'liked')) { setProfileFeedTab(v) } }} @@ -712,6 +723,7 @@ export default function Profile({ {t('Posts')} {t('Media')} {t('Articles and Publications')} + {isSelf && {t('Liked')}} @@ -722,6 +734,11 @@ export default function Profile({ + {isSelf && ( + + + + )} {openPublicMessageTo && ( { - const stats = noteStatsService.getNoteStats(reply.id) - if (!stats?.likes) { - return 0 - } - - const upvoteReactions = stats.likes.filter((r) => - isDiscussionRoot ? isDiscussionUpvoteEmoji(r.emoji) : r.emoji === '⬆️' - ) - const downvoteReactions = stats.likes.filter((r) => - isDiscussionRoot ? isDiscussionDownvoteEmoji(r.emoji) : r.emoji === '⬇️' - ) - const score = upvoteReactions.length - downvoteReactions.length - - return score - } - - // Helper function to get controversy score for a reply - const getReplyControversyScore = (reply: NEvent) => { - const stats = noteStatsService.getNoteStats(reply.id) - if (!stats?.likes) { - return 0 - } - - const upvoteReactions = stats.likes.filter((r) => - isDiscussionRoot ? isDiscussionUpvoteEmoji(r.emoji) : r.emoji === '⬆️' - ) - const downvoteReactions = stats.likes.filter((r) => - isDiscussionRoot ? isDiscussionDownvoteEmoji(r.emoji) : r.emoji === '⬇️' - ) - - // Controversy = minimum of upvotes and downvotes (both need to be high) - const controversy = Math.min(upvoteReactions.length, downvoteReactions.length) - return controversy - } - - // Helper function to get total zap amount for a reply - const getReplyZapAmount = (reply: NEvent) => { - const stats = noteStatsService.getNoteStats(reply.id) - if (!stats?.zaps) { - return 0 - } - - const totalAmount = stats.zaps.reduce((sum, zap) => sum + zap.amount, 0) - return totalAmount - } const replies = useMemo(() => { const replyIdSet = new Set() const replyEvents: NEvent[] = [] @@ -517,6 +470,33 @@ function ReplyNoteList({ const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents) const zaps = filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold) + const replyScoreById = + sort === 'top' || sort === 'controversial' || sort === 'most-zapped' + ? new Map( + nonZaps.map((reply) => { + const stats = noteStatsService.getNoteStats(reply.id) + let upvotes = 0 + let downvotes = 0 + for (const reaction of stats?.likes ?? []) { + if (isDiscussionRoot ? isDiscussionUpvoteEmoji(reaction.emoji) : reaction.emoji === '⬆️') { + upvotes++ + } else if ( + isDiscussionRoot ? isDiscussionDownvoteEmoji(reaction.emoji) : reaction.emoji === '⬇️' + ) { + downvotes++ + } + } + return [ + reply.id, + { + vote: upvotes - downvotes, + controversy: Math.min(upvotes, downvotes), + zapAmount: (stats?.zaps ?? []).reduce((sum, zap) => sum + zap.amount, 0) + } + ] as const + }) + ) + : new Map() // Sort notes/comments; zap receipts (9735) are always listed first, largest sats → smallest switch (sort) { @@ -533,8 +513,8 @@ function ReplyNoteList({ case 'top': return replyFeedZapsFirst( [...nonZaps].sort((a, b) => { - const scoreA = getReplyVoteScore(a) - const scoreB = getReplyVoteScore(b) + const scoreA = replyScoreById.get(a.id)?.vote ?? 0 + const scoreB = replyScoreById.get(b.id)?.vote ?? 0 if (scoreA !== scoreB) { return scoreB - scoreA } @@ -545,8 +525,8 @@ function ReplyNoteList({ case 'controversial': return replyFeedZapsFirst( [...nonZaps].sort((a, b) => { - const controversyA = getReplyControversyScore(a) - const controversyB = getReplyControversyScore(b) + const controversyA = replyScoreById.get(a.id)?.controversy ?? 0 + const controversyB = replyScoreById.get(b.id)?.controversy ?? 0 if (controversyA !== controversyB) { return controversyB - controversyA } @@ -557,8 +537,8 @@ function ReplyNoteList({ case 'most-zapped': return replyFeedZapsFirst( [...nonZaps].sort((a, b) => { - const zapAmountA = getReplyZapAmount(a) - const zapAmountB = getReplyZapAmount(b) + const zapAmountA = replyScoreById.get(a.id)?.zapAmount ?? 0 + const zapAmountB = replyScoreById.get(b.id)?.zapAmount ?? 0 if (zapAmountA !== zapAmountB) { return zapAmountB - zapAmountA } diff --git a/src/constants.ts b/src/constants.ts index 58a04b2d..eb9fe8db 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -616,13 +616,6 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [ kinds.BadgeAward ] -/** - * {@link THREAD_BACKLINK_STREAM_KINDS} without kind 9802. Highlights use separate low-`kinds` REQs so - * relays that reject large `kinds` arrays still return NIP-84 backlinks. - */ -export const THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT: readonly number[] = - THREAD_BACKLINK_STREAM_KINDS.filter((k) => k !== kinds.Highlights) - /** * Kinds that reference an OP via `#e` / `#E` / `#a` / `#A` / `#q` in note-stats and thread REQ filters. * Extends {@link THREAD_BACKLINK_STREAM_KINDS} with publication headers (30040) that may tag notes without using 30041. @@ -909,9 +902,7 @@ export const WS_URL_REGEX = /wss?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+[^\s.,;:'")\]}!?,。;:"'!?】)](?=\.(?:\s|$)|,\s|,(?=\/|\s|$)|$|[^\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,])/giu /** @see {@link '@/lib/content-patterns'} — single source for emoji + nostr regexes */ export { - EMOJI_SHORT_CODE_REGEX, - EMBEDDED_EVENT_REGEX, - EMBEDDED_MENTION_REGEX + EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns' export const HASHTAG_REGEX = /#[a-zA-Z0-9_\-\u00C0-\u017F\u0100-\u017F\u0180-\u024F\u1E00-\u1EFF]+/g export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g diff --git a/src/hooks/useProfileAuthorFeedSubRequests.ts b/src/hooks/useProfileAuthorFeedSubRequests.ts index e7b8215b..cdaddb7c 100644 --- a/src/hooks/useProfileAuthorFeedSubRequests.ts +++ b/src/hooks/useProfileAuthorFeedSubRequests.ts @@ -1,8 +1,7 @@ import { buildProfileAuthorSubRequestsFromUrlGroups } from '@/lib/profile-author-subrequests' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' -import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' -import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' +import { normalizeAnyRelayUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import client from '@/services/client.service' @@ -109,22 +108,18 @@ export function useProfileAuthorFeedSubRequests({ } }, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, favoriteRelays, blockedRelays, includeAuthorLocalRelays]) + const activeUrls = fullUrls?.length ? fullUrls : provisionalUrls + const subRequests = useMemo(() => { - if (!provisionalUrls.length) return [] as TFeedSubRequest[] - return buildProfileAuthorSubRequestsFromUrlGroups([provisionalUrls], authorHex, [...kinds], limit) - }, [provisionalUrls, authorHex, kinds, limit]) + if (!activeUrls.length) return [] as TFeedSubRequest[] + return buildProfileAuthorSubRequestsFromUrlGroups([activeUrls], authorHex, [...kinds], limit) + }, [activeUrls, authorHex, kinds, limit]) - const followingFeedDeltaSubRequests = useMemo(() => { - if (!fullUrls?.length || !provisionalUrls.length) return [] as TFeedSubRequest[] - const delta = subtractNormalizedRelayUrls(fullUrls, provisionalUrls) - if (!delta.length) return [] as TFeedSubRequest[] - return buildProfileAuthorSubRequestsFromUrlGroups([delta], authorHex, [...kinds], limit) - }, [fullUrls, provisionalUrls, authorHex, kinds, limit]) + const followingFeedDeltaSubRequests = useMemo(() => [] as TFeedSubRequest[], []) const feedSubscriptionKey = useMemo(() => { - const base = computeSpellSubRequestsIdentityKey(subRequests) - return `profile-posts-${authorHex}-${relayListsKey}-${base}` - }, [authorHex, relayListsKey, subRequests]) + return `profile-posts-${authorHex}-${kindsKey}-${limit}` + }, [authorHex, kindsKey, limit]) const refresh = useCallback(() => { setRefreshToken((n) => n + 1) diff --git a/src/hooks/useProfilePins.tsx b/src/hooks/useProfilePins.tsx index 2191d05b..9cf39f12 100644 --- a/src/hooks/useProfilePins.tsx +++ b/src/hooks/useProfilePins.tsx @@ -13,6 +13,7 @@ import { normalizeUrl } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import client, { eventService, queryService } from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' const CACHE_DURATION = 5 * 60 * 1000 @@ -137,6 +138,39 @@ export function useProfilePins(pubkey: string | undefined) { setLoadingPins(true) try { const pk = normalizeHexPubkey(pubkey) + let paintedLocalPins = false + const localPinLists = eventService.listSessionEventsAuthoredBy(pk, { kinds: [10001], limit: 8 }) + const diskPinList = await indexedDb.getReplaceableEvent(pk, 10001).catch(() => undefined) + if (diskPinList) localPinLists.push(diskPinList) + const localPinList = + localPinLists.length > 0 + ? localPinLists.reduce((best, event) => + event.created_at > best.created_at ? event : best + ) + : null + if (localPinList?.tags?.length) { + const localIds = localPinList.tags + .filter((tag) => tag[0] === 'e' && tag[1]) + .slice(0, PROFILE_PAGE_PINS_RESOLVE_LIMIT) + .map((tag) => tag[1]!.toLowerCase()) + const [archiveHits, publicationHits] = await Promise.all([ + indexedDb.getArchivedEventsByIds(localIds), + Promise.all(localIds.map((id) => indexedDb.getEventFromPublicationStore(id))) + ]) + const localById = new Map() + for (const event of archiveHits) localById.set(event.id.toLowerCase(), event) + for (const event of publicationHits) { + if (event) localById.set(event.id.toLowerCase(), event) + } + const orderedLocal = orderPinEvents(localPinList, localById).slice(0, PROFILE_PAGE_PINS_RESOLVE_LIMIT) + if (orderedLocal.length > 0) { + setPinEvents(orderedLocal) + paintedLocalPins = true + pinsCache.set(cacheKey, { events: orderedLocal, lastUpdated: Date.now() }) + orderedLocal.forEach((event) => client.addEventToCache(event)) + } + } + const [authorRl, pinListEarly] = await Promise.all([ client.fetchRelayList(pk).catch(() => ({ read: [] as string[], @@ -147,7 +181,7 @@ export function useProfilePins(pubkey: string | undefined) { const authorRelays = buildAuthorInboxOutboxRelayUrls(authorRl, blockedRelays, includeAuthorLocalRelays) const pinsResolveRelays = buildProfileAugmentedReadRelayUrls(authorRelays, blockedRelays) if (!pinsResolveRelays.length) { - setPinEvents([]) + if (!paintedLocalPins) setPinEvents([]) return } @@ -174,12 +208,12 @@ export function useProfilePins(pubkey: string | undefined) { } if (!pinList) { - setPinEvents([]) + if (!paintedLocalPins) setPinEvents([]) return } if (!pinList.tags?.length) { - setPinEvents([]) + if (!paintedLocalPins) setPinEvents([]) return } diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 79dc3dd5..e476a053 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -369,7 +369,7 @@ export function useProfileTimeline({ try { const [disk, longFormRows] = await Promise.all([ - client.getTimelineDiskSnapshotEvents( + client.getLocalFeedEvents( provisionalSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> ), longFormPrefetch @@ -412,7 +412,7 @@ export function useProfileTimeline({ if (cancelled || deltaUrls.length === 0) return const deltaSubs = buildSubRequests([deltaUrls], pubkey, kinds, limit, hasCalendarKinds) try { - const diskDelta = await client.getTimelineDiskSnapshotEvents( + const diskDelta = await client.getLocalFeedEvents( deltaSubs as Array<{ urls: string[]; filter: TSubRequestFilter }> ) if (!cancelled && diskDelta.length > 0) { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1b9adf8c..3fbf2e01 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -748,6 +748,10 @@ export default { Poll: "Poll", Media: "Media", "Articles and Publications": "Articles and Publications", + Liked: "Liked", + "Refreshing liked posts...": "Refreshing liked posts...", + "No liked posts yet": "No liked posts yet", + "Liked by you": "Liked by you", "Search articles...": "Search articles...", "Refreshing articles...": "Refreshing articles...", "No articles or publications found": "No articles or publications found", diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index a6545351..cb899824 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -180,25 +180,34 @@ export function buildProfilePageReadRelayUrls( const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])] const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0 - let urls = getRelayUrlsWithFavoritesFastReadAndInbox( - favoriteRelays, - blockedRelays, - authorRead, + const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const fastReadLayer = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] + const authorWriteLayer = relayUrlsLocalsFirst(authorWrite) + const authorReadLayer = relayUrlsLocalsFirst(authorRead) + const urls = feedRelayPolicyUrls( + [ + { source: 'author-write', urls: authorWriteLayer }, + { source: 'author-read', urls: authorReadLayer }, + { source: 'favorites', urls: favorites }, + { source: 'fast-read', urls: fastReadLayer } + ], { - userWriteRelays: authorWrite, - authorWriteRelays: [], + operation: 'read', + blockedRelays, maxRelays, - applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind + applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind, + socialKindBlockedExemptRelays: [...authorWriteLayer, ...authorReadLayer], + allowThirdPartyLocalRelays: true } ) /** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */ if (authorHasNoNip65) { const profileFetchLayer = PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] - urls = mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8) + return mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8) } if (wantsDocumentLayer) { const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] - urls = mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, maxRelays + 6) + return mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, maxRelays + 6) } return urls } diff --git a/src/lib/feed-local-event-match.test.ts b/src/lib/feed-local-event-match.test.ts new file mode 100644 index 00000000..c63a166f --- /dev/null +++ b/src/lib/feed-local-event-match.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import type { Event } from 'nostr-tools' +import { eventMatchesLocalFeedFilter } from './feed-local-event-match' + +function event(overrides: Partial = {}): Event { + return { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + created_at: 1000, + kind: 1, + tags: [ + ['p', 'c'.repeat(64)], + ['t', 'Nostr'], + ['d', 'article-1'] + ], + content: 'hello local cache', + sig: 'd'.repeat(128), + ...overrides + } +} + +describe('eventMatchesLocalFeedFilter', () => { + it('matches ids, authors, kinds, time windows, tags, and search', () => { + expect( + eventMatchesLocalFeedFilter(event(), { + ids: ['a'.repeat(64)], + authors: ['b'.repeat(64)], + kinds: [1], + since: 900, + until: 1100, + '#p': ['c'.repeat(64)], + '#t': ['nostr'], + search: 'local' + }) + ).toBe(true) + }) + + it('rejects events outside any filter constraint', () => { + expect(eventMatchesLocalFeedFilter(event({ kind: 6 }), { kinds: [1] })).toBe(false) + expect(eventMatchesLocalFeedFilter(event(), { since: 1001 })).toBe(false) + expect(eventMatchesLocalFeedFilter(event(), { '#e': ['e'.repeat(64)] })).toBe(false) + expect(eventMatchesLocalFeedFilter(event(), { search: 'relay-only' })).toBe(false) + }) +}) diff --git a/src/lib/feed-local-event-match.ts b/src/lib/feed-local-event-match.ts new file mode 100644 index 00000000..bd452ed3 --- /dev/null +++ b/src/lib/feed-local-event-match.ts @@ -0,0 +1,43 @@ +import type { Event, Filter } from 'nostr-tools' + +function valuesMatchTag(tagName: string, eventValues: string[], filterValues: unknown[]): boolean { + if (tagName.toLowerCase() === 't') { + const allowed = new Set(filterValues.map((v) => String(v).toLowerCase())) + return eventValues.some((v) => allowed.has(v.toLowerCase())) + } + const allowed = new Set(filterValues.map((v) => String(v))) + return eventValues.some((v) => allowed.has(v)) +} + +export function eventMatchesLocalFeedFilter(event: Event, filter: Filter): boolean { + if (Array.isArray(filter.ids) && filter.ids.length > 0 && !filter.ids.includes(event.id)) return false + if (Array.isArray(filter.authors) && filter.authors.length > 0 && !filter.authors.includes(event.pubkey)) { + return false + } + if (Array.isArray(filter.kinds) && filter.kinds.length > 0 && !filter.kinds.includes(event.kind)) return false + if (typeof filter.since === 'number' && event.created_at < filter.since) return false + if (typeof filter.until === 'number' && event.created_at > filter.until) return false + + const search = typeof filter.search === 'string' ? filter.search.trim().toLowerCase() : '' + if (search) { + const haystack = `${event.content ?? ''} ${(event.tags ?? []).flat().join(' ')}`.toLowerCase() + if (!haystack.includes(search)) return false + } + + for (const [key, values] of Object.entries(filter)) { + if (!key.startsWith('#')) continue + if (!Array.isArray(values) || values.length === 0) continue + const tagName = key.slice(1) + const eventValues = event.tags + .filter((tag) => tag[0] === tagName && typeof tag[1] === 'string') + .map((tag) => tag[1] as string) + if (eventValues.length === 0) return false + if (!valuesMatchTag(tagName, eventValues, values)) return false + } + + return true +} + +export function eventMatchesAnyLocalFeedFilter(event: Event, filters: readonly Filter[]): boolean { + return filters.some((filter) => eventMatchesLocalFeedFilter(event, filter)) +} diff --git a/src/lib/home-feed-relays.ts b/src/lib/home-feed-relays.ts new file mode 100644 index 00000000..8fa9c970 --- /dev/null +++ b/src/lib/home-feed-relays.ts @@ -0,0 +1,27 @@ +import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' +import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' +import { normalizeAnyRelayUrl } from '@/lib/url' + +function relayUrlIsNostrLandAggr(url: string): boolean { + const normalized = (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase() + const aggr = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase() + return normalized === aggr +} + +export function buildAllFavoritesFeedRelayUrls( + favoriteRelays: string[], + blockedRelays: string[], + extraFeedRelayUrls: string[] +): string[] { + return feedRelayPolicyUrls([ + { source: 'favorites', urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) }, + { source: 'fallback', urls: extraFeedRelayUrls } + ], { + operation: 'favorites-feed', + blockedRelays, + nostrLandAggr: 'never', + applySocialKindBlockedFilter: false, + allowThirdPartyLocalRelays: true + }).filter((url) => !relayUrlIsNostrLandAggr(url)) +} diff --git a/src/lib/relay-url-priority.test.ts b/src/lib/relay-url-priority.test.ts index 2befa11b..9c9b70a4 100644 --- a/src/lib/relay-url-priority.test.ts +++ b/src/lib/relay-url-priority.test.ts @@ -4,7 +4,7 @@ import { dedupeNormalizeRelayUrlsOrdered, filterContextAuthorReadRelaysForPublish } from '@/lib/relay-url-priority' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { buildProfilePageReadRelayUrls, getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' describe('filterContextAuthorReadRelaysForPublish', () => { @@ -71,3 +71,34 @@ describe('nostr.land aggregator feed relay policy', () => { expect(out).toEqual(['wss://relay.example.com/']) }) }) + +describe('buildProfilePageReadRelayUrls', () => { + it('includes viewed author write relays for remote profile timelines', () => { + const out = buildProfilePageReadRelayUrls( + [], + [], + { + read: [], + write: ['wss://author-outbox.example/'] + }, + false + ) + + expect(out).toContain('wss://author-outbox.example/') + }) + + it('prioritizes viewed author write relays ahead of long read lists', () => { + const out = buildProfilePageReadRelayUrls( + [], + [], + { + read: Array.from({ length: 20 }, (_, i) => `wss://author-inbox-${i}.example/`), + write: ['wss://author-outbox.example/'] + }, + false + ) + + expect(out[0]).toBe('wss://aggr.nostr.land/') + expect(out[1]).toBe('wss://author-outbox.example/') + }) +}) diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 87682292..1524b462 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' import { checkAlgoRelay } from '@/lib/relay' import { normalizeUrl } from '@/lib/url' -import { useFeed } from '@/providers/FeedProvider' +import { useFeed } from '@/providers/feed-context' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import relayInfoService from '@/services/relay-info.service' import { kinds } from 'nostr-tools' @@ -114,6 +114,7 @@ const RelaysFeed = forwardRef< preserveTimelineOnSubRequestsChange repliesSubRequests={repliesSubRequests} widenMainGalleryRelays={false} + feedSubscriptionKey="home-all-favorites" feedTimelineScopeKey="all-favorites" showFeedClientFilter hostPrimaryPageName="feed" diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 575ff22a..7fc51f4a 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -2,7 +2,7 @@ import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' -import { useFeed } from '@/providers/FeedProvider' +import { useFeed } from '@/providers/feed-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index 02868e88..6f908110 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -145,7 +145,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) [pubkey, relayUrls, followPubkeys, feedFilterKey] ) - const mergeHeatMapData = useCallback(async (): Promise<{ + const mergeHeatMapData = useCallback(async (includeRelay = true): Promise<{ bubbles: TRelayThreadHeatBubble[] edges: TRelayThreadHeatEdge[] }> => { @@ -164,7 +164,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) maxMatches: ARCHIVE_HEAT_MAX_MATCHES }) const relayFetch = - relayUrls.length > 0 + includeRelay && relayUrls.length > 0 ? client.fetchEvents( relayUrls, { kinds: [...HEAT_KINDS], limit: HEAT_REQ_LIMIT }, @@ -228,7 +228,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) rootById.set(ev.id.toLowerCase(), ev) } const stillMissing = missingRootIds.filter((id) => !rootById.has(id)) - if (stillMissing.length > 0 && relayUrls.length > 0) { + if (includeRelay && stillMissing.length > 0 && relayUrls.length > 0) { const fetched = await raceWithTimeout( client.fetchEvents( relayUrls, @@ -299,7 +299,15 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) setIsMerging(true) try { - const { bubbles, edges: nextEdges } = await mergeHeatMapData() + const local = await mergeHeatMapData(false) + if (cancelled) return + if (!hadEnvelope || local.bubbles.length > 0) { + setRows(local.bubbles) + setEdges(local.edges) + setLoading(false) + } + + const { bubbles, edges: nextEdges } = await mergeHeatMapData(true) if (cancelled) return setRows(bubbles) setEdges(nextEdges) diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx index e13e9f1e..947031d5 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx @@ -139,7 +139,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { const [error, setError] = useState(null) const [rescanTick, setRescanTick] = useState(0) - const mergeData = useCallback(async (): Promise => { + const mergeData = useCallback(async (includeRelay = true): Promise => { const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC const sessionEv = eventService.listSessionEventsByKinds(MAP_KINDS, { limit: SESSION_LIMIT }) @@ -150,7 +150,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { maxMatches: ARCHIVE_MAX_MATCHES }) const relayFetch = - relayUrls.length > 0 + includeRelay && relayUrls.length > 0 ? client.fetchEvents( relayUrls, { kinds: [...MAP_KINDS], limit: HEAT_REQ_LIMIT }, @@ -196,10 +196,13 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { setIsMerging(true) void (async () => { try { - const bubbles = await mergeData() - if (!cancelled) { - setRows(bubbles) - } + const localBubbles = await mergeData(false) + if (cancelled) return + setRows(localBubbles) + setLoading(false) + + const bubbles = await mergeData(true) + if (!cancelled) setRows(bubbles) } catch (e) { if (!cancelled) { logger.warn('[TopicKeywordHeatMap] merge failed', { err: e }) diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index e4af534f..b458d006 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -9,10 +9,7 @@ import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' -import { - computeKind777SpellFeedSubscriptionKey, - computeSpellSubRequestsIdentityKey -} from '@/lib/spell-feed-request-identity' +import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import { isUserInEventMentions } from '@/lib/event' import { decodeFollowSetSpellId, @@ -223,6 +220,11 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { relayList, augment } + const syncProvisional = buildInboxShardFollowingSubRequests({ + authors: provisionalAuthors, + ...inboxFallbackArgs + }) + if (!cancelled && syncProvisional.length > 0) setFollowingSubRequests(syncProvisional) const [rawProv, followings] = await Promise.all([ racePromiseWithTimeout( @@ -273,6 +275,14 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { } const listed = pubkeysFromFollowSetEvent(ev) const authorPubkeys = [pubkey, ...listed] + const syncReq = buildInboxShardFollowingSubRequests({ + authors: authorPubkeys, + favoriteRelays, + blockedRelays, + relayList, + augment + }) + if (!cancelled && syncReq.length > 0) setFollowingSubRequests(syncReq) const rawFs = await racePromiseWithTimeout( client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) as Promise, FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS, @@ -425,10 +435,16 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { }, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) const spellFeedSubscriptionKey = useMemo(() => { - if (selectedFauxSpell) return computeSpellSubRequestsIdentityKey(subRequests) - if (selectedSpell) return computeKind777SpellFeedSubscriptionKey(selectedSpell, subRequests) + if (selectedFauxSpell) { + const filters = subRequests.map((req) => stableSpellFeedFilterKey(req.filter)).join('|') + return `faux-spell:${selectedFauxSpell}:${pubkey ?? ''}:${filters}` + } + if (selectedSpell) { + const filters = subRequests.map((req) => stableSpellFeedFilterKey(req.filter)).join('|') + return `spell:${selectedSpell.id}:${filters}` + } return '' - }, [selectedFauxSpell, selectedSpell, subRequests]) + }, [selectedFauxSpell, selectedSpell, subRequests, pubkey]) const spellBrowseRelayUrls = useMemo(() => { const set = new Set() diff --git a/src/providers/FeedProvider.test.ts b/src/providers/FeedProvider.test.ts new file mode 100644 index 00000000..e45ac7b3 --- /dev/null +++ b/src/providers/FeedProvider.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' +import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' +import { buildAllFavoritesFeedRelayUrls } from '@/lib/home-feed-relays' + +describe('home feed relay policy', () => { + it('keeps aggr.nostr.land out of the main home feed', () => { + const urls = buildAllFavoritesFeedRelayUrls( + ['wss://relay.example.com/', AGGR_NOSTR_LAND_WSS], + [], + [buildWispTrendingNotesRelayUrl(), AGGR_NOSTR_LAND_WSS] + ) + + expect(urls).toContain('wss://relay.example.com/') + expect(urls).toContain(buildWispTrendingNotesRelayUrl()) + expect(urls).not.toContain('wss://aggr.nostr.land/') + expect(urls).not.toContain(AGGR_NOSTR_LAND_WSS) + }) +}) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index ffe28120..81d3a6f3 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -1,7 +1,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' -import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' +import { buildAllFavoritesFeedRelayUrls } from '@/lib/home-feed-relays' import logger from '@/lib/logger' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { normalizeAnyRelayUrl } from '@/lib/url' @@ -12,7 +12,6 @@ import { FeedContext } from './feed-context' import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useNostr } from './NostrProvider' -export { useFeed } from './feed-context' export type { TFeedContext } from './feed-context' function relayUrlListIdentity(urls: string[]): string { @@ -23,23 +22,6 @@ function relayUrlListIdentity(urls: string[]): string { .join('\n') } -function buildAllFavoritesFeedRelayUrls( - favoriteRelays: string[], - blockedRelays: string[], - extraFeedRelayUrls: string[] -): string[] { - return feedRelayPolicyUrls([ - { source: 'favorites', urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) }, - { source: 'fallback', urls: extraFeedRelayUrls } - ], { - operation: 'favorites-feed', - blockedRelays, - nostrLandAggr: 'never', - applySocialKindBlockedFilter: false, - allowThirdPartyLocalRelays: true - }) -} - function relayListMentionsNostrLand(urls: readonly string[]): boolean { return urls.some((url) => { const normalized = normalizeAnyRelayUrl(url) || url.trim() diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 209293b7..b03f1238 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -36,6 +36,7 @@ import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { isCalendarEventKind } from '@/lib/calendar-event' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { normalizeUrl } from '@/lib/url' @@ -675,6 +676,20 @@ export class EventService { return results } + getSessionEventsMatchingFilters(filters: readonly Filter[], limit: number): NEvent[] { + if (filters.length === 0 || limit <= 0) return [] + const cappedLimit = Math.min(Math.max(limit, 1), 8000) + const results: NEvent[] = [] + for (const [, event] of this.sessionEventCache.entries()) { + if (shouldDropEventOnIngest(event)) continue + if (!eventMatchesAnyLocalFeedFilter(event, filters)) continue + results.push(event) + if (results.length >= cappedLimit) break + } + results.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + return results + } + /** * Session LRU: events authored by `authorPubkey` (e.g. notes, reposts, reactions) for local aggregates. */ diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 3dc40b3a..e1693520 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1824,6 +1824,54 @@ class ClientService extends EventTarget { return merged.slice(0, mergedTimelineLimit) } + async getLocalFeedEvents( + subRequests: { urls: string[]; filter: TSubRequestFilter }[], + options?: { maxRowsScanned?: number; maxMatches?: number } + ): Promise { + if (!subRequests.length) return [] + const filters = subRequests.map(({ filter }) => filter as Filter) + const maxMatches = Math.min( + Math.max( + options?.maxMatches ?? + Math.max( + 500, + ...subRequests.map(({ filter }) => + typeof filter.limit === 'number' && filter.limit > 0 ? filter.limit : 0 + ) + ), + 1 + ), + 3000 + ) + const maxRowsScanned = Math.min(Math.max(options?.maxRowsScanned ?? 18_000, 200), 50_000) + const byId = new Map() + const add = (rows: NEvent[]) => { + for (const event of rows) { + if (shouldDropEventOnIngest(event)) continue + if (!byId.has(event.id)) byId.set(event.id, event) + } + } + + add(this.eventService.getSessionEventsMatchingFilters(filters, maxMatches)) + + const [timelineRows, archiveRows, publicationRows] = await Promise.all([ + this.getTimelineDiskSnapshotEvents(subRequests).catch(() => [] as NEvent[]), + indexedDb + .scanEventArchiveByFilters(filters, { maxRowsScanned, maxMatches }) + .catch(() => [] as NEvent[]), + indexedDb + .scanPublicationEventsByFilters(filters, { maxRowsScanned: Math.min(maxRowsScanned, 16_000), maxMatches }) + .catch(() => [] as NEvent[]) + ]) + add(timelineRows) + add(archiveRows) + add(publicationRows) + + return [...byId.values()] + .sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + .slice(0, maxMatches) + } + async subscribeTimeline( subRequests: { urls: string[]; filter: TSubRequestFilter }[], { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index b82a6bb7..cebff045 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -22,6 +22,8 @@ import { import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' +import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match' +import type { Filter } from 'nostr-tools' /** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */ export type TArchivedEventRow = { @@ -1511,6 +1513,58 @@ class IndexedDbService { }) } + async scanPublicationEventsByFilters( + filters: readonly Filter[], + options: { maxRowsScanned: number; maxMatches: number } + ): Promise { + await this.initPromise + if ( + !this.db || + !this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS) || + filters.length === 0 || + options.maxMatches <= 0 + ) { + return [] + } + const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000) + const maxMatches = Math.min(Math.max(options.maxMatches, 1), 3000) + const workingCap = Math.min(4000, Math.max(maxMatches * 8, maxMatches + 80)) + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') + const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) + const request = store.openCursor() + const results: Event[] = [] + let scanned = 0 + + request.onsuccess = () => { + const cursor = (request as IDBRequest).result + if (!cursor || scanned >= maxRows) { + transaction.commit() + results.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + resolve(results.slice(0, maxMatches)) + return + } + scanned += 1 + const item = cursor.value as TValue | undefined + const event = item?.value + if (event && !shouldDropEventOnIngest(event) && eventMatchesAnyLocalFeedFilter(event, filters)) { + results.push(event) + if (results.length > workingCap) { + results.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + results.length = Math.min(results.length, Math.max(maxMatches * 3, maxMatches + 40)) + } + } + cursor.continue() + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + /** * 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. @@ -3085,6 +3139,50 @@ class IndexedDbService { }) } + async scanEventArchiveByFilters( + filters: readonly Filter[], + options: { maxRowsScanned: number; maxMatches: number } + ): Promise { + if (filters.length === 0 || options.maxMatches <= 0) return [] + const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000) + const maxMatches = Math.min(Math.max(options.maxMatches, 1), 3000) + const workingCap = Math.min(4000, Math.max(maxMatches * 8, maxMatches + 80)) + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] + + return new Promise((resolve, reject) => { + const buf: Event[] = [] + let scanned = 0 + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor || scanned >= maxRows) { + tx.commit() + buf.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + resolve(buf.slice(0, maxMatches)) + return + } + scanned++ + const row = cursor.value as TArchivedEventRow | undefined + const ev = row?.value + if (ev && !shouldDropEventOnIngest(ev) && eventMatchesAnyLocalFeedFilter(ev, filters)) { + buf.push(ev) + if (buf.length > workingCap) { + buf.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id)) + buf.length = Math.min(buf.length, Math.max(maxMatches * 3, maxMatches + 40)) + } + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + /** * Scan {@link StoreNames.EVENT_ARCHIVE} for events whose kind is in `kinds`. * Cursor order follows the store key (not time), so `since` is applied **after** the scan: collect kind diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 186d6eef..d3e4d778 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -95,11 +95,11 @@ class NoteStatsService { private processBatchRunning = false /** While greater than zero, {@link processBatch} defers so user publishes are not starved for WebSocket pool / bandwidth. */ private publishPriorityDepth = 0 - private readonly BATCH_DELAY = 120 + private readonly BATCH_DELAY = 40 /** Larger slices: feed cards each trigger a stats fetch; tiny slices left the tail of the feed starved. */ - private readonly MAX_BATCH_SIZE = 20 + private readonly MAX_BATCH_SIZE = 32 /** Parallel stats REQs per slice (bounded by relay pool pressure). */ - private readonly STATS_SLICE_CONCURRENCY = 6 + private readonly STATS_SLICE_CONCURRENCY = 8 /** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */ private pendingSyntheticRootById = new Map() /** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */ @@ -415,18 +415,20 @@ class NoteStatsService { }) events.push(evt) } - if (nonSocial.length > 0) { - await queryService.fetchEvents(finalRelayUrls, nonSocial, { - ...fetchOpts, - onevent: onStatsEvent - }) - } - if (social.length > 0) { - await queryService.fetchEvents(finalRelayUrls, social, { - ...fetchOpts, - onevent: onStatsEvent - }) - } + await Promise.all([ + nonSocial.length > 0 + ? queryService.fetchEvents(finalRelayUrls, nonSocial, { + ...fetchOpts, + onevent: onStatsEvent + }) + : Promise.resolve([] as Event[]), + social.length > 0 + ? queryService.fetchEvents(finalRelayUrls, social, { + ...fetchOpts, + onevent: onStatsEvent + }) + : Promise.resolve([] as Event[]) + ]) logger.debug('[NoteStats] processSingleEvent: relay fetch finished', { eventId: `${resolvedEvent.id.slice(0, 12)}…`, @@ -508,37 +510,34 @@ class NoteStatsService { // 6. Session cache (e.g. notifications): events that reference this id with a relay hint client.eventService.getSessionRelayHintsForHexTarget(event.id).forEach(add) - // 7. Author's inboxes (read relays from kind 10002) - try { - const relayList = await Promise.race([ + const emptyViewerRl: TRelayList = { + write: [], + read: [], + originalRelays: [], + httpRead: [], + httpWrite: [], + httpOriginalRelays: [] + } + const me = client.pubkey?.trim() + const [authorRelayList, viewerRelayList] = await Promise.all([ + Promise.race([ client.fetchRelayList(event.pubkey), - new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 2000)) - ]) - userReadRelaysWithHttp(relayList).slice(0, 10).forEach(add) - } catch { - // ignore + new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 1500)) + ]).catch(() => undefined), + me + ? Promise.race([ + client.fetchRelayList(me), + new Promise((r) => setTimeout(() => r(emptyViewerRl), 1500)) + ]).catch(() => undefined) + : Promise.resolve(undefined) + ]) + // 7. Author's inboxes (read relays from kind 10002) + if (authorRelayList) { + userReadRelaysWithHttp(authorRelayList).slice(0, 10).forEach(add) } - // 8. Logged-in viewer's inboxes (NIP-65 read + kind 10243 http read) — same events often land on personal relays. - try { - const me = client.pubkey?.trim() - if (me) { - const emptyViewerRl: TRelayList = { - write: [], - read: [], - originalRelays: [], - httpRead: [], - httpWrite: [], - httpOriginalRelays: [] - } - const mine = await Promise.race([ - client.fetchRelayList(me), - new Promise((r) => setTimeout(() => r(emptyViewerRl), 2000)) - ]) - userReadRelaysWithHttp(mine).slice(0, 12).forEach(add) - } - } catch { - // ignore + if (viewerRelayList) { + userReadRelaysWithHttp(viewerRelayList).slice(0, 12).forEach(add) } return feedRelayPolicyUrls([{ source: 'fallback', urls: Array.from(seen) }], {