diff --git a/.cursor/plans/nostr-archives-rollout.md b/.cursor/plans/nostr-archives-rollout.md
new file mode 100644
index 00000000..1670965a
--- /dev/null
+++ b/.cursor/plans/nostr-archives-rollout.md
@@ -0,0 +1,165 @@
+# Nostr Archives integration — rollout checkpoint
+
+**Last updated:** 2026-06-03
+**Purpose:** Preserve plan position across agent “Fix” runs and chat resets. Resume from **Next step** below.
+
+**API docs:** https://nostrarchives.com/docs
+**Rule file:** `.cursor/rules/nostr-archives-integration.mdc`
+
+---
+
+## Status summary
+
+| Phase | Description | Status |
+|-------|-------------|--------|
+| **0** | Foundation (API client, ingest, graceful failure, search relay constant) | **DONE** |
+| **1** | Profile: follower count + paginated followers list | **DONE** |
+| **2** | `search.nostrarchives.com` in `SEARCHABLE_RELAY_URLS` | **DONE** (in Phase 0) |
+| **3** | General notes search: local + Archives REST merge | **DONE** |
+| **4** | Note stats: prefetch `/v1/events/{id}/interactions` | **DONE** |
+| **5a** | `/v1/search/suggest` in profile picker | **DONE** |
+| **5b** | `POST /v1/profiles/metadata` in search/thread UIs | **DONE** |
+| **6** | Note page: layered load (session → IDB → Archives → relays) | **DONE** |
+
+**Other recent work (same branch, separate from Archives):** Post editor TipTap blank-field fix (stable extensions + placeholder shell) — see commit history on `imwald`.
+
+---
+
+## Cross-cutting rules (all phases)
+
+1. **Persist verified events** — Any Archives payload with valid Nostr events goes through `persistArchivesEventsIfNew` / `persistArchivesPayloadEvents` → session cache + IndexedDB archive (`client.addEventToCache` → `queuePersistSeenEvent`). Skip if already in session or archive. Slim rows without valid `sig` are not persisted; use `getEventById` or relay backfill for offline.
+2. **Graceful failure** — `nostrArchivesApi` returns `TArchivesApiResult`; never throw. On failure: hide Archives-only UI or fall back to relays/local. Circuit breaker (2 failures → 60s). Setting: `storage.getUseNostrArchivesApi()` (default on). Hook: `useNostrArchivesAvailable()`.
+3. **Rate limit** — 100 req/min client budget via `nostrArchivesApi` only; batch metadata and interaction prefetch.
+
+---
+
+## Phase 0 — DONE (foundation files)
+
+| File | Role |
+|------|------|
+| `src/constants.ts` | `NOSTR_ARCHIVES_API_BASE_URL`, `NOSTR_ARCHIVES_SEARCH_RELAY_URL`, rate limit, `StorageKey.USE_NOSTR_ARCHIVES_API`, search relay in `SEARCHABLE_RELAY_URLS` |
+| `src/types/nostr-archives.ts` | `TArchivesApiResult`, social, interactions, metadata, note page types |
+| `src/lib/nostr-archives-event.ts` | Strip enrichment fields; `archivesJsonToVerifiedEvent()` |
+| `src/lib/nostr-archives-ingest.ts` | `persistArchivesEventsIfNew`, `persistArchivesPayloadEvents` |
+| `src/services/nostr-archives-api.service.ts` | HTTP client, circuit breaker, all endpoint stubs |
+| `src/services/local-storage.service.ts` | `getUseNostrArchivesApi` / `setUseNostrArchivesApi` |
+| `src/hooks/useNostrArchivesAvailable.ts` | UI availability |
+| `.cursor/rules/nostr-archives-integration.mdc` | Agent constraints |
+
+**Service methods ready:** `getEventInteractions`, `getSocialGraph`, `getEventById`, `getNotePage`, `searchNotes`, `searchGeneral`, `searchSuggest`, `fetchProfilesMetadata`.
+
+---
+
+## Phase 1 — DONE: Followers on profile
+
+| File | Role |
+|------|------|
+| `src/hooks/useNostrArchivesSocial.ts` | Counts via `getSocialGraph` (`followers_limit=0`) |
+| `src/components/Profile/SmartFollowers.tsx` | Count + link; hidden when API unavailable or no count |
+| `src/components/Profile/index.tsx` | Renders `SmartFollowers` next to `SmartFollowings` |
+| `src/pages/secondary/FollowersListPage/index.tsx` | Paginated list (100/page), infinite scroll |
+| `src/lib/link.ts` | `toFollowersList` |
+| `src/routes.tsx`, `PageManager.tsx`, `navigation.service.ts` | Route + mobile/desktop nav |
+| i18n `en.ts` / `de.ts` | Followers strings + indexer hint |
+
+**Offline:** `!social.ok` or `!useNostrArchivesAvailable()` → hide count and list unavailable message on list page.
+
+---
+
+## Phase 3 — DONE: General notes search
+
+| File | Role |
+|------|------|
+| `src/lib/nostr-archives-search.ts` | `searchArchivesNotesForGeneralSearch` → `/v1/notes/search` |
+| `src/components/SearchResult/FullTextSearchByRelay.tsx` | Parallel local + Archives rows; merged hits; source badges |
+
+**Offline:** Archives progress row hidden when `!useNostrArchivesAvailable()`; local search unchanged.
+
+**Deferred:** `searchGeneral` `resolved` entity navigation (profile/note picker) — not wired in notes full-text UI yet.
+
+---
+
+## Phase 4 — DONE: Interaction prefetch
+
+| File | Role |
+|------|------|
+| `src/lib/note-stats-archives-prefetch.ts` | Batched queue → `getEventInteractions` |
+| `src/services/note-stats.service.ts` | `archivesInteractions` on `TNoteStats`, `applyArchivesInteractionCounts`, display helpers |
+| `src/components/NoteStats/index.tsx` | `prefetchArchivesInteractions` when near viewport |
+| Stat buttons | `displayListCountWithArchives` / `displayZapSatsWithArchives`, `noteStatsHasResolvableCounts` |
+
+**Offline:** Prefetch no-op; relay stats unchanged.
+
+---
+
+## Phase 5 — Profiles medium
+
+### 5a — DONE
+- `src/lib/archives-profile-metadata.ts` — `archivesMetadataToProfile`
+- `client.searchProfilesStaged` — `searchSuggest` after local, before profile relays
+
+### 5b — DONE
+| File | Role |
+|------|------|
+| `src/lib/profile-metadata-batch.ts` | `fetchProfilesMetadataBatch` — Archives POST metadata, relay gap fill |
+| `FullTextSearchByRelay.tsx` | Search merged profile provider |
+| `ThreadProfileBatchProvider.tsx` | Thread/note panel batch |
+| `ProfileList/index.tsx` | Followers list + other pubkey lists |
+| `ProfileListBySearch/index.tsx` | Profile search results prefetch |
+
+---
+
+## Phase 6 — DONE: Note page pipeline
+
+| File | Role |
+|------|------|
+| `src/lib/note-page-load-pipeline.ts` | `resolveNoteEventFromArchives`, `fetchArchivesNotePageBundle`, `prewarmArchivesNotePage` |
+| `src/lib/thread-context-local.ts` | Archives REST after IDB archive, before publication store |
+| `src/hooks/useFetchEvent.tsx` | Local stores + Archives before relay `fetchEvent` |
+| `src/pages/secondary/NotePage/index.tsx` | Background `prewarmArchivesNotePage` (replies + interaction counts) |
+
+**Deferred:** optional IDB bundle cache service; NoteCard hover prewarm (no hover handler in card today).
+
+---
+
+
+## Suggested PR order (unchanged)
+
+1. ~~PR0+2: Foundation + search relay~~ (done)
+2. **PR1: Phase 1 followers** ← resume here after review fixes
+3. PR3: Notes search merge
+4. PR4: Interactions prefetch
+5. PR5: Suggest
+6. PR6: Metadata batch
+7. PR7: Note page pipeline
+
+---
+
+## Agent review / Fix button — scratch pad
+
+_Use this section to note review findings so the next session does not lose context._
+
+### Open issues (fill in when Fix runs)
+
+_None — minor gaps addressed 2026-06-03 (settings toggle, search resolved, note bundle profiles, feed metadata batch)._
+
+### Fixes applied
+
+- **`ARCHIVES_ENGAGEMENT_KEYS` included `'event'`** — stripped nested `{ event: … }` wrappers before `archivesJsonToVerifiedEvent` could unwrap them. Removed `'event'` from the set; test in `src/lib/nostr-archives-event.test.ts`.
+- **`isPersistableNostrEventShape` allowed `NaN` kind** — `typeof NaN === 'number'` passed; now `Number.isFinite(ev.kind)` (and early return in `archivesJsonToVerifiedEvent` after coercion).
+- **Duplicate `getEventById` in `useFetchEvent`** — removed second `resolveNoteEventFromArchives` call; `resolveThreadContextEventFromLocalStores` already hits Archives REST.
+- **Settings UI** — General settings toggle for `useNostrArchivesApi`; `nostrArchivesApi.notifySettingsChanged()` on change.
+- **`searchGeneral` resolved** — `tryResolveSearchViaArchives` in SearchPage submit path; `src/lib/nostr-archives-search-resolved.ts`.
+- **Note page bundle profiles** — `ThreadProfileBatchProvider.seedProfiles` + `prewarmArchivesNotePage` callback on NotePage.
+- **Feed profile batch** — `NoteList` uses `fetchProfilesMetadataBatch`.
+
+---
+
+## Resume prompt (paste into a new chat)
+
+```
+Continue Nostr Archives rollout from `.cursor/plans/nostr-archives-rollout.md`.
+Nostr Archives rollout phases 0–6 are complete. Use this file for review fixes or follow-ups (e.g. `searchGeneral` resolved navigation, optional note bundle IDB cache).
+Respect `.cursor/rules/nostr-archives-integration.mdc`.
+Check "Agent review / Fix button" section for any open issues first.
+```
diff --git a/src/PageManager.tsx b/src/PageManager.tsx
index d73e99b9..34c0ece4 100644
--- a/src/PageManager.tsx
+++ b/src/PageManager.tsx
@@ -109,6 +109,7 @@ const PostSignupBackupRedirectLazy = lazy(() => import('@/components/PostSignupB
/** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
+const PrimaryFollowersListPageLazy = lazy(() => import('@/pages/secondary/FollowersListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage'))
const PrimaryNotificationThreadFollowListPageLazy = lazy(() =>
@@ -786,6 +787,27 @@ export function useSmartFollowingListNavigation() {
return { navigateToFollowingList }
}
+export function useSmartFollowersListNavigation() {
+ const { setPrimaryNoteView } = usePrimaryNoteView()
+ const { push: pushSecondaryPage } = useSecondaryPage()
+ const { isSmallScreen } = useScreenSize()
+
+ const navigateToFollowersList = (url: string) => {
+ if (isSmallScreen) {
+ const profileId = url.replace('/users/', '').replace('/followers', '')
+ window.history.pushState(null, '', url)
+ setPrimaryNoteView(
+ suspensePrimaryPage(),
+ 'followers'
+ )
+ } else {
+ pushSecondaryPage(url)
+ }
+ }
+
+ return { navigateToFollowersList }
+}
+
// Fixed: Mute list navigation now uses primary note view on mobile, secondary routing on desktop
export function useSmartMuteListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
@@ -1953,6 +1975,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
if (
primaryViewType === 'following' ||
+ primaryViewType === 'followers' ||
primaryViewType === 'others-relay-settings'
) {
const currentPath = window.location.pathname.split('?')[0].split('#')[0]
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index de4daa4f..ff52a048 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -34,6 +34,7 @@ import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
+import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { useFeedAttestedSuperchatIds } from '@/hooks/useFeedAttestedSuperchatIds'
import { shouldIncludePaymentInFeed } from '@/lib/superchat'
@@ -1787,7 +1788,7 @@ const NoteList = forwardRef(
chunks.push(need.slice(i, i + FEED_PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
- chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
+ chunks.map((chunk) => fetchProfilesMetadataBatch(chunk))
)
if (gen !== feedProfileBatchGenRef.current) return
diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx
index fe13a008..d035340f 100644
--- a/src/components/NoteStats/LikeButton.tsx
+++ b/src/components/NoteStats/LikeButton.tsx
@@ -25,7 +25,11 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { eventService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
-import type { TNoteStats } from '@/services/note-stats.service'
+import {
+ displayListCountWithArchives,
+ noteStatsHasResolvableCounts,
+ type TNoteStats
+} from '@/services/note-stats.service'
import { TEmoji } from '@/types'
import { SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools'
@@ -72,7 +76,7 @@ export function LikeButtonWithStats({
const isReplyToDiscussion = isReplyToDiscussionProp ?? false
const showDiscussionVotes = isDiscussion || isReplyToDiscussion
- const statsLoaded = noteStats?.updatedAt != null
+ const statsLoaded = noteStatsHasResolvableCounts(noteStats)
const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => {
const stats = noteStats || {}
@@ -93,7 +97,9 @@ export function LikeButtonWithStats({
return {
myLastEmoji: myLike?.emoji,
- likeCount: likes?.length,
+ likeCount: showDiscussionVotes
+ ? likes?.length
+ : displayListCountWithArchives(likes?.length, stats.archivesInteractions, 'reactions'),
upVoteCount,
downVoteCount
}
diff --git a/src/components/NoteStats/ReplyButton.tsx b/src/components/NoteStats/ReplyButton.tsx
index 0109e005..65383f30 100644
--- a/src/components/NoteStats/ReplyButton.tsx
+++ b/src/components/NoteStats/ReplyButton.tsx
@@ -2,7 +2,11 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { cn } from '@/lib/utils'
import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider'
-import type { TNoteStats } from '@/services/note-stats.service'
+import {
+ displayListCountWithArchives,
+ noteStatsHasResolvableCounts,
+ type TNoteStats
+} from '@/services/note-stats.service'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
@@ -26,11 +30,15 @@ export function ReplyButtonWithStats({ event, hideCount = false, noteStats }: Re
: false
return {
- replyCount: noteStats?.replies?.length ?? 0,
+ replyCount: displayListCountWithArchives(
+ noteStats?.replies?.length,
+ noteStats?.archivesInteractions,
+ 'replies'
+ ),
hasReplied
}
}, [noteStats, event.id, pubkey])
- const statsLoaded = noteStats?.updatedAt != null
+ const statsLoaded = noteStatsHasResolvableCounts(noteStats)
const replyCountLabel = statsLoaded
? replyCount >= 100
? '99+'
diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx
index 9ceab48a..ff1e8e1a 100644
--- a/src/components/NoteStats/RepostButton.tsx
+++ b/src/components/NoteStats/RepostButton.tsx
@@ -22,7 +22,11 @@ import { useSignGatedControl } from '@/hooks/useSignGatedControl'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import noteStatsService from '@/services/note-stats.service'
-import type { TNoteStats } from '@/services/note-stats.service'
+import {
+ displayListCountWithArchives,
+ noteStatsHasResolvableCounts,
+ type TNoteStats
+} from '@/services/note-stats.service'
import { PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
@@ -48,10 +52,14 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
- const statsLoaded = noteStats?.updatedAt != null
+ const statsLoaded = noteStatsHasResolvableCounts(noteStats)
const { repostCount, hasReposted } = useMemo(() => {
return {
- repostCount: noteStats?.reposts?.length,
+ repostCount: displayListCountWithArchives(
+ noteStats?.reposts?.length,
+ noteStats?.archivesInteractions,
+ 'reposts'
+ ),
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
}
}, [noteStats, event.id, pubkey])
diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx
index f7ea2ca3..dfbfd7dd 100644
--- a/src/components/NoteStats/ZapButton.tsx
+++ b/src/components/NoteStats/ZapButton.tsx
@@ -1,4 +1,8 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
+import {
+ displayZapSatsWithArchives,
+ noteStatsHasResolvableCounts
+} from '@/services/note-stats.service'
import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods'
import {
buildRecipientPaymentData,
@@ -124,10 +128,10 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const { t } = useTranslation()
const { pubkey } = useNostr()
const [openPaymentDialog, setOpenPaymentDialog] = useState(false)
- const statsLoaded = noteStats?.updatedAt != null
+ const statsLoaded = noteStatsHasResolvableCounts(noteStats)
const { zapAmount, hasZapped } = useMemo(() => {
return {
- zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
+ zapAmount: displayZapSatsWithArchives(noteStats?.zaps, noteStats?.archivesInteractions),
hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
}
}, [noteStats, pubkey])
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx
index f5ccb9ae..08fde738 100644
--- a/src/components/NoteStats/index.tsx
+++ b/src/components/NoteStats/index.tsx
@@ -115,6 +115,7 @@ export default function NoteStats({
useEffect(() => {
if (!fetchIfNotExisting) return
if (shouldDeferStatsFetch && !isNearViewport) return
+ noteStatsService.prefetchArchivesInteractions(event.id)
setLoading(true)
noteStatsService
.fetchNoteStats(event, pubkey, statsRelaysRef.current, {
diff --git a/src/components/Profile/SmartFollowers.tsx b/src/components/Profile/SmartFollowers.tsx
new file mode 100644
index 00000000..80531d98
--- /dev/null
+++ b/src/components/Profile/SmartFollowers.tsx
@@ -0,0 +1,32 @@
+import { useNostrArchivesSocial } from '@/hooks/useNostrArchivesSocial'
+import { toFollowersList } from '@/lib/link'
+import { useSmartFollowersListNavigation } from '@/PageManager'
+import { Skeleton } from '@/components/ui/skeleton'
+import { useTranslation } from 'react-i18next'
+
+export default function SmartFollowers({ pubkey }: { pubkey: string }) {
+ const { t } = useTranslation()
+ const { followersCount, isFetching, showFollowers } = useNostrArchivesSocial(pubkey)
+ const { navigateToFollowersList } = useSmartFollowersListNavigation()
+
+ if (!showFollowers) return null
+
+ const handleClick = () => {
+ navigateToFollowersList(toFollowersList(pubkey))
+ }
+
+ return (
+
+ {isFetching ? (
+
+ ) : (
+ followersCount
+ )}
+ {t('Followers')}
+
+ )
+}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index bba8d8fe..e14454db 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -66,6 +66,7 @@ import NotFound from '../NotFound'
import ProfileBadges from './ProfileBadges'
import ProfileFeed from './ProfileFeed'
import ProfileReportsDialog from './ProfileReportsDialog'
+import SmartFollowers from './SmartFollowers'
import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
@@ -617,6 +618,7 @@ export default function Profile({
)}
+
{isSelf && }
diff --git a/src/components/ProfileList/index.tsx b/src/components/ProfileList/index.tsx
index 5e63765b..a1c7ec8f 100644
--- a/src/components/ProfileList/index.tsx
+++ b/src/components/ProfileList/index.tsx
@@ -1,5 +1,5 @@
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
-import client from '@/services/client.service'
+import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import type { TProfile } from '@/types'
import { useEffect, useMemo, useRef, useState } from 'react'
import UserItem from '../UserItem'
@@ -68,7 +68,7 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
chunks.push(need.slice(i, i + PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
- chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
+ chunks.map((chunk) => fetchProfilesMetadataBatch(chunk))
)
if (gen !== batchGenRef.current) return
diff --git a/src/components/ProfileListBySearch/index.tsx b/src/components/ProfileListBySearch/index.tsx
index f7ca50c5..05571a94 100644
--- a/src/components/ProfileListBySearch/index.tsx
+++ b/src/components/ProfileListBySearch/index.tsx
@@ -2,8 +2,10 @@ import { useSecondaryPage } from '@/PageManager'
import { PROFILE_RELAY_URLS } from '@/constants'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
+import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import { toProfile } from '@/lib/link'
import client from '@/services/client.service'
+import type { TProfile } from '@/types'
import { cn } from '@/lib/utils'
import dayjs from 'dayjs'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -29,7 +31,9 @@ export function ProfileListBySearch({
const [hasMore, setHasMore] = useState(true)
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
const [empty, setEmpty] = useState(false)
+ const [profilesByPubkey, setProfilesByPubkey] = useState