diff --git a/src/components/AccountList/index.tsx b/src/components/AccountList/index.tsx index 79fc7970..ea45bd17 100644 --- a/src/components/AccountList/index.tsx +++ b/src/components/AccountList/index.tsx @@ -57,13 +57,13 @@ export default function AccountList({ if (isRedundantAccountPick(act, account)) { if (account?.signerType === 'npub' && act.signerType === 'nip-07') { setSwitchingAccount(act) - const switched = await switchAccount(act) - if (switched) { + await switchAccount(act) + const ok = await retryNip07SignerForPreferredAccount() + if (ok) { + toast.success(t('accountSwitch.extensionConnected')) afterSwitch() } else { - const ok = await retryNip07SignerForPreferredAccount() - if (ok) toast.success(t('accountSwitch.extensionConnected')) - else toast.error(t('accountSwitch.extensionRetryFailed')) + toast.error(t('accountSwitch.extensionRetryFailed')) } setSwitchingAccount(null) } diff --git a/src/components/AccountQuickSwitchMenuItems.tsx b/src/components/AccountQuickSwitchMenuItems.tsx index 2ae02d81..f5c8bd6b 100644 --- a/src/components/AccountQuickSwitchMenuItems.tsx +++ b/src/components/AccountQuickSwitchMenuItems.tsx @@ -50,11 +50,8 @@ export function AccountQuickSwitchMenuItems({ onAfterSwitch }: { onAfterSwitch?: if (isRedundantAccountPick(act, account)) { if (account?.signerType === 'npub' && act.signerType === 'nip-07') { - const switched = await switchAccount(act) - if (switched) { - onAfterSwitch?.() - return - } + // switchAccount may return a pubkey even when it fell back to read-only npub — always try reconnect. + await switchAccount(act) const ok = await retryNip07SignerForPreferredAccount() if (ok) { toast.success(t('accountSwitch.extensionConnected')) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index fadbe582..36a3a0b5 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -490,6 +490,8 @@ function ReplyNoteList({ ]) const [loading, setLoading] = useState(false) + /** Bumped when thread relay URLs are known — re-runs stats id hydration with inbox relays. */ + const [threadRelaysRevision, setThreadRelaysRevision] = useState(0) const [showCount, setShowCount] = useState(THREAD_REPLY_SHOW_COUNT) const [highlightReplyId, setHighlightReplyId] = useState(undefined) const replyRefs = useRef>({}) @@ -500,6 +502,7 @@ function ReplyNoteList({ useEffect(() => { statsHydratedReplyIdsRef.current.clear() + setThreadRelaysRevision(0) }, [event.id]) useEffect(() => { @@ -520,11 +523,14 @@ function ReplyNoteList({ ) if (candidates.length === 0) return + const relayUrls = threadRelayUrlsRef.current + if (!relayUrls.length) return + let cancelled = false ;(async () => { for (const { id } of candidates) statsHydratedReplyIdsRef.current.add(id) const batch = await hydrateThreadRepliesFromStats(candidates, { - relayUrls: threadRelayUrlsRef.current, + relayUrls, mutePubkeySet, hideContentMentioningMutedUsers }) @@ -547,7 +553,8 @@ function ReplyNoteList({ addReplies, mutePubkeySet, hideContentMentioningMutedUsers, - refreshToken + refreshToken, + threadRelaysRevision ]) /** When stats counted many replies but the thread REQ returned few, run the same social filters as note-stats. */ @@ -695,12 +702,17 @@ function ReplyNoteList({ // Check cache next — discussion cache merges with relay results const cachedData = discussionFeedCache.getCachedReplies(rootInfo) const hasCache = cachedData !== null + const existingReplyCount = [...repliesMap.values()].reduce((n, b) => n + b.events.length, 0) + const showLoadingIndicator = + existingReplyCount === 0 && !(hasCache && cachedData && cachedData.length > 0) if (hasCache && cachedData) { addReplies(cachedData) - setLoading(false) - } else { + } + if (showLoadingIndicator) { setLoading(true) + } else { + setLoading(false) } try { @@ -727,6 +739,7 @@ function ReplyNoteList({ async function fetchFromRelays() { if (!rootInfo) return // Type guard + const streamWalk = new Map() try { // READ from: thread hints, author/user NIP-65, favorites, cache — then DEFAULT_FAVORITE_RELAYS fallback. const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined @@ -789,6 +802,7 @@ function ReplyNoteList({ ) ) threadRelayUrlsRef.current = relayUrlsForThreadReq + setThreadRelaysRevision((n) => n + 1) const recipientPubkey = event.pubkey // Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE. @@ -804,16 +818,17 @@ function ReplyNoteList({ } if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return - if (statsIdsStream.size > 0) { - if ( - !statsIdsStream.has(evt.id) && - !replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) - ) { - return - } + streamWalk.set(evt.id.toLowerCase(), evt) + if (statsIdsStream.has(evt.id)) { + addReplies([evt]) + setLoading(false) + return + } + if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, streamWalk)) { + return } addReplies([evt]) - if (!hasCache) setLoading(false) + setLoading(false) } const superchatFilters = buildThreadSuperchatPriorityFilters({ @@ -941,9 +956,22 @@ function ReplyNoteList({ }, 0) } - if (!hasCache) { - // No cache: stop loading after adding replies - setLoading(false) + const statsAfterFetch = noteStatsService.getNoteStats(event.id)?.replies + if (statsAfterFetch?.length) { + const resolvedIds = new Set(mergedForUi.map((e) => e.id)) + const missingStats = statsAfterFetch.filter( + (r) => !resolvedIds.has(r.id) && !client.peekSessionCachedEvent(r.id) + ) + if (missingStats.length > 0) { + const hydrated = await hydrateThreadRepliesFromStats(missingStats, { + relayUrls: relayUrlsForThreadReq, + mutePubkeySet, + hideContentMentioningMutedUsers + }) + if (fetchGeneration === replyFetchGenRef.current && hydrated.length > 0) { + addReplies(hydrated) + } + } } // Second pass for URL threads: fetch replies to individual comments that may omit the @@ -1068,9 +1096,8 @@ function ReplyNoteList({ } } catch (error) { logger.error('[ReplyNoteList] Error fetching replies:', error) - if (fetchGeneration !== replyFetchGenRef.current) return - if (!hasCache) { - // Only set loading to false if we don't have cache to fall back on + } finally { + if (fetchGeneration === replyFetchGenRef.current) { setLoading(false) } } diff --git a/src/components/StoredAccountSwitchSelect.tsx b/src/components/StoredAccountSwitchSelect.tsx index 47f1256b..415fda32 100644 --- a/src/components/StoredAccountSwitchSelect.tsx +++ b/src/components/StoredAccountSwitchSelect.tsx @@ -161,8 +161,7 @@ export default function StoredAccountSwitchSelect({ if (account?.signerType === 'npub' && nextAccount.signerType === 'nip-07') { setSwitchingKey(accountPointerKey(nextAccount)) try { - const switched = await switchAccount(nextAccount) - if (switched) return + await switchAccount(nextAccount) const ok = await retryNip07SignerForPreferredAccount() if (ok) toast.success(t('accountSwitch.extensionConnected')) else toast.error(t('accountSwitch.extensionRetryFailed')) diff --git a/src/constants.ts b/src/constants.ts index fd36df61..42d6b756 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -515,7 +515,8 @@ export const FAST_READ_RELAY_URLS = [ 'wss://nostr.land', 'wss://nostr.wine', 'wss://nostr21.com', - 'wss://primus.nostr1.com' + 'wss://primus.nostr1.com', + 'wss://nostr.sovbit.host' ] // Optimized relay list for write operations (no aggregator since it's read-only) @@ -557,15 +558,8 @@ export const NOSTR_ARCHIVES_API_RATE_LIMIT_PER_MIN = 100 export const SEARCHABLE_RELAY_URLS = [ NOSTR_ARCHIVES_SEARCH_RELAY_URL, 'wss://search.nos.today', - 'wss://nostr.wine', 'wss://relay.noswhere.com', - 'wss://nostr-pub.wellorder.net', - 'wss://relay.damus.io', - 'wss://theforest.nostr1.com', - 'wss://nostr.land', - 'wss://relay.primal.net', - 'wss://nos.lol', - 'wss://thecitadel.nostr1.com' + 'wss://nostr-pub.wellorder.net' ] /** diff --git a/src/lib/relay-icon-source.test.ts b/src/lib/relay-icon-source.test.ts index 233795be..6f427d03 100644 --- a/src/lib/relay-icon-source.test.ts +++ b/src/lib/relay-icon-source.test.ts @@ -1,5 +1,10 @@ +import { NOSTR_ARCHIVES_SEARCH_RELAY_URL } from '@/constants' import { describe, expect, it } from 'vitest' -import { getRelayIconFallbackGlyph, getRelayIconOverrideSrc } from '@/lib/relay-icon-source' +import { + getRelayIconFallbackGlyph, + getRelayIconOverrideSrc, + NOSTRARCHIVES_SITE_ICON_SRC +} from '@/lib/relay-icon-source' describe('relay icon branding', () => { it('uses favicon override for sovbit hosts', () => { @@ -11,4 +16,8 @@ describe('relay icon branding', () => { expect(getRelayIconFallbackGlyph('wss://purplepag.es/')).toBe('🟣') expect(getRelayIconOverrideSrc('wss://purplepag.es/')).toBeUndefined() }) + + it('uses nostrarchives favicon for search relay (same as trending)', () => { + expect(getRelayIconOverrideSrc(NOSTR_ARCHIVES_SEARCH_RELAY_URL)).toBe(NOSTRARCHIVES_SITE_ICON_SRC) + }) }) diff --git a/src/lib/relay-icon-source.ts b/src/lib/relay-icon-source.ts index 9743fdce..23eb9c67 100644 --- a/src/lib/relay-icon-source.ts +++ b/src/lib/relay-icon-source.ts @@ -1,3 +1,4 @@ +import { NOSTR_ARCHIVES_SEARCH_RELAY_URL } from '@/constants' import { normalizeUrl } from '@/lib/url' import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' @@ -14,11 +15,27 @@ export const NOSTR_SOVBIT_ICON_SRC = 'https://nostr.sovbit.host/favicon.ico' export const FREELAY_SOVBIT_ICON_SRC = 'https://freelay.sovbit.host/favicon.ico' /** - * Nostr Archives front-site favicon for trending shards and related relay hosts. + * Nostr Archives front-site favicon for trending shards, search relay, and related hosts. * @see https://nostrarchives.com/ */ export const NOSTRARCHIVES_SITE_ICON_SRC = 'https://nostrarchives.com/favicon.ico' +/** Same branding as Wisp trending — nostrarchives.com favicon in {@link RelayIcon}. */ +export function isNostrArchivesBrandedRelayUrl(url: string | undefined): boolean { + if (!url) return false + if (isWispTrendingNotesRelayUrl(url)) return true + const norm = (normalizeUrl(url) || url).trim().toLowerCase() + if (norm === (normalizeUrl(NOSTR_ARCHIVES_SEARCH_RELAY_URL) || NOSTR_ARCHIVES_SEARCH_RELAY_URL).toLowerCase()) { + return true + } + const host = parseRelayHostname(url) + return ( + host === 'feeds.nostrarchives.com' || + host === 'nostrarchives.com' || + host === 'search.nostrarchives.com' + ) +} + function parseRelayHostname(url: string): string | undefined { const raw = (normalizeUrl(url) || url).trim() const forParse = raw.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') @@ -43,11 +60,7 @@ export function getRelayIconOverrideSrc(url: string | undefined): string | undef if (host === 'freelay.sovbit.host') { return FREELAY_SOVBIT_ICON_SRC } - if ( - isWispTrendingNotesRelayUrl(url) || - host === 'feeds.nostrarchives.com' || - host === 'nostrarchives.com' - ) { + if (isNostrArchivesBrandedRelayUrl(url)) { return NOSTRARCHIVES_SITE_ICON_SRC } return undefined diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 8127b67c..101beead 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -13,6 +13,7 @@ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from import { getHttpRelayListFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes' import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { prependAggrForEventLookupRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' @@ -737,7 +738,18 @@ export async function buildReplyReadRelayList( includeProfileFetchRelays: useGlobal, blockedRelays }) - return prependAggrForEventLookupRelayUrls( - mergeRelayUrlLayers([scoped, defaultFavoriteRelaysForViewer(useGlobal)], blockedRelays) - ) + const layers: string[][] = [threadRelayHints] + if (userPubkey) { + try { + const rl = await client.peekRelayListFromStorage(userPubkey) + const cache = await getCacheRelayUrls(userPubkey).catch(() => [] as string[]) + const inbox = collectUserReadInboxUrls(rl ?? undefined, cache) + if (inbox.length > 0) layers.push(inbox) + } catch { + /* inbox tier optional */ + } + } + layers.push(scoped) + layers.push(defaultFavoriteRelaysForViewer(useGlobal)) + return prependAggrForEventLookupRelayUrls(mergeRelayUrlLayers(layers, blockedRelays)) }