From 1cca3465191dd5b10c02a745aa655c3b93aa9437 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 6 May 2026 14:35:13 +0200 Subject: [PATCH] bug-fixes --- .../LatestFromFollowsSection/index.tsx | 105 ++++++++++++++++-- src/constants.ts | 10 ++ src/hooks/useFetchCalendarRsvps.tsx | 33 +++++- src/hooks/useProfileTimeline.tsx | 29 ++++- src/lib/draft-event.ts | 8 +- src/lib/favorites-feed-relays.ts | 20 +++- src/providers/NostrProvider/index.tsx | 8 ++ src/providers/UserTrustProvider.tsx | 8 +- src/services/client.service.ts | 17 ++- src/services/indexed-db.service.ts | 51 +++++++++ 10 files changed, 259 insertions(+), 30 deletions(-) diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx index 93183e0a..f920d1f3 100644 --- a/src/components/LatestFromFollowsSection/index.tsx +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -60,17 +60,21 @@ const FEED_KINDS = [ const feedKindSet = new Set(FEED_KINDS) +const LOG = '[LatestFromFollows]' + function mergeBatchPosts( prev: Map, incoming: NostrEvent[], batchAuthors: string[] ): Map { const next = new Map(prev) - const authorSet = new Set(batchAuthors) - const filtered = incoming.filter((e) => authorSet.has(e.pubkey)) + /** Follow list pubkeys are lowercased in `getPubkeysFromPTags`; relay `pubkey` may be mixed-case hex. */ + const authorSet = new Set(batchAuthors.map((a) => a.toLowerCase())) + const filtered = incoming.filter((e) => authorSet.has(e.pubkey.toLowerCase())) for (const pk of batchAuthors) { + const pkNorm = pk.toLowerCase() 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() for (const e of prevList) byId.set(e.id, e) for (const e of newForPk) { @@ -188,11 +192,13 @@ export default function LatestFromFollowsSection({ setGuestFollowPubkeys([]) ;(async () => { + logger.info(`${LOG} guest: loading recommended follow list`) const hex = recommendedCuratorHexPubkey() if (!hex) { if (!cancelled) { setGuestFollowPubkeys([]) setGuestListReady(true) + logger.info(`${LOG} guest: no curator npub; follow list empty`) } return } @@ -201,6 +207,7 @@ export default function LatestFromFollowsSection({ if (cancelled) return const list = evt ? getPubkeysFromPTags(evt.tags).slice(0, MAX_FOLLOWS) : [] setGuestFollowPubkeys(list) + logger.info(`${LOG} guest: follow list loaded`, { count: list.length }) } catch (err) { logger.warn('[LatestFromFollows] Failed to load recommended follow list', err) 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. useEffect(() => { if (!isInitialized || loadingFollowList) { + logger.info(`${LOG} relays: waiting`, { + isInitialized, + loadingFollowList, + variant, + followsLabel + }) return } if (followPubkeys.length === 0) { + logger.info(`${LOG} relays: no follows; skipping aggregate`) setAggregateRelayUrls([]) setAggregateRelaysReady(true) return @@ -230,6 +244,11 @@ export default function LatestFromFollowsSection({ setAggregateRelayUrls([]) ;(async () => { + logger.info(`${LOG} relays: fetch NIP-65 lists start`, { + authorCount: followPubkeys.length, + variant, + followsLabel + }) try { // Dynamic import avoids a static cycle: client.service → replaceable-events → client.service // (would break React context / HMR when this module loads early). @@ -248,28 +267,53 @@ export default function LatestFromFollowsSection({ favoriteRelays ) setAggregateRelayUrls(urls) + logger.info(`${LOG} relays: aggregate URLs computed → setState`, { + nip65ListsLoaded: allLists.length, + aggregateUrlCount: urls.length, + relaySample: urls.slice(0, 6) + }) } catch (err) { logger.warn('[LatestFromFollows] Failed to build follow outbox aggregate relays', err) if (!cancelled) { - setAggregateRelayUrls( - buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays) - ) + const fallback = buildFollowOutboxAggregateReadUrls([], blockedRelays, favoriteRelays) + setAggregateRelayUrls(fallback) + logger.info(`${LOG} relays: using fallback aggregate URLs after error`, { + aggregateUrlCount: fallback.length + }) } } finally { - if (!cancelled) setAggregateRelaysReady(true) + if (!cancelled) { + setAggregateRelaysReady(true) + logger.info(`${LOG} relays: aggregateRelaysReady → true`) + } } })() return () => { 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. useEffect(() => { - if (!isInitialized || loadingFollowList) return - if (followPubkeys.length === 0) return - if (!aggregateRelaysReady) return + if (!isInitialized || loadingFollowList) { + logger.info(`${LOG} posts: waiting`, { + 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 let cancelled = false @@ -280,6 +324,24 @@ export default function LatestFromFollowsSection({ let working = seed ? postsRecordToMap(seed.posts) : new Map() setPostsByPubkey(new Map(working)) + const summarizePosts = (m: Map) => { + 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 = () => { writeSearchFollowsFeedCache({ 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) { if (cancelled || abortedRef.current) break const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) + const batchIndex = Math.floor(i / AUTHORS_PER_BATCH) + 1 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( aggregateRelayUrls, { @@ -302,11 +372,19 @@ export default function LatestFromFollowsSection({ }, { eoseTimeout: 2800, globalTimeout: 9000 } ) + const ms = Math.round(performance.now() - t0) if (cancelled || abortedRef.current) break const filtered = raw.filter((e) => acceptEvent(e)) working = mergeBatchPosts(working, filtered, batch) setPostsByPubkey(new Map(working)) 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) { logger.warn('[LatestFromFollows] Batch fetch failed', { err, batchSize: batch.length }) } @@ -314,6 +392,10 @@ export default function LatestFromFollowsSection({ if (!cancelled) { persist() setBatchBusy(false) + logger.info(`${LOG} posts: batch run finished`, { + cancelled: false, + ...summarizePosts(working) + }) } } @@ -322,6 +404,7 @@ export default function LatestFromFollowsSection({ cancelled = true abortedRef.current = true setBatchBusy(false) + logger.info(`${LOG} posts: batch effect cleanup (cancelled / deps changed)`) } }, [ followPubkeys, diff --git a/src/constants.ts b/src/constants.ts index 0eac290b..3623161c 100644 --- a/src/constants.ts +++ b/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). */ export const MAX_PUBLISH_RELAYS = 20 +/** + * Kind 24 / 31925: {@link mergeRelayPriorityLayers} used the full {@link MAX_PUBLISH_RELAYS} budget on the author’s + * 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. */ export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index 460286a1..2582a15f 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -15,6 +15,20 @@ import { FAST_READ_RELAY_URLS } from '@/constants' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' 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 { const status = rsvp.tags.find(tagNameEquals('status'))?.[1] 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[] { 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 - 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) } @@ -55,8 +70,10 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { getReplaceableCoordinateFromEvent(calendarEvent) ) const userRead = userReadRelaysWithHttp(relayList) + const userWrite = userWriteRelaysForQuery(relayList) void (async () => { + // Read order: IndexedDB first (offline + last session), then in-memory session, then relays. let fromIdb: Event[] = [] try { fromIdb = await indexedDb.getCalendarRsvpEventsByParentCoordinate(coordinate) @@ -67,11 +84,12 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) const mergedLocal = mergeRsvpList([...fromIdb, ...fromSession]) - if (mergedLocal.length) setRsvps(mergedLocal) + setRsvps(mergedLocal) const baseUrls = new Set([ ...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[]) const organizerPubkey = calendarEvent.pubkey @@ -120,7 +138,11 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { } ) 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 { if (!cancelled) setIsFetching(false) } @@ -150,6 +172,7 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const matchesA = aCoord !== '' && aCoord === coordinate const matchesE = eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId if (!matchesA && !matchesE) return + void indexedDb.putCalendarRsvpEventRow(evt).catch(() => undefined) setRsvps((prev) => mergeRsvp(prev, evt)) } diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 3418ff15..4b9eb9ae 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -2,12 +2,13 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client from '@/services/client.service' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' +import indexedDb from '@/services/indexed-db.service' type ProfileTimelineMemoryEntry = { events: Event[] @@ -221,9 +222,30 @@ export function useProfileTimeline({ blockedRelays, emptyAuthor, 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) => { if (cancelled || subRequests.length === 0) return try { @@ -273,7 +295,8 @@ export function useProfileTimeline({ blockedRelays, authorRl, socialKinds, - includeAuthorLocalRelays + includeAuthorLocalRelays, + kinds ) const deltaUrls = subtractNormalizedRelayUrls(fullFeedUrls, provisionalFeedUrls) if (cancelled || deltaUrls.length === 0) return diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index b235db0a..9a3ecf06 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -20,6 +20,7 @@ import { Event, kinds, nip19 } from 'nostr-tools' import { getReplaceableCoordinate, getReplaceableCoordinateFromEvent, + normalizeReplaceableCoordinateString, getRootETag, isProtectedEvent, isReplaceableEvent, @@ -680,11 +681,14 @@ export function createCalendarRsvpDraftEvent( status: 'accepted' | 'tentative' | 'declined', options: { content?: string; fb?: 'free' | 'busy' } = {} ): TDraftEvent { - const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) + const coordinate = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(calendarEvent)) const hint = client.getEventHint(calendarEvent.id) + const calendarHexId = /^[0-9a-f]{64}$/i.test(calendarEvent.id) + ? calendarEvent.id.toLowerCase() + : calendarEvent.id const tags: string[][] = [ ['a', coordinate, hint ?? ''], - ['e', calendarEvent.id, hint ?? ''], + ['e', calendarHexId, hint ?? ''], ['d', randomString(12)], ['status', status], ['p', calendarEvent.pubkey] diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 955693bc..f41f5096 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -1,7 +1,9 @@ import { DEFAULT_FAVORITE_RELAYS, + DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, READ_ONLY_RELAY_URLS, + isDocumentRelayKind, relayFilterIncludesSocialKindBlockedKind } from '@/constants' 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. */ 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 function buildProfilePageReadRelayUrls( @@ -170,22 +175,31 @@ export function buildProfilePageReadRelayUrls( blockedRelays: string[], authorRelayList: { read: string[]; write: string[]; httpRead?: string[]; httpWrite?: string[] }, 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[] { + 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 ? authorRelayList : stripMailboxLocalUrlsForRemoteViewers(authorRelayList) - return getRelayUrlsWithFavoritesFastReadAndInbox( + let urls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, [...(list.httpRead ?? []), ...(list.read ?? [])], { userWriteRelays: [...(list.httpWrite ?? []), ...(list.write ?? [])], authorWriteRelays: [], - maxRelays: PROFILE_PAGE_FEED_MAX_RELAYS, + maxRelays, 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 } /** diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 8441c235..d3528918 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/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 (publishResult.successCount >= 1) { 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) // Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM void replaceableEventService.updateReplaceableEventCache(event).catch(() => {}) diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index b236bf61..e3328461 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -34,7 +34,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { const initWoT = async () => { const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] - followings.forEach((pubkey) => wotSet.add(pubkey)) + followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase())) const batchSize = 20 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 _followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] _followings.forEach((following) => { - wotSet.add(following) + wotSet.add(following.toLowerCase()) }) }) ) @@ -56,8 +56,8 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { const isUserTrusted = useCallback( (pubkey: string) => { - if (!currentPubkey || pubkey === currentPubkey) return true - return wotSet.has(pubkey) + if (!currentPubkey || pubkey.toLowerCase() === currentPubkey.toLowerCase()) return true + return wotSet.has(pubkey.toLowerCase()) }, [currentPubkey] ) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 987ce934..64f91283 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -14,6 +14,8 @@ import { SOCIAL_KIND_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, MAX_PUBLISH_RELAYS, + PUBLIC_MESSAGE_RSVP_PUBLISH_AUTHOR_WRITE_CAP, + PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS, PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS, PUBLISH_RELAY_LIST_RESOLUTION_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)) ]) 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( - [relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], + [authorPrimary, recipientReadDeduped, authorOverflow], blockedRelayUrls, - MAX_PUBLISH_RELAYS, + publishCap, { applySocialKindBlockedFilter: false } ) pubRelays = this.filterPublishingRelays(pubRelays, event) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 89ccbbfa..f2151068 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1173,6 +1173,7 @@ class IndexedDbService { case ExtendedKind.PUBLICATION: case ExtendedKind.PUBLICATION_CONTENT: case ExtendedKind.WIKI_ARTICLE: + case ExtendedKind.WIKI_ARTICLE_MARKDOWN: case kinds.LongFormArticle: return StoreNames.PUBLICATION_EVENTS 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 { + 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).result + if (!cursor || results.length >= max) { + transaction.commit() + resolve(results) + return + } + const item = cursor.value as TValue | 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 * match the search query (case-insensitive). Used by nevent/naddr picker to show cached events first.