Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
1cca346519
  1. 105
      src/components/LatestFromFollowsSection/index.tsx
  2. 10
      src/constants.ts
  3. 33
      src/hooks/useFetchCalendarRsvps.tsx
  4. 29
      src/hooks/useProfileTimeline.tsx
  5. 8
      src/lib/draft-event.ts
  6. 20
      src/lib/favorites-feed-relays.ts
  7. 8
      src/providers/NostrProvider/index.tsx
  8. 8
      src/providers/UserTrustProvider.tsx
  9. 17
      src/services/client.service.ts
  10. 51
      src/services/indexed-db.service.ts

105
src/components/LatestFromFollowsSection/index.tsx

@ -60,17 +60,21 @@ const FEED_KINDS = [
const feedKindSet = new Set(FEED_KINDS) const feedKindSet = new Set(FEED_KINDS)
const LOG = '[LatestFromFollows]'
function mergeBatchPosts( function mergeBatchPosts(
prev: Map<string, NostrEvent[]>, prev: Map<string, NostrEvent[]>,
incoming: NostrEvent[], incoming: NostrEvent[],
batchAuthors: string[] batchAuthors: string[]
): Map<string, NostrEvent[]> { ): Map<string, NostrEvent[]> {
const next = new Map(prev) const next = new Map(prev)
const authorSet = new Set(batchAuthors) /** Follow list pubkeys are lowercased in `getPubkeysFromPTags`; relay `pubkey` may be mixed-case hex. */
const filtered = incoming.filter((e) => authorSet.has(e.pubkey)) const authorSet = new Set(batchAuthors.map((a) => a.toLowerCase()))
const filtered = incoming.filter((e) => authorSet.has(e.pubkey.toLowerCase()))
for (const pk of batchAuthors) { for (const pk of batchAuthors) {
const pkNorm = pk.toLowerCase()
const prevList = next.get(pk) ?? [] const prevList = next.get(pk) ?? []
const newForPk = filtered.filter((e) => e.pubkey === pk) const newForPk = filtered.filter((e) => e.pubkey.toLowerCase() === pkNorm)
const byId = new Map<string, NostrEvent>() const byId = new Map<string, NostrEvent>()
for (const e of prevList) byId.set(e.id, e) for (const e of prevList) byId.set(e.id, e)
for (const e of newForPk) { for (const e of newForPk) {
@ -188,11 +192,13 @@ export default function LatestFromFollowsSection({
setGuestFollowPubkeys([]) setGuestFollowPubkeys([])
;(async () => { ;(async () => {
logger.info(`${LOG} guest: loading recommended follow list`)
const hex = recommendedCuratorHexPubkey() const hex = recommendedCuratorHexPubkey()
if (!hex) { if (!hex) {
if (!cancelled) { if (!cancelled) {
setGuestFollowPubkeys([]) setGuestFollowPubkeys([])
setGuestListReady(true) setGuestListReady(true)
logger.info(`${LOG} guest: no curator npub; follow list empty`)
} }
return return
} }
@ -201,6 +207,7 @@ export default function LatestFromFollowsSection({
if (cancelled) return if (cancelled) return
const list = evt ? getPubkeysFromPTags(evt.tags).slice(0, MAX_FOLLOWS) : [] const list = evt ? getPubkeysFromPTags(evt.tags).slice(0, MAX_FOLLOWS) : []
setGuestFollowPubkeys(list) setGuestFollowPubkeys(list)
logger.info(`${LOG} guest: follow list loaded`, { count: list.length })
} catch (err) { } catch (err) {
logger.warn('[LatestFromFollows] Failed to load recommended follow list', err) logger.warn('[LatestFromFollows] Failed to load recommended follow list', err)
if (!cancelled) setGuestFollowPubkeys([]) if (!cancelled) setGuestFollowPubkeys([])
@ -217,9 +224,16 @@ export default function LatestFromFollowsSection({
// Load each follow's NIP-65 list (IndexedDB + network), then aggregate first outboxes + READ_ONLY relays. // Load each follow's NIP-65 list (IndexedDB + network), then aggregate first outboxes + READ_ONLY relays.
useEffect(() => { useEffect(() => {
if (!isInitialized || loadingFollowList) { if (!isInitialized || loadingFollowList) {
logger.info(`${LOG} relays: waiting`, {
isInitialized,
loadingFollowList,
variant,
followsLabel
})
return return
} }
if (followPubkeys.length === 0) { if (followPubkeys.length === 0) {
logger.info(`${LOG} relays: no follows; skipping aggregate`)
setAggregateRelayUrls([]) setAggregateRelayUrls([])
setAggregateRelaysReady(true) setAggregateRelaysReady(true)
return return
@ -230,6 +244,11 @@ export default function LatestFromFollowsSection({
setAggregateRelayUrls([]) setAggregateRelayUrls([])
;(async () => { ;(async () => {
logger.info(`${LOG} relays: fetch NIP-65 lists start`, {
authorCount: followPubkeys.length,
variant,
followsLabel
})
try { try {
// Dynamic import avoids a static cycle: client.service → replaceable-events → client.service // Dynamic import avoids a static cycle: client.service → replaceable-events → client.service
// (would break React context / HMR when this module loads early). // (would break React context / HMR when this module loads early).
@ -248,28 +267,53 @@ export default function LatestFromFollowsSection({
favoriteRelays favoriteRelays
) )
setAggregateRelayUrls(urls) setAggregateRelayUrls(urls)
logger.info(`${LOG} relays: aggregate URLs computed → setState`, {
nip65ListsLoaded: allLists.length,
aggregateUrlCount: urls.length,
relaySample: urls.slice(0, 6)
})
} catch (err) { } catch (err) {
logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err)
if (!cancelled) { if (!cancelled) {
setAggregateRelayUrls( const fallback = buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays)
buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays) setAggregateRelayUrls(fallback)
) logger.info(`${LOG} relays: using fallback aggregate URLs after error`, {
aggregateUrlCount: fallback.length
})
} }
} finally { } finally {
if (!cancelled) setAggregateRelaysReady(true) if (!cancelled) {
setAggregateRelaysReady(true)
logger.info(`${LOG} relays: aggregateRelaysReady → true`)
}
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [followPubkeys, favoriteRelays, blockedRelays, isInitialized, loadingFollowList]) }, [followPubkeys, favoriteRelays, blockedRelays, isInitialized, loadingFollowList, variant, followsLabel])
// Batch-fetch posts per slice of authors against the aggregate relay set. // Batch-fetch posts per slice of authors against the aggregate relay set.
useEffect(() => { useEffect(() => {
if (!isInitialized || loadingFollowList) return if (!isInitialized || loadingFollowList) {
if (followPubkeys.length === 0) return logger.info(`${LOG} posts: waiting`, {
if (!aggregateRelaysReady) return isInitialized,
loadingFollowList,
aggregateRelaysReady,
followCount: followPubkeys.length,
variant
})
return
}
if (followPubkeys.length === 0) {
logger.info(`${LOG} posts: no follows; skipping batch fetch`)
return
}
if (!aggregateRelaysReady) {
logger.info(`${LOG} posts: waiting for aggregate relays`)
return
}
abortedRef.current = false abortedRef.current = false
let cancelled = false let cancelled = false
@ -280,6 +324,24 @@ export default function LatestFromFollowsSection({
let working = seed ? postsRecordToMap(seed.posts) : new Map<string, NostrEvent[]>() let working = seed ? postsRecordToMap(seed.posts) : new Map<string, NostrEvent[]>()
setPostsByPubkey(new Map(working)) setPostsByPubkey(new Map(working))
const summarizePosts = (m: Map<string, NostrEvent[]>) => {
let authorsWithPosts = 0
let totalNotes = 0
for (const arr of m.values()) {
if (arr.length > 0) authorsWithPosts++
totalNotes += arr.length
}
return { authorsWithPosts, totalNotes, mapKeyCount: m.size }
}
logger.info(`${LOG} posts: batch run start`, {
followCount: followPubkeys.length,
relayUrlCount: aggregateRelayUrls.length,
hideUntrustedNotes,
usedCacheSeed: Boolean(seed),
...summarizePosts(working)
})
const persist = () => { const persist = () => {
writeSearchFollowsFeedCache({ writeSearchFollowsFeedCache({
v: 1, v: 1,
@ -289,10 +351,18 @@ export default function LatestFromFollowsSection({
}) })
} }
const batchCount = Math.ceil(followPubkeys.length / AUTHORS_PER_BATCH)
for (let i = 0; i < followPubkeys.length; i += AUTHORS_PER_BATCH) { for (let i = 0; i < followPubkeys.length; i += AUTHORS_PER_BATCH) {
if (cancelled || abortedRef.current) break if (cancelled || abortedRef.current) break
const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH)
const batchIndex = Math.floor(i / AUTHORS_PER_BATCH) + 1
try { try {
logger.info(`${LOG} posts: REQ batch ${batchIndex}/${batchCount}`, {
authorBatchSize: batch.length,
kinds: FEED_KINDS.length,
limit: BATCH_EVENT_LIMIT
})
const t0 = performance.now()
const raw = await queryService.fetchEvents( const raw = await queryService.fetchEvents(
aggregateRelayUrls, aggregateRelayUrls,
{ {
@ -302,11 +372,19 @@ export default function LatestFromFollowsSection({
}, },
{ eoseTimeout: 2800, globalTimeout: 9000 } { eoseTimeout: 2800, globalTimeout: 9000 }
) )
const ms = Math.round(performance.now() - t0)
if (cancelled || abortedRef.current) break if (cancelled || abortedRef.current) break
const filtered = raw.filter((e) => acceptEvent(e)) const filtered = raw.filter((e) => acceptEvent(e))
working = mergeBatchPosts(working, filtered, batch) working = mergeBatchPosts(working, filtered, batch)
setPostsByPubkey(new Map(working)) setPostsByPubkey(new Map(working))
persist() persist()
logger.info(`${LOG} posts: batch ${batchIndex}/${batchCount} done + UI setPostsByPubkey`, {
ms,
rawFromRelays: raw.length,
afterAcceptFilter: filtered.length,
droppedByAccept: raw.length - filtered.length,
...summarizePosts(working)
})
} catch (err) { } catch (err) {
logger.warn('[LatestFromFollows] Batch fetch failed', { err, batchSize: batch.length }) logger.warn('[LatestFromFollows] Batch fetch failed', { err, batchSize: batch.length })
} }
@ -314,6 +392,10 @@ export default function LatestFromFollowsSection({
if (!cancelled) { if (!cancelled) {
persist() persist()
setBatchBusy(false) setBatchBusy(false)
logger.info(`${LOG} posts: batch run finished`, {
cancelled: false,
...summarizePosts(working)
})
} }
} }
@ -322,6 +404,7 @@ export default function LatestFromFollowsSection({
cancelled = true cancelled = true
abortedRef.current = true abortedRef.current = true
setBatchBusy(false) setBatchBusy(false)
logger.info(`${LOG} posts: batch effect cleanup (cancelled / deps changed)`)
} }
}, [ }, [
followPubkeys, followPubkeys,

10
src/constants.ts

@ -113,6 +113,16 @@ export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 3
/** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */ /** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */
export const MAX_PUBLISH_RELAYS = 20 export const MAX_PUBLISH_RELAYS = 20
/**
* Kind 24 / 31925: {@link mergeRelayPriorityLayers} used the full {@link MAX_PUBLISH_RELAYS} budget on the authors
* outbox list first, so recipient **read** inboxes were often never reached. This higher cap plus an author slice
* (see client.service) reserves space for organizer/recipient relays.
*/
export const PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS = 28
/** When publishing kind 24 / 31925 to recipients, only the first N author outbox URLs fill tier‑1 before inboxes. */
export const PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP = 10
/** After a publish wave, failed NIP-65 write (outbox) relays are retried once after this delay. */ /** After a publish wave, failed NIP-65 write (outbox) relays are retried once after this delay. */
export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000

33
src/hooks/useFetchCalendarRsvps.tsx

@ -15,6 +15,20 @@ import { FAST_READ_RELAY_URLS } from '@/constants'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
/** NIP-65 inboxes only — calendar RSVPs are published to the author’s outboxes, so REQ must include those too. */
function userWriteRelaysForQuery(
relayList: { write?: string[]; httpWrite?: string[] } | null | undefined
): string[] {
if (!relayList) return []
const ws = (relayList.write ?? [])
.map((url) => normalizeAnyRelayUrl(url) || url)
.filter(Boolean) as string[]
const http = (relayList.httpWrite ?? [])
.map((url) => normalizeAnyRelayUrl(url) || url)
.filter(Boolean) as string[]
return [...http, ...ws]
}
function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined { function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined {
const status = rsvp.tags.find(tagNameEquals('status'))?.[1] const status = rsvp.tags.find(tagNameEquals('status'))?.[1]
if (status === 'accepted' || status === 'tentative' || status === 'declined') return status if (status === 'accepted' || status === 'tentative' || status === 'declined') return status
@ -23,9 +37,10 @@ function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | und
function mergeRsvp(prev: Event[], evt: Event): Event[] { function mergeRsvp(prev: Event[], evt: Event): Event[] {
const next = prev.filter((e) => e.id !== evt.id) const next = prev.filter((e) => e.id !== evt.id)
const samePubkey = next.find((e) => e.pubkey === evt.pubkey) const pk = evt.pubkey.toLowerCase()
const samePubkey = next.find((e) => e.pubkey.toLowerCase() === pk)
if (samePubkey && samePubkey.created_at >= evt.created_at) return next if (samePubkey && samePubkey.created_at >= evt.created_at) return next
const withoutSamePubkey = samePubkey ? next.filter((e) => e.pubkey !== evt.pubkey) : next const withoutSamePubkey = samePubkey ? next.filter((e) => e.pubkey.toLowerCase() !== pk) : next
return [...withoutSamePubkey, evt].sort((a, b) => b.created_at - a.created_at) return [...withoutSamePubkey, evt].sort((a, b) => b.created_at - a.created_at)
} }
@ -55,8 +70,10 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
getReplaceableCoordinateFromEvent(calendarEvent) getReplaceableCoordinateFromEvent(calendarEvent)
) )
const userRead = userReadRelaysWithHttp(relayList) const userRead = userReadRelaysWithHttp(relayList)
const userWrite = userWriteRelaysForQuery(relayList)
void (async () => { void (async () => {
// Read order: IndexedDB first (offline + last session), then in-memory session, then relays.
let fromIdb: Event[] = [] let fromIdb: Event[] = []
try { try {
fromIdb = await indexedDb.getCalendarRsvpEventsByParentCoordinate(coordinate) fromIdb = await indexedDb.getCalendarRsvpEventsByParentCoordinate(coordinate)
@ -67,11 +84,12 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent)
const mergedLocal = mergeRsvpList([...fromIdb, ...fromSession]) const mergedLocal = mergeRsvpList([...fromIdb, ...fromSession])
if (mergedLocal.length) setRsvps(mergedLocal) setRsvps(mergedLocal)
const baseUrls = new Set<string>([ const baseUrls = new Set<string>([
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...userRead.map((url) => normalizeAnyRelayUrl(url) || url) ...userRead.map((url) => normalizeAnyRelayUrl(url) || url),
...userWrite.map((url) => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[]) ].filter(Boolean) as string[])
const organizerPubkey = calendarEvent.pubkey const organizerPubkey = calendarEvent.pubkey
@ -120,7 +138,11 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
} }
) )
if (cancelled) return if (cancelled) return
setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...(events ?? [])])) const fromRelay = events ?? []
await Promise.allSettled(
fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined))
)
setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...fromRelay]))
} finally { } finally {
if (!cancelled) setIsFetching(false) if (!cancelled) setIsFetching(false)
} }
@ -150,6 +172,7 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const matchesA = aCoord !== '' && aCoord === coordinate const matchesA = aCoord !== '' && aCoord === coordinate
const matchesE = eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId const matchesE = eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId
if (!matchesA && !matchesE) return if (!matchesA && !matchesE) return
void indexedDb.putCalendarRsvpEventRow(evt).catch(() => undefined)
setRsvps((prev) => mergeRsvp(prev, evt)) setRsvps((prev) => mergeRsvp(prev, evt))
} }

29
src/hooks/useProfileTimeline.tsx

@ -2,12 +2,13 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { CALENDAR_EVENT_KINDS, ExtendedKind, isSocialKindBlockedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind, isDocumentRelayKind, isSocialKindBlockedKind } from '@/constants'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import indexedDb from '@/services/indexed-db.service'
type ProfileTimelineMemoryEntry = { type ProfileTimelineMemoryEntry = {
events: Event[] events: Event[]
@ -221,9 +222,30 @@ export function useProfileTimeline({
blockedRelays, blockedRelays,
emptyAuthor, emptyAuthor,
socialKinds, socialKinds,
includeAuthorLocalRelays includeAuthorLocalRelays,
kinds
) )
const idbDocKinds = kinds.filter((k) => isDocumentRelayKind(k))
if (idbDocKinds.length > 0) {
try {
const pkNorm = normalizeHexPubkey(pubkey)
const fromIdb = await indexedDb.getCachedPublicationStoreEventsForProfileAuthor(
pkNorm,
idbDocKinds,
limit
)
if (!cancelled) {
for (const e of fromIdb) {
pool.set(e.id, e)
}
if (fromIdb.length) flushPool()
}
} catch {
/* IDB optional */
}
}
const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => { const startWave = async (subRequests: ReturnType<typeof buildSubRequests>) => {
if (cancelled || subRequests.length === 0) return if (cancelled || subRequests.length === 0) return
try { try {
@ -273,7 +295,8 @@ export function useProfileTimeline({
blockedRelays, blockedRelays,
authorRl, authorRl,
socialKinds, socialKinds,
includeAuthorLocalRelays includeAuthorLocalRelays,
kinds
) )
const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls)
if (cancelled || deltaUrls.length === 0) return if (cancelled || deltaUrls.length === 0) return

8
src/lib/draft-event.ts

@ -20,6 +20,7 @@ import { Event, kinds, nip19 } from 'nostr-tools'
import { import {
getReplaceableCoordinate, getReplaceableCoordinate,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
normalizeReplaceableCoordinateString,
getRootETag, getRootETag,
isProtectedEvent, isProtectedEvent,
isReplaceableEvent, isReplaceableEvent,
@ -680,11 +681,14 @@ export function createCalendarRsvpDraftEvent(
status: 'accepted' | 'tentative' | 'declined', status: 'accepted' | 'tentative' | 'declined',
options: { content?: string; fb?: 'free' | 'busy' } = {} options: { content?: string; fb?: 'free' | 'busy' } = {}
): TDraftEvent { ): TDraftEvent {
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) const coordinate = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(calendarEvent))
const hint = client.getEventHint(calendarEvent.id) const hint = client.getEventHint(calendarEvent.id)
const calendarHexId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const tags: string[][] = [ const tags: string[][] = [
['a', coordinate, hint ?? ''], ['a', coordinate, hint ?? ''],
['e', calendarEvent.id, hint ?? ''], ['e', calendarHexId, hint ?? ''],
['d', randomString(12)], ['d', randomString(12)],
['status', status], ['status', status],
['p', calendarEvent.pubkey] ['p', calendarEvent.pubkey]

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

@ -1,7 +1,9 @@
import { import {
DEFAULT_FAVORITE_RELAYS, DEFAULT_FAVORITE_RELAYS,
DOCUMENT_RELAY_URLS,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
READ_ONLY_RELAY_URLS, READ_ONLY_RELAY_URLS,
isDocumentRelayKind,
relayFilterIncludesSocialKindBlockedKind relayFilterIncludesSocialKindBlockedKind
} from '@/constants' } from '@/constants'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
@ -163,6 +165,9 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
/** Profile REQ cap: too small waits on a few bad relays; larger spreads load across fast-read / favorites. */ /** Profile REQ cap: too small waits on a few bad relays; larger spreads load across fast-read / favorites. */
const PROFILE_PAGE_FEED_MAX_RELAYS = 14 const PROFILE_PAGE_FEED_MAX_RELAYS = 14
/** Long-form / publication profile tab: slightly larger cap + {@link DOCUMENT_RELAY_URLS} merge. */
const PROFILE_PAGE_DOCUMENT_FEED_MAX_RELAYS = 24
export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10 export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10
export function buildProfilePageReadRelayUrls( export function buildProfilePageReadRelayUrls(
@ -170,22 +175,31 @@ export function buildProfilePageReadRelayUrls(
blockedRelays: string[], blockedRelays: string[],
authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] }, authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] },
kindsIncludeSocialBlockedKind: boolean, kindsIncludeSocialBlockedKind: boolean,
includeAuthorLocalRelays = false includeAuthorLocalRelays = false,
/** When the timeline includes document kinds (30023, 30040, …), add document index relays and raise the cap. */
profileKindsHint?: readonly number[]
): string[] { ): string[] {
const wantsDocumentLayer = profileKindsHint?.some((k) => isDocumentRelayKind(k)) ?? false
const maxRelays = wantsDocumentLayer ? PROFILE_PAGE_DOCUMENT_FEED_MAX_RELAYS : PROFILE_PAGE_FEED_MAX_RELAYS
const list = includeAuthorLocalRelays const list = includeAuthorLocalRelays
? authorRelayList ? authorRelayList
: stripMailboxLocalUrlsForRemoteViewers(authorRelayList) : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
return getRelayUrlsWithFavoritesFastReadAndInbox( let urls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
[...(list.httpRead ?? []), ...(list.read ?? [])], [...(list.httpRead ?? []), ...(list.read ?? [])],
{ {
userWriteRelays: [...(list.httpWrite ?? []), ...(list.write ?? [])], userWriteRelays: [...(list.httpWrite ?? []), ...(list.write ?? [])],
authorWriteRelays: [], authorWriteRelays: [],
maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS, maxRelays,
applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind
} }
) )
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 urls
} }
/** /**

8
src/providers/NostrProvider/index.tsx

@ -1314,6 +1314,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
// If at least one relay accepted, cache and emit immediately so UI shows the event without waiting // If at least one relay accepted, cache and emit immediately so UI shows the event without waiting
if (publishResult.successCount >= 1) { if (publishResult.successCount >= 1) {
client.addEventToCache(event) client.addEventToCache(event)
// Calendar RSVPs: durable store before `newEvent` so hooks that re-read IDB see this row first.
if (event.kind === ExtendedKind.CALENDAR_EVENT_RSVP) {
try {
await indexedDb.putCalendarRsvpEventRow(event)
} catch (err) {
logger.warn('[Publish] Calendar RSVP IndexedDB persist failed', { err })
}
}
client.emitNewEvent(event) client.emitNewEvent(event)
// Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM // Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM
void replaceableEventService.updateReplaceableEventCache(event).catch(() => {}) void replaceableEventService.updateReplaceableEventCache(event).catch(() => {})

8
src/providers/UserTrustProvider.tsx

@ -34,7 +34,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
const initWoT = async () => { const initWoT = async () => {
const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts)
const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
followings.forEach((pubkey) => wotSet.add(pubkey)) followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase()))
const batchSize = 20 const batchSize = 20
for (let i = 0; i < followings.length; i += batchSize) { for (let i = 0; i < followings.length; i += batchSize) {
@ -44,7 +44,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
const followListEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts) const followListEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts)
const _followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] const _followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
_followings.forEach((following) => { _followings.forEach((following) => {
wotSet.add(following) wotSet.add(following.toLowerCase())
}) })
}) })
) )
@ -56,8 +56,8 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
const isUserTrusted = useCallback( const isUserTrusted = useCallback(
(pubkey: string) => { (pubkey: string) => {
if (!currentPubkey || pubkey === currentPubkey) return true if (!currentPubkey || pubkey.toLowerCase() === currentPubkey.toLowerCase()) return true
return wotSet.has(pubkey) return wotSet.has(pubkey.toLowerCase())
}, },
[currentPubkey] [currentPubkey]
) )

17
src/services/client.service.ts

@ -14,6 +14,8 @@ import {
SOCIAL_KIND_BLOCKED_RELAY_URLS, SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_CONCURRENT_RELAY_CONNECTIONS,
MAX_PUBLISH_RELAYS, MAX_PUBLISH_RELAYS,
PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP,
PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS,
PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS, PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS,
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS,
RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS, RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS,
@ -895,10 +897,21 @@ class ClientService extends EventTarget {
...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)) ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u))
]) ])
recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead) recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead)
const authorWriteOrdered = relayUrlsLocalsFirst(authorWrite)
/** Without this, tier‑1 author outboxes can consume all of {@link MAX_PUBLISH_RELAYS} and organizer inboxes never receive RSVPs. */
const recipientReadDeduped = recipientRead
const authorTier1Cap =
recipientReadDeduped.length > 0
? Math.min(PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP, authorWriteOrdered.length)
: authorWriteOrdered.length
const authorPrimary = authorWriteOrdered.slice(0, authorTier1Cap)
const authorOverflow = authorWriteOrdered.slice(authorTier1Cap)
const publishCap =
recipientReadDeduped.length > 0 ? PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS : MAX_PUBLISH_RELAYS
let pubRelays = mergeRelayPriorityLayers( let pubRelays = mergeRelayPriorityLayers(
[relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], [authorPrimary, recipientReadDeduped, authorOverflow],
blockedRelayUrls, blockedRelayUrls,
MAX_PUBLISH_RELAYS, publishCap,
{ applySocialKindBlockedFilter: false } { applySocialKindBlockedFilter: false }
) )
pubRelays = this.filterPublishingRelays(pubRelays, event) pubRelays = this.filterPublishingRelays(pubRelays, event)

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

@ -1173,6 +1173,7 @@ class IndexedDbService {
case ExtendedKind.PUBLICATION: case ExtendedKind.PUBLICATION:
case ExtendedKind.PUBLICATION_CONTENT: case ExtendedKind.PUBLICATION_CONTENT:
case ExtendedKind.WIKI_ARTICLE: case ExtendedKind.WIKI_ARTICLE:
case ExtendedKind.WIKI_ARTICLE_MARKDOWN:
case kinds.LongFormArticle: case kinds.LongFormArticle:
return StoreNames.PUBLICATION_EVENTS return StoreNames.PUBLICATION_EVENTS
case ExtendedKind.BADGE_DEFINITION: case ExtendedKind.BADGE_DEFINITION:
@ -1350,6 +1351,56 @@ class IndexedDbService {
}) })
} }
/**
* Cached long-form / wiki / publication rows for a profile author (same store as {@link putReplaceableEvent} for
* those kinds). Used to hydrate the profile Articles and publications tab before relay SUB results arrive.
*/
async getCachedPublicationStoreEventsForProfileAuthor(
pubkeyHex: string,
allowedKinds: number[],
limit: number
): Promise<Event[]> {
const pk = pubkeyHex.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk) || allowedKinds.length === 0 || limit <= 0) {
return []
}
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return []
}
const kindSet = new Set(allowedKinds)
const max = Math.min(Math.max(limit, 1), 500)
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[] = []
request.onsuccess = () => {
const cursor = (request as IDBRequest<IDBCursorWithValue>).result
if (!cursor || results.length >= max) {
transaction.commit()
resolve(results)
return
}
const item = cursor.value as TValue<Event> | undefined
if (item?.value) {
const event = item.value as Event
if (kindSet.has(event.kind) && event.pubkey?.toLowerCase() === pk) {
results.push(event)
}
}
cursor.continue()
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
/** /**
* Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags * Iterate PUBLICATION_EVENTS and return events whose kind is in allowedKinds and content or tags
* match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first. * match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first.

Loading…
Cancel
Save