Browse Source

fix profiles and reactions being slow to use

imwald
Silberengel 1 month ago
parent
commit
25f3fecb81
  1. 5
      src/components/ContentPreview/index.tsx
  2. 8
      src/components/Note/index.tsx
  3. 7
      src/components/ReplyNote/index.tsx
  4. 5
      src/constants.ts
  5. 10
      src/features/feed/relay-policy.test.ts
  6. 20
      src/features/feed/relay-policy.ts
  7. 15
      src/hooks/useFetchProfile.tsx
  8. 59
      src/hooks/useNotificationReactionDisplay.ts
  9. 45
      src/lib/nostr-land-relay-eligibility.ts
  10. 9
      src/lib/relay-url-priority.test.ts
  11. 55
      src/providers/FeedProvider.tsx
  12. 10
      src/services/client-events.service.ts
  13. 23
      src/services/client-replaceable-events.service.ts

5
src/components/ContentPreview/index.tsx

@ -1,4 +1,3 @@
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' import { ExtendedKind, isNip71StyleVideoKind } from '@/constants'
import { import {
notificationReactionSummaryKey, notificationReactionSummaryKey,
@ -371,9 +370,7 @@ export default function ContentPreview({
if (isNip25ReactionKind(event.kind)) { if (isNip25ReactionKind(event.kind)) {
return withKindRow( return withKindRow(
<div className="pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground">
{reactionDisplay.status === 'pending' ? ( {reactionDisplay.status === 'vote_up' ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : reactionDisplay.status === 'vote_up' ? (
<span className="text-base leading-none" aria-hidden> <span className="text-base leading-none" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY} {DISCUSSION_UPVOTE_DISPLAY}
</span> </span>

8
src/components/Note/index.tsx

@ -76,7 +76,6 @@ import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay' import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
import NoteKindLabel from './NoteKindLabel' import NoteKindLabel from './NoteKindLabel'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
@ -607,12 +606,7 @@ export default function Note({
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{isNip25ReactionKind(event.kind) ? ( {isNip25ReactionKind(event.kind) ? (
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2">
{reactionDisplay.status === 'pending' ? ( {reactionDisplay.status === 'vote_up' ? (
<Skeleton
className={cn('shrink-0 rounded-sm', size === 'small' ? 'size-7' : 'size-8')}
aria-hidden
/>
) : reactionDisplay.status === 'vote_up' ? (
<span <span
className={cn( className={cn(
'inline-flex shrink-0 select-none leading-none', 'inline-flex shrink-0 select-none leading-none',

7
src/components/ReplyNote/index.tsx

@ -179,12 +179,7 @@ export default function ReplyNote({
: 'text-muted-foreground text-sm' : 'text-muted-foreground text-sm'
)} )}
> >
{reactionDisplay.status === 'pending' ? ( {reactionDisplay.status === 'vote_up' ? (
<Skeleton
className="h-10 w-10 shrink-0 rounded-lg sm:h-11 sm:w-11"
aria-hidden
/>
) : reactionDisplay.status === 'vote_up' ? (
<span className="text-sm leading-none opacity-90" aria-hidden> <span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY} {DISCUSSION_UPVOTE_DISPLAY}
</span> </span>

5
src/constants.ts

@ -469,7 +469,10 @@ export const SEARCHABLE_RELAY_URLS = [
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es' 'wss://purplepag.es',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://nos.lol'
] ]
export const FOLLOWS_HISTORY_RELAY_URLS = [ export const FOLLOWS_HISTORY_RELAY_URLS = [

10
src/features/feed/relay-policy.test.ts

@ -5,7 +5,12 @@ describe('applyFeedRelayPolicy', () => {
it('prepends aggr.nostr.land for read feeds before caps', () => { it('prepends aggr.nostr.land for read feeds before caps', () => {
const result = applyFeedRelayPolicy( const result = applyFeedRelayPolicy(
[{ source: 'viewer-read', urls: ['wss://reader-a.example/', 'wss://reader-b.example/'] }], [{ source: 'viewer-read', urls: ['wss://reader-a.example/', 'wss://reader-b.example/'] }],
{ operation: 'read', maxRelays: 2, applySocialKindBlockedFilter: false } {
operation: 'read',
maxRelays: 2,
applySocialKindBlockedFilter: false,
nostrLandAggrEligible: true
}
) )
expect(result.urls).toEqual(['wss://aggr.nostr.land/', 'wss://reader-a.example/']) expect(result.urls).toEqual(['wss://aggr.nostr.land/', 'wss://reader-a.example/'])
@ -33,7 +38,8 @@ describe('applyFeedRelayPolicy', () => {
{ {
operation: 'read', operation: 'read',
blockedRelays: ['wss://aggr.nostr.land/'], blockedRelays: ['wss://aggr.nostr.land/'],
applySocialKindBlockedFilter: false applySocialKindBlockedFilter: false,
nostrLandAggrEligible: true
} }
) )

20
src/features/feed/relay-policy.ts

@ -4,6 +4,7 @@ import {
relayFilterIncludesSocialKindBlockedKind relayFilterIncludesSocialKindBlockedKind
} from '@/constants' } from '@/constants'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { getViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import { import {
relayFiltersUseCapitalLetterTagKeys, relayFiltersUseCapitalLetterTagKeys,
relayUrlsStripExtendedTagReqBlocked relayUrlsStripExtendedTagReqBlocked
@ -66,10 +67,16 @@ export type FeedRelayPolicyContext = {
eventKind?: number eventKind?: number
maxRelays?: number maxRelays?: number
/** /**
* Default: read surfaces include aggr.nostr.land; favorites and write * Default: for `operation === 'read'`, prepend {@link AGGR_NOSTR_LAND_WSS} only when the viewer has a
* surfaces do not. Set explicitly for specialized fetches. * `nostr.land` host in their relay stack (see {@link getViewerRelayStackNostrLandAggrEligible}) or this
* flag is set true. `favorites-feed` never prepends. Use `nostrLandAggr: 'always'|'never'` to override.
*/ */
nostrLandAggr?: 'default' | 'always' | 'never' nostrLandAggr?: 'default' | 'always' | 'never'
/**
* Per-call override for read-surface aggr eligibility. When omitted, uses the global synced flag from
* {@link syncViewerRelayStackNostrLandAggrEligible}.
*/
nostrLandAggrEligible?: boolean
applySocialKindBlockedFilter?: boolean applySocialKindBlockedFilter?: boolean
applyExtendedTagBlockedFilter?: boolean applyExtendedTagBlockedFilter?: boolean
preserveSingleExplicitRelay?: boolean preserveSingleExplicitRelay?: boolean
@ -105,10 +112,17 @@ function shouldApplyExtendedTagFilter(ctx: FeedRelayPolicyContext): boolean {
return (ctx.filters ?? []).some((filter) => relayFiltersUseCapitalLetterTagKeys([filter])) return (ctx.filters ?? []).some((filter) => relayFiltersUseCapitalLetterTagKeys([filter]))
} }
function nostrLandAggrEligibleEffective(ctx: FeedRelayPolicyContext): boolean {
if (ctx.nostrLandAggrEligible !== undefined) return ctx.nostrLandAggrEligible
return getViewerRelayStackNostrLandAggrEligible()
}
function shouldEnsureAggr(ctx: FeedRelayPolicyContext): boolean { function shouldEnsureAggr(ctx: FeedRelayPolicyContext): boolean {
if (ctx.nostrLandAggr === 'always') return true if (ctx.nostrLandAggr === 'always') return true
if (ctx.nostrLandAggr === 'never') return false if (ctx.nostrLandAggr === 'never') return false
return ctx.operation === 'read' if (ctx.operation === 'favorites-feed') return false
if (ctx.operation === 'read') return nostrLandAggrEligibleEffective(ctx)
return false
} }
function isReadOnlyRelay(norm: string): boolean { function isReadOnlyRelay(norm: string): boolean {

15
src/hooks/useFetchProfile.tsx

@ -211,10 +211,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
try { try {
globalFetchingPubkeys.add(pubkey) globalFetchingPubkeys.add(pubkey)
const quick = tryHydrateProfileFromSessionOnly(pubkey, skipCache) /** Session-only fast path removed: {@link replaceableEventService.fetchProfileEvent} still refreshes from relays while session primes the loader. */
if (quick) {
return quick
}
/** Disk read runs in parallel with `fetchProfileEvent` — never block network on IDB. */ /** Disk read runs in parallel with `fetchProfileEvent` — never block network on IDB. */
idbEarlyP = profileFromIdbPromise(pubkey, skipCache) idbEarlyP = profileFromIdbPromise(pubkey, skipCache)
@ -516,8 +513,16 @@ export function useFetchProfile(id?: string, skipCache = false) {
const run = async () => { const run = async () => {
try { try {
setIsFetching(true)
setError(null) setError(null)
const earlyProfile =
tryHydrateProfileFromSessionOnly(extractedPubkey, skipCache) ??
(await profileFromIdbPromise(extractedPubkey, skipCache))
if (!cancelled.current && earlyProfile) {
setProfile(earlyProfile)
setIsFetching(false)
} else if (!cancelled.current) {
setIsFetching(true)
}
const profile = await checkProfile(extractedPubkey, cancelled) const profile = await checkProfile(extractedPubkey, cancelled)

59
src/hooks/useNotificationReactionDisplay.ts

@ -7,19 +7,52 @@ import { getRootEventHexId } from '@/lib/event'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { getFirstHexEventIdFromETags } from '@/lib/tag' import { getFirstHexEventIdFromETags } from '@/lib/tag'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import type { NEvent } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
export type NotificationReactionDisplay = export type NotificationReactionDisplay =
| { status: 'pending' }
| { status: 'vote_up' } | { status: 'vote_up' }
| { status: 'vote_down' } | { status: 'vote_down' }
| { status: 'discussion_custom' } | { status: 'discussion_custom' }
| { status: 'default' } | { status: 'default' }
function classifyDiscussionReactionFromTargets(
reaction: Event,
target: NEvent,
root: NEvent | undefined
): NotificationReactionDisplay {
let inDiscussion = target.kind === ExtendedKind.DISCUSSION
if (!inDiscussion && target.kind === ExtendedKind.COMMENT) {
inDiscussion = root?.kind === ExtendedKind.DISCUSSION
}
if (!inDiscussion) return { status: 'default' }
const raw = reaction.content?.trim() ?? ''
if (isDiscussionUpvoteEmoji(raw)) return { status: 'vote_up' }
if (isDiscussionDownvoteEmoji(raw)) return { status: 'vote_down' }
return { status: 'discussion_custom' }
}
function peekReactionDisplayFromSessionCaches(event: Event): NotificationReactionDisplay {
if (event.kind !== kinds.Reaction) return { status: 'default' }
const targetId = getFirstHexEventIdFromETags(event.tags)
if (!targetId) return { status: 'default' }
const target = eventService.peekHexIdNoteFromSessionCache(targetId)
if (!target) return { status: 'default' }
let root: NEvent | undefined
if (target.kind === ExtendedKind.COMMENT) {
const rootId = getRootEventHexId(target)
if (rootId) root = eventService.peekHexIdNoteFromSessionCache(rootId)
}
return classifyDiscussionReactionFromTargets(event, target, root)
}
/** /**
* For kind 7: resolves whether the reacted-to note is a discussion (kind 11 or 1111 under 11) * For kind 7: resolves whether the reacted-to note is a discussion (kind 11 or 1111 under 11)
* and classifies +/- / as vote display vs other reactions. * and classifies +/- / as vote display vs other reactions.
*
* Always starts from session cache (sync) so the glyph is never a blank skeleton; async fetch refines
* when the target was not yet in memory.
*/ */
export function useNotificationReactionDisplay(event: Event): NotificationReactionDisplay { export function useNotificationReactionDisplay(event: Event): NotificationReactionDisplay {
const targetId = useMemo(() => { const targetId = useMemo(() => {
@ -29,9 +62,11 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
const reactionRelayHints = useMemo(() => relayHintsFromEventTags(event), [event]) const reactionRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const [state, setState] = useState<NotificationReactionDisplay>(() => const [state, setState] = useState<NotificationReactionDisplay>({ status: 'default' })
event.kind === kinds.Reaction ? { status: 'pending' } : { status: 'default' }
) useLayoutEffect(() => {
setState(peekReactionDisplayFromSessionCaches(event))
}, [event.id, event.kind, event.content, event.tags])
useEffect(() => { useEffect(() => {
if (event.kind === ExtendedKind.EXTERNAL_REACTION) { if (event.kind === ExtendedKind.EXTERNAL_REACTION) {
@ -48,8 +83,6 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
} }
let cancelled = false let cancelled = false
setState({ status: 'pending' })
const fetchOpts = reactionRelayHints.length ? { relayHints: reactionRelayHints } : undefined const fetchOpts = reactionRelayHints.length ? { relayHints: reactionRelayHints } : undefined
;(async () => { ;(async () => {
@ -60,13 +93,14 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
return return
} }
let root: NEvent | undefined
let inDiscussion = target.kind === ExtendedKind.DISCUSSION let inDiscussion = target.kind === ExtendedKind.DISCUSSION
if (!inDiscussion && target.kind === ExtendedKind.COMMENT) { if (!inDiscussion && target.kind === ExtendedKind.COMMENT) {
const rootId = getRootEventHexId(target) const rootId = getRootEventHexId(target)
if (rootId) { if (rootId) {
const rootHints = relayHintsFromEventTags(target) const rootHints = relayHintsFromEventTags(target)
const rootOpts = rootHints.length ? { relayHints: rootHints } : fetchOpts const rootOpts = rootHints.length ? { relayHints: rootHints } : fetchOpts
const root = await eventService.fetchEvent(rootId, rootOpts) root = await eventService.fetchEvent(rootId, rootOpts)
if (cancelled) return if (cancelled) return
inDiscussion = root?.kind === ExtendedKind.DISCUSSION inDiscussion = root?.kind === ExtendedKind.DISCUSSION
} }
@ -77,14 +111,7 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
return return
} }
const raw = event.content?.trim() ?? '' setState(classifyDiscussionReactionFromTargets(event, target, root))
if (isDiscussionUpvoteEmoji(raw)) {
setState({ status: 'vote_up' })
} else if (isDiscussionDownvoteEmoji(raw)) {
setState({ status: 'vote_down' })
} else {
setState({ status: 'discussion_custom' })
}
})() })()
return () => { return () => {

45
src/lib/nostr-land-relay-eligibility.ts

@ -0,0 +1,45 @@
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { normalizeAnyRelayUrl } from '@/lib/url'
/**
* True when any URLs host is `nostr.land` (e.g. `wss://nostr.land`, `wss://aggr.nostr.land`).
* Used to decide whether read fetches should prepend {@link AGGR_NOSTR_LAND_WSS} (except the primary home OP feed).
*/
export function relayUrlsMentionNostrLandDomain(urls: readonly string[]): boolean {
return urls.some((url) => {
const normalized = normalizeAnyRelayUrl(url) || String(url).trim()
if (!normalized) return false
try {
const parsed = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://'))
return parsed.hostname.toLowerCase() === 'nostr.land'
} catch {
return false
}
})
}
let viewerStackMentionsNostrLand = false
/**
* Synced from the logged-in viewers relay stack (favorites, relay sets, NIP-65, cache, HTTP lists).
* Service-layer reads use {@link getViewerRelayStackNostrLandAggrEligible} when building REQ targets.
*/
export function syncViewerRelayStackNostrLandAggrEligible(urls: readonly string[]): boolean {
viewerStackMentionsNostrLand = relayUrlsMentionNostrLandDomain(urls)
return viewerStackMentionsNostrLand
}
export function getViewerRelayStackNostrLandAggrEligible(): boolean {
return viewerStackMentionsNostrLand
}
/** Deduped prepend of aggr when the viewer opted into nostr.land relays (see sync…). */
export function prependAggrNostrLandIfViewerEligible(relayUrls: readonly string[]): string[] {
if (!viewerStackMentionsNostrLand) return [...relayUrls]
const aggrNorm = (normalizeAnyRelayUrl(AGGR_NOSTR_LAND_WSS) || AGGR_NOSTR_LAND_WSS).toLowerCase()
const norm = (u: string) => (normalizeAnyRelayUrl(u) || u.trim()).toLowerCase()
if (relayUrls.some((u) => norm(u) === aggrNorm)) {
return [...relayUrls]
}
return [AGGR_NOSTR_LAND_WSS, ...relayUrls]
}

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

@ -6,6 +6,7 @@ import {
} from '@/lib/relay-url-priority' } from '@/lib/relay-url-priority'
import { buildProfilePageReadRelayUrls, getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls, getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
describe('filterContextAuthorReadRelaysForPublish', () => { describe('filterContextAuthorReadRelaysForPublish', () => {
it('drops loopback, LAN, and .onion; keeps public relays', () => { it('drops loopback, LAN, and .onion; keeps public relays', () => {
@ -46,7 +47,8 @@ describe('stripMailboxLocalUrlsForRemoteViewers', () => {
}) })
describe('nostr.land aggregator feed relay policy', () => { describe('nostr.land aggregator feed relay policy', () => {
it('keeps aggr.nostr.land in capped read feed relay stacks', () => { it('keeps aggr.nostr.land in capped read feed relay stacks when viewer uses nostr.land relays', () => {
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
const out = buildPrioritizedReadRelayUrls({ const out = buildPrioritizedReadRelayUrls({
userReadRelays: [ userReadRelays: [
'wss://reader-a.example/', 'wss://reader-a.example/',
@ -60,6 +62,7 @@ describe('nostr.land aggregator feed relay policy', () => {
expect(out).toHaveLength(3) expect(out).toHaveLength(3)
expect(out[0]).toBe('wss://aggr.nostr.land/') expect(out[0]).toBe('wss://aggr.nostr.land/')
syncViewerRelayStackNostrLandAggrEligible([])
}) })
it('excludes aggr.nostr.land from the favorites feed relay list', () => { it('excludes aggr.nostr.land from the favorites feed relay list', () => {
@ -74,6 +77,7 @@ describe('nostr.land aggregator feed relay policy', () => {
describe('buildProfilePageReadRelayUrls', () => { describe('buildProfilePageReadRelayUrls', () => {
it('includes viewed author write relays for remote profile timelines', () => { it('includes viewed author write relays for remote profile timelines', () => {
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
const out = buildProfilePageReadRelayUrls( const out = buildProfilePageReadRelayUrls(
[], [],
[], [],
@ -85,9 +89,11 @@ describe('buildProfilePageReadRelayUrls', () => {
) )
expect(out).toContain('wss://author-outbox.example/') expect(out).toContain('wss://author-outbox.example/')
syncViewerRelayStackNostrLandAggrEligible([])
}) })
it('prioritizes viewed author write relays ahead of long read lists', () => { it('prioritizes viewed author write relays ahead of long read lists', () => {
syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/'])
const out = buildProfilePageReadRelayUrls( const out = buildProfilePageReadRelayUrls(
[], [],
[], [],
@ -100,5 +106,6 @@ describe('buildProfilePageReadRelayUrls', () => {
expect(out[0]).toBe('wss://aggr.nostr.land/') expect(out[0]).toBe('wss://aggr.nostr.land/')
expect(out[1]).toBe('wss://author-outbox.example/') expect(out[1]).toBe('wss://author-outbox.example/')
syncViewerRelayStackNostrLandAggrEligible([])
}) })
}) })

55
src/providers/FeedProvider.tsx

@ -3,7 +3,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata' import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { buildAllFavoritesFeedRelayUrls } from '@/lib/home-feed-relays' import { buildAllFavoritesFeedRelayUrls } from '@/lib/home-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { syncViewerRelayStackNostrLandAggrEligible } from '@/lib/nostr-land-relay-eligibility'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
@ -22,40 +22,27 @@ function relayUrlListIdentity(urls: string[]): string {
.join('\n') .join('\n')
} }
function relayListMentionsNostrLand(urls: readonly string[]): boolean {
return urls.some((url) => {
const normalized = normalizeAnyRelayUrl(url) || url.trim()
if (!normalized) return false
try {
const parsed = new URL(normalized.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://'))
return parsed.hostname.toLowerCase() === 'nostr.land'
} catch {
return false
}
})
}
function buildHomeReplyFeedRelayUrls( function buildHomeReplyFeedRelayUrls(
primaryRelayUrls: string[], primaryRelayUrls: string[],
inboxRelayUrls: string[], inboxRelayUrls: string[],
cacheRelayUrls: string[], cacheRelayUrls: string[],
httpRelayUrls: string[], httpRelayUrls: string[],
includeNostrLandAggr: boolean,
blockedRelays: string[] blockedRelays: string[]
): string[] { ): string[] {
return feedRelayPolicyUrls([ return feedRelayPolicyUrls(
[
{ source: 'favorites', urls: primaryRelayUrls }, { source: 'favorites', urls: primaryRelayUrls },
{ source: 'viewer-read', urls: inboxRelayUrls }, { source: 'viewer-read', urls: inboxRelayUrls },
{ source: 'cache', urls: cacheRelayUrls }, { source: 'cache', urls: cacheRelayUrls },
{ source: 'http-index', urls: httpRelayUrls }, { source: 'http-index', urls: httpRelayUrls }
...(includeNostrLandAggr ? [{ source: 'read-only', urls: [AGGR_NOSTR_LAND_WSS] }] : []) ],
], { {
operation: 'read', operation: 'read',
blockedRelays, blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: true
}) }
)
} }
export function FeedProvider({ children }: { children: ReactNode }) { export function FeedProvider({ children }: { children: ReactNode }) {
@ -105,7 +92,6 @@ export function FeedProvider({ children }: { children: ReactNode }) {
[], [],
[], [],
[], [],
false,
[] []
) )
) )
@ -120,21 +106,25 @@ export function FeedProvider({ children }: { children: ReactNode }) {
[] []
) )
const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' }) const viewerNostrLandAggrEligible = useMemo(() => {
const updateFeedRelayUrls = useCallback(() => { const urls = [
const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls)
const aggrEligibleRelayUrls = [
...favoriteFeedRelayUrls, ...favoriteFeedRelayUrls,
...replyExtraRelayLayers.inboxRelayUrls, ...replyExtraRelayLayers.inboxRelayUrls,
...replyExtraRelayLayers.outboxRelayUrls, ...replyExtraRelayLayers.outboxRelayUrls,
...replyExtraRelayLayers.cacheRelayUrls ...replyExtraRelayLayers.cacheRelayUrls,
...replyExtraRelayLayers.httpRelayUrls
] ]
return syncViewerRelayStackNostrLandAggrEligible(urls)
}, [favoriteFeedRelayUrls, replyExtraRelayLayers])
const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' })
const updateFeedRelayUrls = useCallback(() => {
const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls)
const replyRelays = buildHomeReplyFeedRelayUrls( const replyRelays = buildHomeReplyFeedRelayUrls(
primaryRelays, primaryRelays,
replyExtraRelayLayers.inboxRelayUrls, replyExtraRelayLayers.inboxRelayUrls,
replyExtraRelayLayers.cacheRelayUrls, replyExtraRelayLayers.cacheRelayUrls,
replyExtraRelayLayers.httpRelayUrls, replyExtraRelayLayers.httpRelayUrls,
relayListMentionsNostrLand(aggrEligibleRelayUrls),
blockedRelays blockedRelays
) )
const primaryId = relayUrlListIdentity(primaryRelays) const primaryId = relayUrlListIdentity(primaryRelays)
@ -149,7 +139,14 @@ export function FeedProvider({ children }: { children: ReactNode }) {
} }
setUrlStateIfChanged(setRelayUrls, primaryRelays) setUrlStateIfChanged(setRelayUrls, primaryRelays)
setUrlStateIfChanged(setReplyRelayUrls, replyRelays) setUrlStateIfChanged(setReplyRelayUrls, replyRelays)
}, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged]) }, [
favoriteFeedRelayUrls,
blockedRelays,
primaryExtraRelayUrls,
replyExtraRelayLayers,
setUrlStateIfChanged,
viewerNostrLandAggrEligible
])
const favoriteRelaysIdentity = useMemo( const favoriteRelaysIdentity = useMemo(
() => () =>

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

@ -648,6 +648,16 @@ export class EventService {
return e return e
} }
/**
* Session LRU only for UI that must classify before async fetch (e.g. notification reactions).
* Does not query IndexedDB or relays; {@link fetchEvent} remains authoritative when missing.
*/
peekHexIdNoteFromSessionCache(hexId: string): NEvent | undefined {
const id = hexId.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) return undefined
return this.getSessionEventIfAllowed(id, true)
}
/** /**
* Pubkeys whose session-cached kind 0 matches a name / display_name / nip-05 substring (for search without IDB). * Pubkeys whose session-cached kind 0 matches a name / display_name / nip-05 substring (for search without IDB).
*/ */

23
src/services/client-replaceable-events.service.ts

@ -23,6 +23,7 @@ import type { QueryService } from './client-query.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from './client.service' import client from './client.service'
import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList, buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder'
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
export class ReplaceableEventService { export class ReplaceableEventService {
@ -613,6 +614,7 @@ export class ReplaceableEventService {
} else { } else {
relayUrls = [...FAST_READ_RELAY_URLS] relayUrls = [...FAST_READ_RELAY_URLS]
} }
relayUrls = prependAggrNostrLandIfViewerEligible(relayUrls)
// Contacts + NIP-65 need the same patience as pins/payment: 100ms EOSE loses the race on slow relays // Contacts + NIP-65 need the same patience as pins/payment: 100ms EOSE loses the race on slow relays
// and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author). // and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author).
const isSlowReplaceableBatch = const isSlowReplaceableBatch =
@ -868,6 +870,8 @@ export class ReplaceableEventService {
throw new Error('Invalid id') throw new Error('Invalid id')
} }
/** Used only when relay steps miss — UI should already show this from {@link useFetchProfile} IDB/session first. */
let sessionFallback: NEvent | undefined
if (!_skipCache) { if (!_skipCache) {
const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey) const sessionEv = client.eventService.getSessionMetadataForPubkey(pubkey)
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) { if (sessionEv && !shouldDropEventOnIngest(sessionEv)) {
@ -878,7 +882,7 @@ export class ReplaceableEventService {
await this.indexProfile(sessionEv) await this.indexProfile(sessionEv)
void indexedDb.putReplaceableEvent(sessionEv).catch(() => {}) void indexedDb.putReplaceableEvent(sessionEv).catch(() => {})
ReplaceableEventService.clearProfileFetchMiss(pubkey) ReplaceableEventService.clearProfileFetchMiss(pubkey)
return sessionEv sessionFallback = sessionEv
} }
} }
@ -886,7 +890,7 @@ export class ReplaceableEventService {
const relayHints = relays.length > 0 ? [...relays] : [] const relayHints = relays.length > 0 ? [...relays] : []
if (!_skipCache && relayHints.length === 0 && ReplaceableEventService.isProfileFetchMissCached(pubkey)) { if (!_skipCache && relayHints.length === 0 && ReplaceableEventService.isProfileFetchMissCached(pubkey)) {
return undefined return sessionFallback
} }
// CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available // CRITICAL: Always use relay hints from bech32 addresses (nprofile, naddr, nevent) when available
@ -939,14 +943,16 @@ export class ReplaceableEventService {
] ]
: [] : []
const expandedRelays = [ const expandedRelays = prependAggrNostrLandIfViewerEligible(
...new Set([ Array.from(
new Set([
...relayHints, ...relayHints,
...authorRelays, ...authorRelays,
...PROFILE_FETCH_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS,
...FAST_READ_RELAY_URLS ...FAST_READ_RELAY_URLS
]) ])
] )
)
const profileFromExpanded = await this.fetchReplaceableEvent( const profileFromExpanded = await this.fetchReplaceableEvent(
pubkey, pubkey,
@ -976,8 +982,9 @@ export class ReplaceableEventService {
}) })
if (comprehensiveRelays.length > 0) { if (comprehensiveRelays.length > 0) {
const relaysForQuery = prependAggrNostrLandIfViewerEligible(comprehensiveRelays)
const events = await this.queryService.query( const events = await this.queryService.query(
comprehensiveRelays, relaysForQuery,
{ {
authors: [pubkey], authors: [pubkey],
kinds: [kinds.Metadata] kinds: [kinds.Metadata]
@ -1007,10 +1014,10 @@ export class ReplaceableEventService {
ReplaceableEventService.releaseProfileFallbackNetworkSlot() ReplaceableEventService.releaseProfileFallbackNetworkSlot()
} }
if (!_skipCache && relayHints.length === 0) { if (!_skipCache && relayHints.length === 0 && !sessionFallback) {
ReplaceableEventService.rememberProfileFetchMiss(pubkey) ReplaceableEventService.rememberProfileFetchMiss(pubkey)
} }
return undefined return sessionFallback
} }
/** /**

Loading…
Cancel
Save