Browse Source

bug-fixes

added likes to profile page
imwald
Silberengel 1 month ago
parent
commit
6200f6624d
  1. 52
      src/components/NoteList/index.tsx
  2. 31
      src/components/NoteStats/LikeButton.tsx
  3. 14
      src/components/NoteStats/Likes.tsx
  4. 15
      src/components/NoteStats/ReplyButton.tsx
  5. 15
      src/components/NoteStats/RepostButton.tsx
  6. 23
      src/components/NoteStats/ZapButton.tsx
  7. 42
      src/components/NoteStats/index.tsx
  8. 298
      src/components/Profile/ProfileLikedFeed.tsx
  9. 10
      src/components/Profile/ProfileMediaFeed.tsx
  10. 23
      src/components/Profile/index.tsx
  11. 86
      src/components/ReplyNoteList/index.tsx
  12. 11
      src/constants.ts
  13. 23
      src/hooks/useProfileAuthorFeedSubRequests.ts
  14. 40
      src/hooks/useProfilePins.tsx
  15. 4
      src/hooks/useProfileTimeline.tsx
  16. 4
      src/i18n/locales/en.ts
  17. 27
      src/lib/favorites-feed-relays.ts
  18. 44
      src/lib/feed-local-event-match.test.ts
  19. 43
      src/lib/feed-local-event-match.ts
  20. 27
      src/lib/home-feed-relays.ts
  21. 33
      src/lib/relay-url-priority.test.ts
  22. 3
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  23. 2
      src/pages/primary/NoteListPage/index.tsx
  24. 16
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  25. 15
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx
  26. 30
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  27. 19
      src/providers/FeedProvider.test.ts
  28. 20
      src/providers/FeedProvider.tsx
  29. 15
      src/services/client-events.service.ts
  30. 48
      src/services/client.service.ts
  31. 98
      src/services/indexed-db.service.ts
  32. 57
      src/services/note-stats.service.ts

52
src/components/NoteList/index.tsx

@ -491,7 +491,8 @@ function buildNoteListMappedFilterForFullSearch( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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

31
src/components/NoteStats/LikeButton.tsx

@ -25,6 +25,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -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 { @@ -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<TNoteStats>
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; @@ -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; @@ -344,3 +356,16 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
</DropdownMenu>
)
}
export default function LikeButton({ event, hideCount = false }: LikeButtonProps) {
const noteStats = useNoteStatsById(event.id)
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
return (
<LikeButtonWithStats
event={event}
hideCount={hideCount}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
/>
)
}

14
src/components/NoteStats/Likes.tsx

@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' @@ -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' @@ -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<TNoteStats>
}
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<string | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
@ -213,3 +218,8 @@ export default function Likes({ event }: { event: Event }) { @@ -213,3 +218,8 @@ export default function Likes({ event }: { event: Event }) {
</ScrollArea>
)
}
export default function Likes({ event }: LikesProps) {
const noteStats = useNoteStatsById(event.id)
return <LikesWithStats event={event} noteStats={noteStats} />
}

15
src/components/NoteStats/ReplyButton.tsx

@ -2,6 +2,7 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' @@ -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' @@ -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<TNoteStats>
}
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 @@ -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 <ReplyButtonWithStats event={event} hideCount={hideCount} noteStats={noteStats} />
}

15
src/components/NoteStats/RepostButton.tsx

@ -16,6 +16,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -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' @@ -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<TNoteStats>
}
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 @@ -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 <RepostButtonWithStats event={event} hideCount={hideCount} noteStats={noteStats} />
}

23
src/components/NoteStats/ZapButton.tsx

@ -9,6 +9,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata' @@ -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' @@ -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<TNoteStats>
}
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; @@ -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; @@ -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 <ZapButtonWithStats event={event} hideCount={hideCount} noteStats={noteStats} />
}
function formatAmount(amount: number) {
if (amount < 1000) return amount
if (amount < 1000000) return `${Math.round(amount / 100) / 10}k`

42
src/components/NoteStats/index.tsx

@ -2,6 +2,7 @@ import { cn } from '@/lib/utils' @@ -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' @@ -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({ @@ -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({ @@ -79,7 +81,7 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
{showLikesPills && <Likes event={event} />}
{showLikesPills && <LikesWithStats event={event} noteStats={noteStats} />}
</>
)}
<div
@ -89,13 +91,18 @@ export default function NoteStats({ @@ -89,13 +91,18 @@ export default function NoteStats({
classNames?.buttonBar
)}
>
<ReplyButton event={event} hideCount={hideInteractions} />
<ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
<RepostButton event={event} hideCount={hideInteractions} />
<RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
<LikeButton event={event} hideCount={hideInteractions} />
<LikeButtonWithStats
event={event}
hideCount={hideInteractions}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButton event={event} hideCount={hideInteractions} />
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
{!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} />
@ -108,20 +115,25 @@ export default function NoteStats({ @@ -108,20 +115,25 @@ export default function NoteStats({
<div className={cn('select-none', className)} data-note-stats onClick={(e) => e.stopPropagation()}>
{displayTopZapsAndLikes && (
<>
{showLikesPills && <Likes event={event} />}
{showLikesPills && <LikesWithStats event={event} noteStats={noteStats} />}
</>
)}
<div className="flex justify-between h-5 [&_svg]:size-4">
<div
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
>
<ReplyButton event={event} hideCount={hideInteractions} />
<ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
<RepostButton event={event} hideCount={hideInteractions} />
<RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
<LikeButton event={event} hideCount={hideInteractions} />
<LikeButtonWithStats
event={event}
hideCount={hideInteractions}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButton event={event} hideCount={hideInteractions} />
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
</div>
<div className="flex items-center">

298
src/components/Profile/ProfileLikedFeed.tsx

@ -0,0 +1,298 @@ @@ -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<string, { reaction: Event; targetRef: ReactionTargetRef }>()
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<LikedTarget[]>([])
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(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<string, Event>()
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<string, LikedTarget>()
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 (
<div className="mt-4 space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (likedTargets.length === 0) {
return (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t('No liked posts yet')}
</div>
)
}
return (
<div className="mt-4 min-w-0">
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing liked posts...')}
</div>
)}
<div className="space-y-2">
{displayedTargets.map(({ target }) => (
<NoteCard key={target.id} className="w-full" event={target} filterMutedNotes={false} bottomNoteLabel={t('Liked by you')} />
))}
</div>
{displayedTargets.length < likedTargets.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
</div>
)}
</div>
)
})
ProfileLikedFeed.displayName = 'ProfileLikedFeed'
export default ProfileLikedFeed

10
src/components/Profile/ProfileMediaFeed.tsx

@ -1,7 +1,6 @@ @@ -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<TNoteListRef, { pubkey: string }>(({ pubkey @@ -99,10 +98,11 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ 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()

23
src/components/Profile/index.tsx

@ -58,6 +58,7 @@ import logger from '@/lib/logger' @@ -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({ @@ -196,7 +197,8 @@ export default function Profile({
const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef<TNoteListRef>(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({ @@ -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({ @@ -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({ @@ -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({ @@ -702,7 +713,7 @@ export default function Profile({
<Tabs
value={profileFeedTab}
onValueChange={(v) => {
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({ @@ -712,6 +723,7 @@ export default function Profile({
<TabsTrigger value="posts">{t('Posts')}</TabsTrigger>
<TabsTrigger value="media">{t('Media')}</TabsTrigger>
<TabsTrigger value="publications">{t('Articles and Publications')}</TabsTrigger>
{isSelf && <TabsTrigger value="liked">{t('Liked')}</TabsTrigger>}
</TabsList>
<TabsContent value="posts" className="min-w-0 focus-visible:outline-none">
<ProfileFeedWithPins ref={postsFeedRef} pubkey={pubkey} />
@ -722,6 +734,11 @@ export default function Profile({ @@ -722,6 +734,11 @@ export default function Profile({
<TabsContent value="publications" className="min-w-0 focus-visible:outline-none">
<ProfilePublicationsFeed ref={publicationsFeedRef} pubkey={pubkey} />
</TabsContent>
{isSelf && (
<TabsContent value="liked" className="min-w-0 focus-visible:outline-none">
<ProfileLikedFeed ref={likedFeedRef} pubkey={pubkey} />
</TabsContent>
)}
</Tabs>
{openPublicMessageTo && (
<PostEditor

86
src/components/ReplyNoteList/index.tsx

@ -403,53 +403,6 @@ function ReplyNoteList({ @@ -403,53 +403,6 @@ function ReplyNoteList({
return out.length ? out : undefined
}, [duplicateWebPreviewCleanedUrlHints, rootInfo])
// Helper function to get vote score for a reply
const getReplyVoteScore = (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 === '⬇'
)
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<string>()
const replyEvents: NEvent[] = []
@ -517,6 +470,33 @@ function ReplyNoteList({ @@ -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<string, { vote: number; controversy: number; zapAmount: number }>()
// Sort notes/comments; zap receipts (9735) are always listed first, largest sats → smallest
switch (sort) {
@ -533,8 +513,8 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -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
}

11
src/constants.ts

@ -616,13 +616,6 @@ export const THREAD_BACKLINK_STREAM_KINDS: readonly number[] = [ @@ -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 = @@ -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

23
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -1,8 +1,7 @@ @@ -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({ @@ -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)

40
src/hooks/useProfilePins.tsx

@ -13,6 +13,7 @@ import { normalizeUrl } from '@/lib/url' @@ -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) { @@ -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<string, Event>()
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) { @@ -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) { @@ -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
}

4
src/hooks/useProfileTimeline.tsx

@ -369,7 +369,7 @@ export function useProfileTimeline({ @@ -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({ @@ -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) {

4
src/i18n/locales/en.ts

@ -748,6 +748,10 @@ export default { @@ -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",

27
src/lib/favorites-feed-relays.ts

@ -180,25 +180,34 @@ export function buildProfilePageReadRelayUrls( @@ -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
}

44
src/lib/feed-local-event-match.test.ts

@ -0,0 +1,44 @@ @@ -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> = {}): 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)
})
})

43
src/lib/feed-local-event-match.ts

@ -0,0 +1,43 @@ @@ -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))
}

27
src/lib/home-feed-relays.ts

@ -0,0 +1,27 @@ @@ -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))
}

33
src/lib/relay-url-priority.test.ts

@ -4,7 +4,7 @@ import { @@ -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', () => { @@ -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/')
})
})

3
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed' @@ -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< @@ -114,6 +114,7 @@ const RelaysFeed = forwardRef<
preserveTimelineOnSubRequestsChange
repliesSubRequests={repliesSubRequests}
widenMainGalleryRelays={false}
feedSubscriptionKey="home-all-favorites"
feedTimelineScopeKey="all-favorites"
showFeedClientFilter
hostPrimaryPageName="feed"

2
src/pages/primary/NoteListPage/index.tsx

@ -2,7 +2,7 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -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'

16
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -145,7 +145,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -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) @@ -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) @@ -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) @@ -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)

15
src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx

@ -139,7 +139,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { @@ -139,7 +139,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
const [error, setError] = useState<string | null>(null)
const [rescanTick, setRescanTick] = useState(0)
const mergeData = useCallback(async (): Promise<TTopicKeywordBubble[]> => {
const mergeData = useCallback(async (includeRelay = true): Promise<TTopicKeywordBubble[]> => {
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) { @@ -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) { @@ -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 })

30
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -9,10 +9,7 @@ import { @@ -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) { @@ -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<TFeedSubRequest[]>(
@ -273,6 +275,14 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { @@ -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<TFeedSubRequest[]>(
client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) as Promise<TFeedSubRequest[]>,
FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS,
@ -425,10 +435,16 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { @@ -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<string>()

19
src/providers/FeedProvider.test.ts

@ -0,0 +1,19 @@ @@ -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)
})
})

20
src/providers/FeedProvider.tsx

@ -1,7 +1,7 @@ @@ -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' @@ -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 { @@ -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()

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

@ -36,6 +36,7 @@ import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' @@ -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 { @@ -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.
*/

48
src/services/client.service.ts

@ -1824,6 +1824,54 @@ class ClientService extends EventTarget { @@ -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<NEvent[]> {
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<string, NEvent>()
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 }[],
{

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

@ -22,6 +22,8 @@ import { @@ -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 { @@ -1511,6 +1513,58 @@ class IndexedDbService {
})
}
async scanPublicationEventsByFilters(
filters: readonly Filter[],
options: { maxRowsScanned: number; maxMatches: number }
): Promise<Event[]> {
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<IDBCursorWithValue>).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<Event> | 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 { @@ -3085,6 +3139,50 @@ class IndexedDbService {
})
}
async scanEventArchiveByFilters(
filters: readonly Filter[],
options: { maxRowsScanned: number; maxMatches: number }
): Promise<Event[]> {
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

57
src/services/note-stats.service.ts

@ -95,11 +95,11 @@ class NoteStatsService { @@ -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<string, Event>()
/** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */
@ -415,18 +415,20 @@ class NoteStatsService { @@ -415,18 +415,20 @@ class NoteStatsService {
})
events.push(evt)
}
if (nonSocial.length > 0) {
await queryService.fetchEvents(finalRelayUrls, nonSocial, {
await Promise.all([
nonSocial.length > 0
? queryService.fetchEvents(finalRelayUrls, nonSocial, {
...fetchOpts,
onevent: onStatsEvent
})
}
if (social.length > 0) {
await queryService.fetchEvents(finalRelayUrls, social, {
: 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,21 +510,6 @@ class NoteStatsService { @@ -508,21 +510,6 @@ 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([
client.fetchRelayList(event.pubkey),
new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 2000))
])
userReadRelaysWithHttp(relayList).slice(0, 10).forEach(add)
} catch {
// ignore
}
// 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: [],
@ -531,14 +518,26 @@ class NoteStatsService { @@ -531,14 +518,26 @@ class NoteStatsService {
httpWrite: [],
httpOriginalRelays: []
}
const mine = await Promise.race([
const me = client.pubkey?.trim()
const [authorRelayList, viewerRelayList] = await Promise.all([
Promise.race([
client.fetchRelayList(event.pubkey),
new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 1500))
]).catch(() => undefined),
me
? Promise.race([
client.fetchRelayList(me),
new Promise<TRelayList>((r) => setTimeout(() => r(emptyViewerRl), 2000))
new Promise<TRelayList>((r) => setTimeout(() => r(emptyViewerRl), 1500))
]).catch(() => undefined)
: Promise.resolve(undefined)
])
userReadRelaysWithHttp(mine).slice(0, 12).forEach(add)
// 7. Author's inboxes (read relays from kind 10002)
if (authorRelayList) {
userReadRelaysWithHttp(authorRelayList).slice(0, 10).forEach(add)
}
} catch {
// ignore
// 8. Logged-in viewer's inboxes (NIP-65 read + kind 10243 http read) — same events often land on personal relays.
if (viewerRelayList) {
userReadRelaysWithHttp(viewerRelayList).slice(0, 12).forEach(add)
}
return feedRelayPolicyUrls([{ source: 'fallback', urls: Array.from(seen) }], {

Loading…
Cancel
Save