Browse Source

more nostr archives

imwald
Silberengel 2 weeks ago
parent
commit
a047a5b848
  1. 165
      .cursor/plans/nostr-archives-rollout.md
  2. 23
      src/PageManager.tsx
  3. 3
      src/components/NoteList/index.tsx
  4. 12
      src/components/NoteStats/LikeButton.tsx
  5. 14
      src/components/NoteStats/ReplyButton.tsx
  6. 14
      src/components/NoteStats/RepostButton.tsx
  7. 8
      src/components/NoteStats/ZapButton.tsx
  8. 1
      src/components/NoteStats/index.tsx
  9. 32
      src/components/Profile/SmartFollowers.tsx
  10. 2
      src/components/Profile/index.tsx
  11. 4
      src/components/ProfileList/index.tsx
  12. 32
      src/components/ProfileListBySearch/index.tsx
  13. 194
      src/components/SearchResult/FullTextSearchByRelay.tsx
  14. 1
      src/contexts/primary-note-view-context.tsx
  15. 16
      src/hooks/useFetchEvent.tsx
  16. 40
      src/hooks/useNostrArchivesSocial.ts
  17. 12
      src/i18n/locales/de.ts
  18. 12
      src/i18n/locales/en.ts
  19. 38
      src/lib/archives-profile-metadata.ts
  20. 4
      src/lib/document-meta.ts
  21. 4
      src/lib/link.ts
  22. 69
      src/lib/nostr-archives-event.test.ts
  23. 6
      src/lib/nostr-archives-event.ts
  24. 26
      src/lib/nostr-archives-search-resolved.test.ts
  25. 137
      src/lib/nostr-archives-search-resolved.ts
  26. 37
      src/lib/nostr-archives-search.ts
  27. 69
      src/lib/note-page-load-pipeline.ts
  28. 94
      src/lib/note-stats-archives-prefetch.ts
  29. 67
      src/lib/profile-metadata-batch.ts
  30. 6
      src/lib/thread-context-local.ts
  31. 215
      src/pages/secondary/FollowersListPage/index.tsx
  32. 18
      src/pages/secondary/GeneralSettingsPage/index.tsx
  33. 17
      src/pages/secondary/NotePage/index.tsx
  34. 64
      src/pages/secondary/SearchPage/index.tsx
  35. 25
      src/providers/ThreadProfileBatchProvider.tsx
  36. 2
      src/routes.tsx
  37. 12
      src/services/client.service.ts
  38. 21
      src/services/navigation.service.ts
  39. 5
      src/services/nostr-archives-api.service.ts
  40. 36
      src/services/note-stats.service.ts

165
.cursor/plans/nostr-archives-rollout.md

@ -0,0 +1,165 @@ @@ -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.
```

23
src/PageManager.tsx

@ -109,6 +109,7 @@ const PostSignupBackupRedirectLazy = lazy(() => import('@/components/PostSignupB @@ -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() { @@ -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(<PrimaryFollowersListPageLazy id={profileId} index={0} hideTitlebar={true} />),
'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 }) { @@ -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]

3
src/components/NoteList/index.tsx

@ -34,6 +34,7 @@ import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls' @@ -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( @@ -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

12
src/components/NoteStats/LikeButton.tsx

@ -25,7 +25,11 @@ import { useNostr } from '@/providers/NostrProvider' @@ -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({ @@ -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({ @@ -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
}

14
src/components/NoteStats/ReplyButton.tsx

@ -2,7 +2,11 @@ import { useNoteStatsById } from '@/hooks/useNoteStatsById' @@ -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 @@ -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+'

14
src/components/NoteStats/RepostButton.tsx

@ -22,7 +22,11 @@ import { useSignGatedControl } from '@/hooks/useSignGatedControl' @@ -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 @@ -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])

8
src/components/NoteStats/ZapButton.tsx

@ -1,4 +1,8 @@ @@ -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 @@ -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])

1
src/components/NoteStats/index.tsx

@ -115,6 +115,7 @@ export default function NoteStats({ @@ -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, {

32
src/components/Profile/SmartFollowers.tsx

@ -0,0 +1,32 @@ @@ -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 (
<span
className="flex gap-1 hover:underline w-fit items-center cursor-pointer"
onClick={handleClick}
title={t('Nostr Archives followers hint')}
>
{isFetching ? (
<Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden />
) : (
followersCount
)}
<div className="text-muted-foreground">{t('Followers')}</div>
</span>
)
}

2
src/components/Profile/index.tsx

@ -66,6 +66,7 @@ import NotFound from '../NotFound' @@ -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({ @@ -617,6 +618,7 @@ export default function Profile({
)}
<div className="flex flex-wrap gap-4 items-center gap-x-4 gap-y-2 mt-2 text-sm min-w-0">
<SmartFollowings pubkey={pubkey} />
<SmartFollowers pubkey={pubkey} />
<SmartRelays pubkey={pubkey} />
{isSelf && <SmartMuteLink />}
</div>

4
src/components/ProfileList/index.tsx

@ -1,5 +1,5 @@ @@ -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[] }) { @@ -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

32
src/components/ProfileListBySearch/index.tsx

@ -2,8 +2,10 @@ import { useSecondaryPage } from '@/PageManager' @@ -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({ @@ -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<Map<string, TProfile>>(() => new Map())
const bottomRef = useRef<HTMLDivElement>(null)
const profileBatchGenRef = useRef(0)
const loadMoreInFlight = useRef(false)
const untilRef = useRef(until)
untilRef.current = until
@ -107,6 +111,28 @@ export function ProfileListBySearch({ @@ -107,6 +111,28 @@ export function ProfileListBySearch({
}
}, [search])
const pubkeysKey = pubkeys.join('\u0001')
useEffect(() => {
const need = pubkeys
.map((pk) => pk.trim().toLowerCase())
.filter((pk) => /^[0-9a-f]{64}$/.test(pk))
if (need.length === 0) {
setProfilesByPubkey(new Map())
return
}
const gen = ++profileBatchGenRef.current
void fetchProfilesMetadataBatch(need).then((profiles) => {
if (gen !== profileBatchGenRef.current) return
const next = new Map<string, TProfile>()
for (const p of profiles) {
next.set(p.pubkey.toLowerCase(), { ...p, pubkey: p.pubkey.toLowerCase() })
}
setProfilesByPubkey(next)
})
}, [pubkeysKey])
const loadMore = useCallback(async () => {
if (loadMoreInFlight.current || !hasMore) return
loadMoreInFlight.current = true
@ -207,7 +233,11 @@ export function ProfileListBySearch({ @@ -207,7 +233,11 @@ export function ProfileListBySearch({
}
}}
>
<UserItem pubkey={pubkey} />
<UserItem
pubkey={pubkey}
prefetchedProfile={profilesByPubkey.get(pubkey.toLowerCase())}
deferRemoteAvatar={false}
/>
</div>
))}
{phase === 'ready' && hasMore && pubkeys.length > 0 && (

194
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -3,20 +3,26 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -3,20 +3,26 @@ import { Skeleton } from '@/components/ui/skeleton'
import { compareMergedGeneralSearchHits } from '@/lib/dtag-search'
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
import { searchArchivesNotesForGeneralSearch } from '@/lib/nostr-archives-search'
import { useNostrArchivesAvailable } from '@/hooks/useNostrArchivesAvailable'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import client from '@/services/client.service'
import type { TProfile } from '@/types'
import type { Event } from 'nostr-tools'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url'
import { HardDrive, Loader2 } from 'lucide-react'
import { Archive, HardDrive, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
type SearchHitSource = 'local' | 'archives'
type LocalHit = {
event: Event
source: SearchHitSource
}
const LOCAL_SEARCH_MAX_EVENTS = 150
@ -105,7 +111,7 @@ function SearchMergedProfileProvider({ @@ -105,7 +111,7 @@ function SearchMergedProfileProvider({
if (cancelled) return
let profiles: TProfile[] = []
try {
profiles = await client.fetchProfilesForPubkeys(chunk)
profiles = await fetchProfilesMetadataBatch(chunk)
} catch {
profiles = []
}
@ -206,8 +212,10 @@ export default function FullTextSearchByRelay({ @@ -206,8 +212,10 @@ export default function FullTextSearchByRelay({
alexandriaEmptyHref?: string | null
}) {
const { t } = useTranslation()
const archivesAvailable = useNostrArchivesAvailable()
const runGeneration = useRef(0)
const [localRow, setLocalRow] = useState<LocalSearchRow | null>(null)
const [archivesRow, setArchivesRow] = useState<LocalSearchRow | null>(null)
const [hits, setHits] = useState<LocalHit[]>([])
const q = searchQuery.trim()
@ -223,18 +231,21 @@ export default function FullTextSearchByRelay({ @@ -223,18 +231,21 @@ export default function FullTextSearchByRelay({
if (!q) {
setLocalRow(null)
setArchivesRow(null)
setHits([])
return
}
const kindsArr = [...kinds]
setLocalRow({ phase: 'loading', hitCount: 0, rawCount: 0 })
setArchivesRow(archivesAvailable ? { phase: 'loading', hitCount: 0, rawCount: 0 } : null)
setHits([])
void (async () => {
const t0 = performance.now()
try {
const mergedLocal = await collectLocalEventsForTextSearch({
const t0Local = performance.now()
const t0Archives = performance.now()
const localPromise = collectLocalEventsForTextSearch({
query: q,
allowedKinds: kindsArr,
sessionCap: 220,
@ -243,44 +254,145 @@ export default function FullTextSearchByRelay({ @@ -243,44 +254,145 @@ export default function FullTextSearchByRelay({
includeOtherStoresFullText: true,
fullTextStoreHitCap: 260
})
if (myRun !== runGeneration.current) return
const visible = mergedLocal
const archivesPromise = archivesAvailable
? searchArchivesNotesForGeneralSearch({ query: q, kinds: kindsArr, limit: 100 })
: Promise.resolve({ ok: false, events: [], total: 0 })
let mergedLocal: Event[] = []
let localError: string | undefined
try {
mergedLocal = await localPromise
if (myRun !== runGeneration.current) return
const localVisible = mergedLocal
.filter((e) => mergedSearchNoteHasPreviewBody(e))
.sort((a, b) => compareMergedGeneralSearchHits(q, { event: a }, { event: b }))
.slice(0, LOCAL_SEARCH_MAX_EVENTS)
.sort((a, b) => compareMergedGeneralSearchHits(q, { event: a, fromLocalArchive: true }, { event: b, fromLocalArchive: true }))
setLocalRow({
phase: 'done',
hitCount: visible.length,
hitCount: localVisible.length,
rawCount: mergedLocal.length,
ms: Math.round(performance.now() - t0)
ms: Math.round(performance.now() - t0Local)
})
setHits(visible.map((event) => ({ event })))
if (visible.length > 0) {
void addSearchEventsToSessionCacheBatched(visible, runGeneration, myRun)
let archivesEvents: Event[] = []
if (archivesAvailable) {
const archivesRes = await archivesPromise
if (myRun !== runGeneration.current) return
if (archivesRes.ok) {
archivesEvents = archivesRes.events
setArchivesRow({
phase: 'done',
hitCount: archivesEvents.length,
rawCount: archivesRes.total,
ms: Math.round(performance.now() - t0Archives)
})
} else {
setArchivesRow({
phase: 'error',
hitCount: 0,
rawCount: 0,
ms: Math.round(performance.now() - t0Archives),
errorMessage: t('Full-text search archives unavailable')
})
}
}
const byId = new Map<string, LocalHit>()
for (const event of localVisible) {
byId.set(event.id, { event, source: 'local' })
}
for (const event of archivesEvents) {
if (!byId.has(event.id)) {
byId.set(event.id, { event, source: 'archives' })
}
}
const mergedHits = [...byId.values()]
.sort((a, b) =>
compareMergedGeneralSearchHits(q, {
event: a.event,
fromLocalArchive: a.source === 'local'
}, {
event: b.event,
fromLocalArchive: b.source === 'local'
})
)
.slice(0, LOCAL_SEARCH_MAX_EVENTS)
setHits(mergedHits)
if (mergedHits.length > 0) {
void addSearchEventsToSessionCacheBatched(
mergedHits.map((h) => h.event),
runGeneration,
myRun
)
}
} catch (err) {
if (myRun !== runGeneration.current) return
localError = err instanceof Error ? err.message : String(err)
setLocalRow({
phase: 'error',
hitCount: 0,
rawCount: 0,
ms: Math.round(performance.now() - t0),
errorMessage: err instanceof Error ? err.message : String(err)
ms: Math.round(performance.now() - t0Local),
errorMessage: localError
})
if (archivesAvailable) {
try {
const archivesRes = await archivesPromise
if (myRun !== runGeneration.current) return
if (archivesRes.ok && archivesRes.events.length > 0) {
const mergedHits = archivesRes.events
.slice(0, LOCAL_SEARCH_MAX_EVENTS)
.map((event) => ({ event, source: 'archives' as const }))
setArchivesRow({
phase: 'done',
hitCount: mergedHits.length,
rawCount: archivesRes.total,
ms: Math.round(performance.now() - t0Archives)
})
setHits(mergedHits)
void addSearchEventsToSessionCacheBatched(
mergedHits.map((h) => h.event),
runGeneration,
myRun
)
} else {
setArchivesRow({
phase: 'error',
hitCount: 0,
rawCount: 0,
ms: Math.round(performance.now() - t0Archives),
errorMessage: t('Full-text search archives unavailable')
})
}
} catch {
setArchivesRow({
phase: 'error',
hitCount: 0,
rawCount: 0,
ms: Math.round(performance.now() - t0Archives),
errorMessage: t('Full-text search archives unavailable')
})
}
}
}
})()
return () => {
runGeneration.current += 1
}
}, [q, kinds])
}, [q, kinds, archivesAvailable])
if (!q) return null
const loading = localRow?.phase === 'loading'
const done = localRow != null && localRow.phase !== 'loading'
const loading =
localRow?.phase === 'loading' || (archivesAvailable && archivesRow?.phase === 'loading')
const done =
localRow != null &&
localRow.phase !== 'loading' &&
(!archivesAvailable || (archivesRow != null && archivesRow.phase !== 'loading'))
return (
<div className="min-w-0 space-y-3" aria-busy={loading}>
@ -319,6 +431,34 @@ export default function FullTextSearchByRelay({ @@ -319,6 +431,34 @@ export default function FullTextSearchByRelay({
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
{archivesRow ? (
<li className="flex min-w-0 items-center gap-2 px-2.5 py-2">
<Archive className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 shrink-0 font-medium text-foreground">
{t('Full-text search source archives')}
</span>
<span
className={cn(
'ml-auto min-w-0 text-right',
archivesRow.phase === 'error'
? 'text-destructive'
: archivesRow.phase === 'loading'
? 'text-muted-foreground'
: archivesRow.hitCount > 0
? 'text-foreground'
: 'text-muted-foreground'
)}
>
{formatLocalStatusLabel(archivesRow, t)}
{archivesRow.ms != null && archivesRow.phase !== 'loading' ? (
<span className="text-muted-foreground"> · {archivesRow.ms} ms</span>
) : null}
</span>
{archivesRow.phase === 'loading' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</li>
) : null}
</ul>
</section>
) : null}
@ -340,16 +480,26 @@ export default function FullTextSearchByRelay({ @@ -340,16 +480,26 @@ export default function FullTextSearchByRelay({
>
<div
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
aria-label={t('Full-text search local archive description')}
aria-label={
hit.source === 'archives'
? t('Full-text search archives description')
: t('Full-text search local archive description')
}
>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0">
{t('Full-text search seen on label')}
</span>
<span
className="inline-flex shrink-0 rounded-sm border border-border/50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90"
title={t('Full-text search local archive description')}
title={
hit.source === 'archives'
? t('Full-text search archives description')
: t('Full-text search local archive description')
}
>
{t('Full-text search local archive badge')}
{hit.source === 'archives'
? t('Full-text search archives badge')
: t('Full-text search local archive badge')}
</span>
</div>
<NoteCard

1
src/contexts/primary-note-view-context.tsx

@ -8,6 +8,7 @@ export type TPrimaryOverlayViewType = @@ -8,6 +8,7 @@ export type TPrimaryOverlayViewType =
| 'hashtag'
| 'relay'
| 'following'
| 'followers'
| 'mute'
| 'bookmarks'
| 'pins'

16
src/hooks/useFetchEvent.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { getNoteBech32Id } from '@/lib/event'
import { resolveThreadContextEventFromLocalStores } from '@/lib/thread-context-local'
import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress'
import { eventService } from '@/services/client.service'
@ -84,6 +85,21 @@ export function useFetchEvent( @@ -84,6 +85,21 @@ export function useFetchEvent(
const fetchEvent = async () => {
try {
if (!skipShortcuts) {
const fromLocal = await resolveThreadContextEventFromLocalStores(
eventId,
initialMatches ? initialEvent : undefined
)
if (cancelled) return
if (fromLocal && !isEventDeleted(fromLocal)) {
setEvent(fromLocal)
addReplies([fromLocal])
setIsFetching(false)
return
}
// Archives REST is already tried inside resolveThreadContextEventFromLocalStores.
}
// First load: DataLoader dedupes. Refetches (incl. session-waiter) clear a prior undefined so
// timeline-cached events resolve after the embed mounted first.
const opts = fetchOpts?.relayHints?.length ? fetchOpts : undefined

40
src/hooks/useNostrArchivesSocial.ts

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
import { useNostrArchivesAvailable } from '@/hooks/useNostrArchivesAvailable'
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import { useEffect, useState } from 'react'
export function useNostrArchivesSocial(pubkey?: string | null, refreshNonce = 0) {
const archivesAvailable = useNostrArchivesAvailable()
const [followersCount, setFollowersCount] = useState<number | null>(null)
const [isFetching, setIsFetching] = useState(false)
useEffect(() => {
let cancelled = false
if (!pubkey?.trim() || !archivesAvailable) {
setFollowersCount(null)
setIsFetching(false)
return
}
setIsFetching(true)
void nostrArchivesApi
.getSocialGraph(pubkey, { followsLimit: 0, followersLimit: 0 })
.then((res) => {
if (cancelled) return
setFollowersCount(res.ok ? res.data.followers.count : null)
})
.finally(() => {
if (!cancelled) setIsFetching(false)
})
return () => {
cancelled = true
}
}, [pubkey, refreshNonce, archivesAvailable])
return {
followersCount,
isFetching,
showFollowers: archivesAvailable && followersCount != null
}
}

12
src/i18n/locales/de.ts

@ -27,6 +27,7 @@ export default { @@ -27,6 +27,7 @@ export default {
Profile: 'Profil',
Logout: 'Abmelden',
Following: 'Folgende',
Followers: 'Follower',
followings: 'Folgekonten',
boosted: 'geboostet',
'Boosted by:': 'Geboostet von:',
@ -108,6 +109,13 @@ export default { @@ -108,6 +109,13 @@ export default {
Note: 'Notiz',
note: 'notiz',
"username's following": '{{username}}s Folgen',
"username's followers": '{{username}}s Follower',
'Nostr Archives followers hint': 'Followerzahl und Liste von Nostr Archives (Indexer).',
'Use Nostr Archives API': 'Nostr Archives API nutzen',
'Use Nostr Archives API hint':
'Indexer für Suche, Follower, Notiz-Statistiken und schnellere Notiz-Ladung. Aus = nur Relays.',
'Followers list unavailable': 'Followerliste ist gerade nicht verfügbar.',
'No followers found': 'Keine Follower gefunden.',
"username's used relays": '{{username}}s verwendete Relays',
"username's muted": '{{username}}s stummgeschaltet',
Login: 'Anmelden',
@ -2445,6 +2453,10 @@ export default { @@ -2445,6 +2453,10 @@ export default {
'Full-text search all relays finished': 'All relay queries have finished.',
'Full-text search sources progress': 'Suchquellen',
'Full-text search source local': 'Dieses Gerät',
'Full-text search source archives': 'Nostr Archives',
'Full-text search archives unavailable': 'Nicht verfügbar',
'Full-text search archives badge': 'Archives',
'Full-text search archives description': 'Vom Nostr-Archives-Indexer',
'Full-text search source loading': 'Suche läuft…',
'Full-text search source zero hits': '0 Treffer',
'Full-text search source zero with note': '0 Treffer · {{note}}',

12
src/i18n/locales/en.ts

@ -27,6 +27,7 @@ export default { @@ -27,6 +27,7 @@ export default {
Profile: 'Profile',
Logout: 'Logout',
Following: 'Following',
Followers: 'Followers',
followings: 'followings',
boosted: 'boosted',
'Boosted by:': 'Boosted by:',
@ -107,6 +108,13 @@ export default { @@ -107,6 +108,13 @@ export default {
Note: 'Note',
note: 'note',
"username's following": "{{username}}'s following",
"username's followers": "{{username}}'s followers",
'Nostr Archives followers hint': 'Follower count and list from Nostr Archives (indexer).',
'Use Nostr Archives API': 'Use Nostr Archives API',
'Use Nostr Archives API hint':
'Indexer for search, followers, note stats, and faster note loads. Turn off to use relays only.',
'Followers list unavailable': 'Followers list is unavailable right now.',
'No followers found': 'No followers found.',
"username's used relays": "{{username}}'s used relays",
"username's muted": "{{username}}'s muted",
Login: 'Login',
@ -2458,6 +2466,10 @@ export default { @@ -2458,6 +2466,10 @@ export default {
'Full-text search all relays finished': 'All relay queries have finished.',
'Full-text search sources progress': 'Search sources',
'Full-text search source local': 'This device',
'Full-text search source archives': 'Nostr Archives',
'Full-text search archives unavailable': 'Unavailable',
'Full-text search archives badge': 'Archives',
'Full-text search archives description': 'From Nostr Archives indexer',
'Full-text search source loading': 'Searching…',
'Full-text search source zero hits': '0 hits',
'Full-text search source zero with note': '0 hits · {{note}}',

38
src/lib/archives-profile-metadata.ts

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import type { TProfile } from '@/types'
import type { TArchivesProfileMetadata } from '@/types/nostr-archives'
/** Map Nostr Archives profile metadata to {@link TProfile} for search / list UI. */
export function archivesMetadataToProfile(meta: TArchivesProfileMetadata): TProfile | null {
const pk = meta.pubkey?.trim().toLowerCase()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null
const npub = pubkeyToNpub(pk) ?? ''
const username =
meta.display_name?.trim() ||
meta.preferred_name?.trim() ||
meta.name?.trim() ||
formatPubkey(pk)
return {
pubkey: pk,
npub,
username,
avatar: meta.picture?.trim() || undefined,
about: meta.about?.trim() || undefined,
nip05: meta.nip05?.trim() || undefined,
lud16: meta.lud16?.trim() || undefined
}
}
export function archivesMetadataListToProfiles(metas: readonly TArchivesProfileMetadata[]): TProfile[] {
const out: TProfile[] = []
const seen = new Set<string>()
for (const meta of metas) {
const p = archivesMetadataToProfile(meta)
if (!p || seen.has(p.pubkey)) continue
seen.add(p.pubkey)
out.push(p)
}
return out
}

4
src/lib/document-meta.ts

@ -211,6 +211,10 @@ export function resolveImwaldRouteSocialCopy( @@ -211,6 +211,10 @@ export function resolveImwaldRouteSocialCopy(
pageTitle = `Following · ${SITE_NAME}`
ogTitle = `Following list · ${SITE_NAME}`
description = `Following list on ${SITE_NAME}.`
} else if (path.match(/^\/users\/[^/]+\/followers$/)) {
pageTitle = `Followers · ${SITE_NAME}`
ogTitle = `Followers list · ${SITE_NAME}`
description = `Followers list on ${SITE_NAME}.`
} else if (path.match(/^\/users\/[^/]+\/relays$/)) {
pageTitle = `Relays · ${SITE_NAME}`
ogTitle = `User relays · ${SITE_NAME}`

4
src/lib/link.ts

@ -105,6 +105,10 @@ export const toFollowingList = (pubkey: string) => { @@ -105,6 +105,10 @@ export const toFollowingList = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
return `/users/${npub}/following`
}
export const toFollowersList = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
return `/users/${npub}/followers`
}
export const toOthersRelaySettings = (pubkey: string) => {
const npub = nip19.npubEncode(pubkey)
return `/users/${npub}/relays`

69
src/lib/nostr-archives-event.test.ts

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import {
archivesJsonToVerifiedEvent,
isPersistableNostrEventShape,
stripArchivesEngagementFields
} from '@/lib/nostr-archives-event'
import type { Event } from 'nostr-tools'
describe('stripArchivesEngagementFields', () => {
it('removes engagement fields but keeps nested event wrapper', () => {
const inner = {
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
kind: 1,
content: 'hi',
created_at: 1,
tags: [],
sig: 'c'.repeat(128)
}
const raw = {
event: inner,
reactions: 5,
replies: 2,
reposts: 1,
zap_sats: 100
}
const stripped = stripArchivesEngagementFields(raw)
expect(stripped.event).toEqual(inner)
expect(stripped).not.toHaveProperty('reactions')
expect(stripped).not.toHaveProperty('replies')
expect(stripped).not.toHaveProperty('reposts')
expect(stripped).not.toHaveProperty('zap_sats')
})
})
describe('isPersistableNostrEventShape', () => {
const base = {
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
kind: 1,
content: 'hi',
created_at: 1700000000,
tags: [] as string[][],
sig: 'c'.repeat(128)
} as Event
it('rejects NaN kind', () => {
expect(isPersistableNostrEventShape({ ...base, kind: NaN })).toBe(false)
})
it('rejects NaN created_at', () => {
expect(isPersistableNostrEventShape({ ...base, created_at: NaN })).toBe(false)
})
})
describe('archivesJsonToVerifiedEvent', () => {
it('returns null when kind is missing and coerces to NaN', () => {
expect(
archivesJsonToVerifiedEvent({
id: 'a'.repeat(64),
pubkey: 'b'.repeat(64),
content: 'hi',
created_at: 1700000000,
tags: [],
sig: 'c'.repeat(128)
})
).toBeNull()
})
})

6
src/lib/nostr-archives-event.ts

@ -2,6 +2,7 @@ import logger from '@/lib/logger' @@ -2,6 +2,7 @@ import logger from '@/lib/logger'
import type { Event } from 'nostr-tools'
import { validateEvent, verifyEvent } from 'nostr-tools'
/** Engagement / aggregate fields on API rows — not Nostr event fields. */
const ARCHIVES_ENGAGEMENT_KEYS = new Set([
'reactions',
'replies',
@ -10,8 +11,8 @@ const ARCHIVES_ENGAGEMENT_KEYS = new Set([ @@ -10,8 +11,8 @@ const ARCHIVES_ENGAGEMENT_KEYS = new Set([
'count',
'total_sats',
'amount_sats',
'event',
'profiles'
// `event` is a structural wrapper ({ event, reactions, … }), not enrichment — do not strip.
])
/** Strip Archives enrichment fields before treating a row as a Nostr event. */
@ -26,7 +27,7 @@ export function stripArchivesEngagementFields(raw: Record<string, unknown>): Rec @@ -26,7 +27,7 @@ export function stripArchivesEngagementFields(raw: Record<string, unknown>): Rec
export function isPersistableNostrEventShape(ev: Event): boolean {
if (!/^[0-9a-f]{64}$/i.test(ev.id)) return false
if (!/^[0-9a-f]{64}$/i.test(ev.pubkey)) return false
if (typeof ev.kind !== 'number' || !Number.isFinite(ev.created_at)) return false
if (!Number.isFinite(ev.kind) || !Number.isFinite(ev.created_at)) return false
if (typeof ev.content !== 'string') return false
if (typeof ev.sig !== 'string' || ev.sig.length < 64) return false
if (!Array.isArray(ev.tags)) return false
@ -47,6 +48,7 @@ export function archivesJsonToVerifiedEvent(raw: unknown): Event | null { @@ -47,6 +48,7 @@ export function archivesJsonToVerifiedEvent(raw: unknown): Event | null {
const pubkey = typeof base.pubkey === 'string' ? base.pubkey.toLowerCase() : ''
const kind = typeof base.kind === 'number' ? base.kind : Number(base.kind)
const created_at = typeof base.created_at === 'number' ? base.created_at : Number(base.created_at)
if (!Number.isFinite(kind) || !Number.isFinite(created_at)) return null
const content = typeof base.content === 'string' ? base.content : ''
const sig = typeof base.sig === 'string' ? base.sig : ''
const tags = Array.isArray(base.tags) ? (base.tags as Event['tags']) : []

26
src/lib/nostr-archives-search-resolved.test.ts

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { archivesSearchResolvedToParams } from './nostr-archives-search-resolved'
import { nip19 } from 'nostr-tools'
const TEST_HEX = '3bf0d63a9344db8bd60b9072ff3e50b9a6662a93f2a4b9a86dd19e4559c94512'
describe('archivesSearchResolvedToParams', () => {
it('maps profile type + hex pubkey', () => {
const params = archivesSearchResolvedToParams({ type: 'profile', pubkey: TEST_HEX })
expect(params?.type).toBe('profile')
expect(params?.search).toBe(nip19.npubEncode(TEST_HEX))
})
it('maps note type + hex id', () => {
const params = archivesSearchResolvedToParams({ type: 'note', id: TEST_HEX })
expect(params?.type).toBe('note')
expect(params?.search).toBe(TEST_HEX)
})
it('maps bech32 string resolved payload', () => {
const npub = nip19.npubEncode(TEST_HEX)
const params = archivesSearchResolvedToParams(npub)
expect(params?.type).toBe('profile')
expect(params?.search).toBe(npub)
})
})

137
src/lib/nostr-archives-search-resolved.ts

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import type { TSearchParams } from '@/types'
import { nip19 } from 'nostr-tools'
function decodeNip19Id(raw: string): TSearchParams | null {
let id = raw.trim()
if (!id) return null
if (id.startsWith('nostr:')) id = id.slice(6)
try {
const { type } = nip19.decode(id)
if (type === 'npub' || type === 'nprofile') {
return { type: 'profile', search: id }
}
if (type === 'nevent' || type === 'naddr' || type === 'note') {
return { type: 'note', search: id }
}
} catch {
// not bech32
}
return null
}
/** Map Archives `GET /v1/search` `resolved` payload to in-app search navigation. */
export function archivesSearchResolvedToParams(
resolved: unknown,
fallbackQuery?: string
): TSearchParams | null {
if (resolved == null) return null
if (typeof resolved === 'string') {
const trimmed = resolved.trim()
const fromBech32 = decodeNip19Id(trimmed)
if (fromBech32) return fromBech32
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { type: 'note', search: trimmed.toLowerCase() }
}
return null
}
if (typeof resolved !== 'object') return null
const o = resolved as Record<string, unknown>
const typeRaw = String(o.type ?? o.kind ?? o.entity_type ?? '').toLowerCase()
const idRaw = String(
o.id ?? o.identifier ?? o.ref ?? o.bech32 ?? o.npub ?? o.note_id ?? o.event_id ?? ''
).trim()
if (typeRaw === 'profile' || typeRaw === 'npub' || typeRaw === 'nprofile' || typeRaw === '0') {
const pubkey = String(o.pubkey ?? o.author ?? idRaw).trim()
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
try {
return { type: 'profile', search: nip19.npubEncode(pubkey) }
} catch {
return { type: 'profile', search: pubkey.toLowerCase() }
}
}
const fromBech32 = decodeNip19Id(idRaw || pubkey)
if (fromBech32?.type === 'profile') return fromBech32
}
if (
typeRaw === 'note' ||
typeRaw === 'event' ||
typeRaw === 'nevent' ||
typeRaw === 'naddr' ||
typeRaw === 'note1' ||
typeRaw === '1'
) {
if (/^[0-9a-f]{64}$/i.test(idRaw)) {
return { type: 'note', search: idRaw.toLowerCase() }
}
const fromBech32 = decodeNip19Id(idRaw)
if (fromBech32?.type === 'note') return fromBech32
}
const embedded = o.event
if (embedded && typeof embedded === 'object') {
const ev = embedded as Record<string, unknown>
const evId = String(ev.id ?? '').trim()
if (/^[0-9a-f]{64}$/i.test(evId)) {
return { type: 'note', search: evId.toLowerCase() }
}
}
const pubkeyOnly = String(o.pubkey ?? '').trim()
if (/^[0-9a-f]{64}$/i.test(pubkeyOnly)) {
try {
return { type: 'profile', search: nip19.npubEncode(pubkeyOnly) }
} catch {
return { type: 'profile', search: pubkeyOnly.toLowerCase() }
}
}
if (idRaw) {
const fromBech32 = decodeNip19Id(idRaw)
if (fromBech32) return fromBech32
if (/^[0-9a-f]{64}$/i.test(idRaw)) {
return { type: 'note', search: idRaw.toLowerCase() }
}
}
if (fallbackQuery) {
const q = fallbackQuery.trim()
const fromBech32 = decodeNip19Id(q)
if (fromBech32) return fromBech32
if (/^[0-9a-f]{64}$/i.test(q)) {
return { type: 'note', search: q.toLowerCase() }
}
}
return null
}
/** When Archives resolves a Nostr entity, returns direct navigation params (or null). */
export async function tryResolveSearchViaArchives(query: string): Promise<TSearchParams | null> {
const q = query.trim()
if (!q || !nostrArchivesApi.isAvailable()) return null
const res = await nostrArchivesApi.searchGeneral({ q, type: 'all', limit: 1 })
if (!res.ok || res.data.resolved == null) return null
return archivesSearchResolvedToParams(res.data.resolved, q)
}
export function isAmbiguousHexSearchQuery(query: string): boolean {
return /^[0-9a-f]{64}$/i.test(query.trim())
}
/** Prefer Archives resolution for ambiguous 64-char hex before defaulting to note/profile rows. */
export function pickArchivesResolvedOverHexDefault(
resolved: TSearchParams,
userChoice: TSearchParams
): TSearchParams {
if (!isAmbiguousHexSearchQuery(userChoice.search)) return userChoice
if (resolved.type === 'profile' || resolved.type === 'note') return resolved
return userChoice
}

37
src/lib/nostr-archives-search.ts

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
import { mergedSearchNoteHasPreviewBody } from '@/lib/merged-search-note-preview'
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import type { Event } from 'nostr-tools'
export type TArchivesNotesSearchResult = {
ok: boolean
events: Event[]
total: number
}
/**
* Notes search via Nostr Archives REST (`GET /v1/notes/search`).
* Verified events are persisted by the API client; returns empty when API is unavailable.
*/
export async function searchArchivesNotesForGeneralSearch(params: {
query: string
kinds: readonly number[]
limit?: number
}): Promise<TArchivesNotesSearchResult> {
const q = params.query.trim()
if (!q || !nostrArchivesApi.isAvailable()) {
return { ok: false, events: [], total: 0 }
}
const kindSet = new Set(params.kinds)
if (kindSet.size === 0) return { ok: false, events: [], total: 0 }
const res = await nostrArchivesApi.searchNotes({
q,
limit: Math.min(100, params.limit ?? 100),
order: 'newest'
})
if (!res.ok) return { ok: false, events: [], total: 0 }
const events = res.data.notes.filter((ev) => kindSet.has(ev.kind) && mergedSearchNoteHasPreviewBody(ev))
return { ok: true, events, total: res.data.total }
}

69
src/lib/note-page-load-pipeline.ts

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
import { archivesMetadataListToProfiles } from '@/lib/archives-profile-metadata'
import client from '@/services/client.service'
import { candidateKeysForNoteUrlId } from '@/services/navigation-event-store'
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import noteStatsService from '@/services/note-stats.service'
import type { TArchivesNotePageBundle } from '@/types/nostr-archives'
import type { TProfile } from '@/types'
import type { Event } from 'nostr-tools'
function resolveEventPointerToHex(eventId: string): string | undefined {
const trimmed = eventId.trim()
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase()
for (const key of candidateKeysForNoteUrlId(trimmed)) {
if (/^[0-9a-f]{64}$/i.test(key)) return key.toLowerCase()
}
return undefined
}
/** Single verified event from Archives `GET /v1/events/{id}` (persisted by API client). */
export async function resolveNoteEventFromArchives(eventId: string): Promise<Event | undefined> {
if (!nostrArchivesApi.isAvailable()) return undefined
const hex = resolveEventPointerToHex(eventId)
if (!hex) return undefined
const res = await nostrArchivesApi.getEventById(hex)
if (!res.ok) return undefined
client.addEventToCache(res.data, { explicitNoteLookupHexId: hex })
return res.data
}
/** Full note page bundle: main event, replies, profile map, interaction counts. */
export async function fetchArchivesNotePageBundle(
eventId: string,
limit = 50
): Promise<TArchivesNotePageBundle | undefined> {
if (!nostrArchivesApi.isAvailable()) return undefined
const hex = resolveEventPointerToHex(eventId)
if (!hex) return undefined
const res = await nostrArchivesApi.getNotePage(hex, limit)
if (!res.ok) return undefined
noteStatsService.applyArchivesInteractionCounts(hex, res.data.interactions)
client.addEventToCache(res.data.event, { explicitNoteLookupHexId: hex })
for (const reply of res.data.replies) {
client.addEventToCache(reply)
}
return res.data
}
/** Profiles from a note page bundle (`GET /v1/pages/note/{id}` `profiles` map). */
export function profilesFromArchivesNotePageBundle(bundle: TArchivesNotePageBundle): TProfile[] {
return archivesMetadataListToProfiles(Object.values(bundle.profiles))
}
/** Fire-and-forget: hydrate replies + interaction counts while the note panel opens. */
export function prewarmArchivesNotePage(
eventId: string,
limit = 50,
onBundle?: (bundle: TArchivesNotePageBundle) => void
): void {
void fetchArchivesNotePageBundle(eventId, limit)
.then((bundle) => {
if (bundle) onBundle?.(bundle)
})
.catch(() => {})
}

94
src/lib/note-stats-archives-prefetch.ts

@ -0,0 +1,94 @@ @@ -0,0 +1,94 @@
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import noteStatsService from '@/services/note-stats.service'
import type { TArchivesInteractionCounts } from '@/types/nostr-archives'
const BATCH_DELAY_MS = 48
const MAX_BATCH_SIZE = 20
const RECENT_TTL_MS = 5 * 60_000
const pending = new Set<string>()
const inFlight = new Set<string>()
const recentById = new Map<string, number>()
let batchTimer: ReturnType<typeof setTimeout> | null = null
function normalizeHexNoteId(noteId: string): string | null {
const hex = noteId.trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(hex) ? hex : null
}
function markRecent(id: string): void {
recentById.set(id, Date.now())
if (recentById.size > 500) {
const cutoff = Date.now() - RECENT_TTL_MS
for (const [k, t] of recentById) {
if (t < cutoff) recentById.delete(k)
}
}
}
function scheduleBatch(): void {
if (batchTimer != null) return
batchTimer = setTimeout(() => {
batchTimer = null
void flushBatch()
}, BATCH_DELAY_MS)
}
async function flushBatch(): Promise<void> {
if (!nostrArchivesApi.isAvailable()) {
pending.clear()
return
}
const batch: string[] = []
for (const id of pending) {
if (batch.length >= MAX_BATCH_SIZE) break
if (inFlight.has(id)) continue
const recentAt = recentById.get(id)
if (recentAt != null && Date.now() - recentAt < RECENT_TTL_MS) {
pending.delete(id)
continue
}
batch.push(id)
pending.delete(id)
}
for (const id of batch) {
if (!nostrArchivesApi.isAvailable()) break
inFlight.add(id)
try {
const res = await nostrArchivesApi.getEventInteractions(id)
if (res.ok) {
markRecent(id)
noteStatsService.applyArchivesInteractionCounts(id, res.data)
}
} finally {
inFlight.delete(id)
}
}
if (pending.size > 0) scheduleBatch()
}
/** Queue Archives interaction counts for a note (batched; no-op when API unavailable). */
export function queueArchivesInteractionPrefetch(noteId: string): void {
const hex = normalizeHexNoteId(noteId)
if (!hex || !nostrArchivesApi.isAvailable()) return
if (inFlight.has(hex)) return
const recentAt = recentById.get(hex)
if (recentAt != null && Date.now() - recentAt < RECENT_TTL_MS) return
pending.add(hex)
scheduleBatch()
}
export function resetArchivesInteractionPrefetchForTests(): void {
pending.clear()
inFlight.clear()
recentById.clear()
if (batchTimer != null) {
clearTimeout(batchTimer)
batchTimer = null
}
}
export type { TArchivesInteractionCounts }

67
src/lib/profile-metadata-batch.ts

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
import { archivesMetadataToProfile } from '@/lib/archives-profile-metadata'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import {
registerProfileBatchPubkeys,
unregisterProfileBatchPubkeys
} from '@/lib/profile-batch-coordinator'
import client from '@/services/client.service'
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import type { TProfile } from '@/types'
function normalizeHexPubkeys(pubkeys: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of pubkeys) {
const pk = raw.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk) || seen.has(pk)) continue
seen.add(pk)
out.push(pk)
}
return out
}
function placeholderProfile(pubkey: string): TProfile {
return {
pubkey,
npub: pubkeyToNpub(pubkey) ?? '',
username: formatPubkey(pubkey),
batchPlaceholder: true
}
}
/**
* Batch profile hydration: Nostr Archives `POST /v1/profiles/metadata` first, then
* {@link client.fetchProfilesForPubkeys} for pubkeys Archives did not return.
*/
export async function fetchProfilesMetadataBatch(pubkeys: readonly string[]): Promise<TProfile[]> {
const deduped = normalizeHexPubkeys(pubkeys)
if (deduped.length === 0) return []
registerProfileBatchPubkeys(deduped)
try {
const byPk = new Map<string, TProfile>()
if (nostrArchivesApi.isAvailable()) {
const res = await nostrArchivesApi.fetchProfilesMetadata(deduped)
if (res.ok) {
for (const meta of res.data.profiles) {
const profile = archivesMetadataToProfile(meta)
if (profile) byPk.set(profile.pubkey, profile)
}
}
}
const missing = deduped.filter((pk) => !byPk.has(pk))
if (missing.length > 0) {
const relayProfiles = await client.fetchProfilesForPubkeys(missing)
for (const p of relayProfiles) {
const pkNorm = p.pubkey.toLowerCase()
byPk.set(pkNorm, { ...p, pubkey: pkNorm })
}
}
return deduped.map((pk) => byPk.get(pk) ?? placeholderProfile(pk))
} finally {
unregisterProfileBatchPubkeys(deduped)
}
}

6
src/lib/thread-context-local.ts

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
resolveDeclaredThreadRootEventHex
} from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { resolveNoteEventFromArchives } from '@/lib/note-page-load-pipeline'
import client, { eventService } from '@/services/client.service'
import { loadArchivedEventForFetch } from '@/services/event-archive.service'
import { candidateKeysForNoteUrlId } from '@/services/navigation-event-store'
@ -55,6 +56,11 @@ export async function resolveThreadContextEventFromLocalStores( @@ -55,6 +56,11 @@ export async function resolveThreadContextEventFromLocalStores(
return fromArchive
}
const fromArchivesApi = await resolveNoteEventFromArchives(hex)
if (fromArchivesApi && !shouldDropEventOnIngest(fromArchivesApi, { explicitNoteLookupHexId: hex })) {
return fromArchivesApi
}
const fromPublication = await eventService.peekPublicationStoreEvent(hex)
if (fromPublication) return fromPublication

215
src/pages/secondary/FollowersListPage/index.tsx

@ -0,0 +1,215 @@ @@ -0,0 +1,215 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import ProfileList from '@/components/ProfileList'
import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useFetchProfile } from '@/hooks'
import { useNostrArchivesAvailable } from '@/hooks/useNostrArchivesAvailable'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import { Code, MoreVertical } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const FOLLOWERS_PAGE_SIZE = 100
function normalizeFollowerPubkey(pk: string): string | null {
const trimmed = pk.trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : null
}
const FollowersListPage = forwardRef(
({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const archivesAvailable = useNostrArchivesAvailable()
const [listRefreshNonce, setListRefreshNonce] = useState(0)
const { profile } = useFetchProfile(id)
const [followers, setFollowers] = useState<string[]>([])
const [totalCount, setTotalCount] = useState<number | null>(null)
const [hasMore, setHasMore] = useState(false)
const [phase, setPhase] = useState<'idle' | 'loading' | 'ready' | 'unavailable'>('idle')
const [jsonOpen, setJsonOpen] = useState(false)
const [followersJsonPayload, setFollowersJsonPayload] = useState<unknown>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const loadMoreInFlight = useRef(false)
const offsetRef = useRef(0)
const bumpList = useCallback(() => {
offsetRef.current = 0
setListRefreshNonce((n) => n + 1)
}, [])
const openFollowersJson = useCallback(() => {
setFollowersJsonPayload({
pubkey: profile?.pubkey ?? null,
source: 'nostr-archives',
endpoint: '/v1/social/{pubkey}',
followersOffset: offsetRef.current,
followersLimit: FOLLOWERS_PAGE_SIZE,
derivedFollowerPubkeys: followers,
totalCount
})
setJsonOpen(true)
}, [profile?.pubkey, followers, totalCount])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(bumpList)
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpList])
const fetchPage = useCallback(
async (offset: number, append: boolean) => {
const pk = profile?.pubkey
if (!pk || !archivesAvailable) return false
const res = await nostrArchivesApi.getSocialGraph(pk, {
followsLimit: 0,
followersLimit: FOLLOWERS_PAGE_SIZE,
followersOffset: offset
})
if (!res.ok) return false
const batch = res.data.followers.pubkeys
.map(normalizeFollowerPubkey)
.filter((x): x is string => x != null)
setTotalCount(res.data.followers.count)
setFollowers((prev) => {
if (!append) return batch
const seen = new Set(prev)
const out = [...prev]
for (const p of batch) {
if (!seen.has(p)) {
seen.add(p)
out.push(p)
}
}
return out
})
offsetRef.current = offset + batch.length
setHasMore(offsetRef.current < res.data.followers.count && batch.length > 0)
return true
},
[profile?.pubkey, archivesAvailable]
)
useEffect(() => {
let cancelled = false
const pk = profile?.pubkey
if (!pk) {
setFollowers([])
setTotalCount(null)
setHasMore(false)
setPhase('idle')
return
}
if (!archivesAvailable) {
setFollowers([])
setTotalCount(null)
setHasMore(false)
setPhase('unavailable')
return
}
setPhase('loading')
setFollowers([])
offsetRef.current = 0
void fetchPage(0, false).then((ok) => {
if (cancelled) return
setPhase(ok ? 'ready' : 'unavailable')
})
return () => {
cancelled = true
}
}, [profile?.pubkey, listRefreshNonce, archivesAvailable, fetchPage])
useEffect(() => {
const el = bottomRef.current
if (!el || !hasMore || phase !== 'ready') return
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting || loadMoreInFlight.current) return
loadMoreInFlight.current = true
void fetchPage(offsetRef.current, true).finally(() => {
loadMoreInFlight.current = false
})
},
{ root: null, rootMargin: '120px', threshold: 0 }
)
observer.observe(el)
return () => observer.disconnect()
}, [hasMore, phase, fetchPage, followers.length])
const title =
hideTitlebar
? undefined
: profile?.username
? t("username's followers", { username: profile.username })
: t('Followers')
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={title}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={bumpList} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openFollowersJson()}>
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={followersJsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
{phase === 'unavailable' ? (
<p className="text-sm text-muted-foreground">{t('Followers list unavailable')}</p>
) : phase === 'loading' && followers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('loading...')}</p>
) : followers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('No followers found')}</p>
) : (
<>
{totalCount != null ? (
<p className="text-xs text-muted-foreground mb-3">{t('Nostr Archives followers hint')}</p>
) : null}
<ProfileList pubkeys={followers} />
</>
)}
<div ref={bottomRef} className="h-1" />
</SecondaryPageLayout>
)
}
)
FollowersListPage.displayName = 'FollowersListPage'
export default FollowersListPage

18
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -18,6 +18,8 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -18,6 +18,8 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useFontSize } from '@/providers/FontSizeProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import nostrArchivesApi from '@/services/nostr-archives-api.service'
import storage from '@/services/local-storage.service'
import { TMediaAutoLoadPolicy } from '@/types'
import { SelectValue } from '@radix-ui/react-select'
import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
@ -29,6 +31,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -29,6 +31,7 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), [])
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const [useNostrArchivesApi, setUseNostrArchivesApi] = useState(() => storage.getUseNostrArchivesApi())
const { themeSetting, setThemeSetting } = useTheme()
const { fontSize, setFontSize } = useFontSize()
const {
@ -216,6 +219,21 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -216,6 +219,21 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</Label>
<Switch id="show-nsfw" checked={defaultShowNsfw} onCheckedChange={setDefaultShowNsfw} />
</SettingItem>
<SettingItem>
<Label htmlFor="use-nostr-archives-api" className="text-base font-normal">
<div>{t('Use Nostr Archives API')}</div>
<div className="text-muted-foreground">{t('Use Nostr Archives API hint')}</div>
</Label>
<Switch
id="use-nostr-archives-api"
checked={useNostrArchivesApi}
onCheckedChange={(checked) => {
setUseNostrArchivesApi(checked)
storage.setUseNostrArchivesApi(checked)
nostrArchivesApi.notifySettingsChanged()
}}
/>
</SettingItem>
{/* DEPRECATED: Double-panel setting removed for technical debt reduction */}
</div>
</SecondaryPageLayout>

17
src/pages/secondary/NotePage/index.tsx

@ -32,6 +32,11 @@ import { @@ -32,6 +32,11 @@ import {
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import {
prewarmArchivesNotePage,
profilesFromArchivesNotePageBundle
} from '@/lib/note-page-load-pipeline'
import type { TProfile } from '@/types'
import { stripMarkupForPreview } from '@/lib/parent-reply-blurb'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
@ -221,6 +226,16 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -221,6 +226,16 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
// Fetch profile for author (for OpenGraph metadata)
const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey)
const [archivesSeedProfiles, setArchivesSeedProfiles] = useState<TProfile[]>([])
useEffect(() => {
if (!finalEvent?.id) return
setArchivesSeedProfiles([])
prewarmArchivesNotePage(finalEvent.id, 50, (bundle) => {
setArchivesSeedProfiles(profilesFromArchivesNotePageBundle(bundle))
})
}, [finalEvent?.id])
/** Resolve nostr embeds after first paint — avoids competing with thread/profile batch on open. */
useEffect(() => {
if (!finalEvent) return
@ -524,7 +539,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -524,7 +539,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return (
<ThreadReplyProvider threadKey={finalEvent.id}>
<ThreadProfileBatchProvider seedEvents={[finalEvent]}>
<ThreadProfileBatchProvider seedEvents={[finalEvent]} seedProfiles={archivesSeedProfiles}>
<SecondaryPageLayout
ref={ref}
index={index}

64
src/pages/secondary/SearchPage/index.tsx

@ -2,11 +2,17 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -2,11 +2,17 @@ import { RefreshButton } from '@/components/RefreshButton'
import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toSearch } from '@/lib/link'
import { toNote, toNoteList, toSearch } from '@/lib/link'
import {
pickArchivesResolvedOverHexDefault,
tryResolveSearchViaArchives
} from '@/lib/nostr-archives-search-resolved'
import { parseAdvancedSearch } from '@/lib/search-parser'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager'
import { useSecondaryPage, useSmartHashtagNavigation, useSmartNoteNavigation } from '@/PageManager'
import client from '@/services/client.service'
import { eventService } from '@/services/client.service'
import { useNostr } from '@/providers/NostrProvider'
import { BookOpen } from 'lucide-react'
import { TSearchParams } from '@/types'
@ -18,6 +24,8 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -18,6 +24,8 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage()
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { pubkey, relayList } = useNostr()
const [locationRevision, setLocationRevision] = useState(0)
const [resultRefreshKey, setResultRefreshKey] = useState(0)
@ -87,6 +95,34 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -87,6 +95,34 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
}
}, [])
const navigateFromSearchParams = (params: TSearchParams) => {
if (params.type === 'note') {
eventService
.fetchEvent(params.search)
.then((ev) => {
if (!ev) return
const hex = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : undefined
eventService.addEventToCache(ev, hex ? { explicitNoteLookupHexId: hex } : undefined)
})
.catch(() => {})
navigateToNote(toNote(params.search))
return
}
if (params.type === 'hashtag') {
navigateToHashtag(toNoteList({ hashtag: params.search }))
return
}
if (params.type === 'dtag') {
navigateToHashtag(toNoteList({ domain: params.search }))
return
}
if (params.type === 'profile') {
client.fetchProfileEvent(params.search).catch(() => {})
}
push(toSearch(params))
bumpLocationRevision()
}
const onSearch = (params: TSearchParams | null) => {
if (!params) {
push(toSearch())
@ -94,6 +130,27 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -94,6 +130,27 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
setResultRefreshKey((k) => k + 1)
return
}
void (async () => {
const archivesResolved = await tryResolveSearchViaArchives(params.search)
if (archivesResolved) {
const effective = pickArchivesResolvedOverHexDefault(archivesResolved, params)
if (
effective.type === 'note' ||
effective.type === 'hashtag' ||
effective.type === 'dtag' ||
effective.type === 'profile'
) {
navigateFromSearchParams(effective)
return
}
}
runSearchPageRouting(params)
})()
}
const runSearchPageRouting = (params: TSearchParams) => {
// Check if this is a 'notes' search that contains advanced search parameters
if (params.type === 'notes' && params.search) {
const searchParams = parseAdvancedSearch(params.search)
@ -141,8 +198,7 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -141,8 +198,7 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
}
// Default behavior - route to SearchPage
push(toSearch(params))
bumpLocationRevision()
navigateFromSearchParams(params)
}
return (

25
src/providers/ThreadProfileBatchProvider.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants'
import client from '@/services/client.service'
import { fetchProfilesMetadataBatch } from '@/lib/profile-metadata-batch'
import {
collectProfilePubkeysFromEvents,
extendProfileNetworkDeferral
@ -32,9 +32,12 @@ function emptyBatch(): TBatchState { @@ -32,9 +32,12 @@ function emptyBatch(): TBatchState {
*/
export function ThreadProfileBatchProvider({
seedEvents,
seedProfiles = [],
children
}: {
seedEvents: readonly Event[]
/** Pre-hydrated profiles (e.g. from Archives note page bundle) — skip network for these pubkeys. */
seedProfiles?: readonly TProfile[]
children: ReactNode
}) {
const parentNoteFeed = useNoteFeedProfileContext()
@ -64,7 +67,7 @@ export function ThreadProfileBatchProvider({ @@ -64,7 +67,7 @@ export function ThreadProfileBatchProvider({
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 !== genRef.current) return
@ -104,8 +107,12 @@ export function ThreadProfileBatchProvider({ @@ -104,8 +107,12 @@ export function ThreadProfileBatchProvider({
}
const seedKey = useMemo(
() => seedEvents.map((e) => e.id).join('\x1e'),
[seedEvents]
() =>
[
seedEvents.map((e) => e.id).join('\x1e'),
seedProfiles.map((p) => p.pubkey).join('\x1e')
].join('\x1f'),
[seedEvents, seedProfiles]
)
useLayoutEffect(() => {
@ -113,12 +120,20 @@ export function ThreadProfileBatchProvider({ @@ -113,12 +120,20 @@ export function ThreadProfileBatchProvider({
genRef.current += 1
const gen = genRef.current
loadedRef.current.clear()
setBatch(emptyBatch())
const seeded = new Map<string, TProfile>()
for (const p of seedProfiles) {
const pkNorm = p.pubkey.toLowerCase()
seeded.set(pkNorm, { ...p, pubkey: pkNorm })
loadedRef.current.add(pkNorm)
}
setBatch({ profiles: seeded, pending: new Set(), version: 0 })
const candidates = collectProfilePubkeysFromEvents(seedEvents)
const parentProfiles = parentNoteFeed?.profiles
const parentPending = parentNoteFeed?.pendingPubkeys
const need = candidates.filter((pk) => {
if (seeded.has(pk)) return false
if (parentProfiles?.has(pk)) return false
if (parentPending?.has(pk)) return false
return true

2
src/routes.tsx

@ -10,6 +10,7 @@ import { @@ -10,6 +10,7 @@ import {
/** Lazy + Suspense so importing `routes` does not sync-pull pages that depend on PageManager (breaks Vite HMR cycles). */
const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingListPage'))
const FollowersListPageLazy = lazy(() => import('./pages/secondary/FollowersListPage'))
const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage'))
const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage'))
const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage'))
@ -99,6 +100,7 @@ const ROUTES = [ @@ -99,6 +100,7 @@ const ROUTES = [
{ path: '/home/rss-item/:articleKey', element: rssArticlePageElement },
{ path: '/users', element: SR(ProfileListPageLazy) },
{ path: '/users/:id/following', element: SR(FollowingListPageLazy) },
{ path: '/users/:id/followers', element: SR(FollowersListPageLazy) },
{ path: '/users/:id/relays', element: SR(OthersRelaySettingsPageLazy) },
{ path: '/users/:id', element: SR(ProfilePageLazy) },
{ path: '/relays/:url/reviews', element: SR(RelayReviewsPageLazy) },

12
src/services/client.service.ts

@ -35,6 +35,7 @@ import { @@ -35,6 +35,7 @@ import {
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { archivesMetadataListToProfiles } from '@/lib/archives-profile-metadata'
import { createEphemeralSigner } from '@/lib/anon-session'
import { getCacheRelayUrls } from '@/lib/private-relays'
import {
@ -244,6 +245,7 @@ import { preloadGifsIntoIdbCache } from './gif.service' @@ -244,6 +245,7 @@ import { preloadGifsIntoIdbCache } from './gif.service'
import { invalidateArchiveFootprintCache } from './event-archive.service'
import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge'
import nip66Service from './nip66.service'
import nostrArchivesApi from './nostr-archives-api.service'
import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filters'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
import {
@ -4217,6 +4219,16 @@ class ClientService extends EventTarget { @@ -4217,6 +4219,16 @@ class ClientService extends EventTarget {
emit()
if (out.length >= limit) return out.slice(0, limit)
if (q.length >= 2 && nostrArchivesApi.isAvailable()) {
const suggestRes = await nostrArchivesApi.searchSuggest(q, Math.min(limit, 10))
if (!isStale() && suggestRes.ok) {
merge(archivesMetadataListToProfiles(suggestRes.data.suggestions))
}
}
if (isStale()) return out.slice(0, limit)
emit()
if (out.length >= limit) return out.slice(0, limit)
const needAfterLocal = limit - out.length
merge(
await this.searchProfiles(

21
src/services/navigation.service.ts

@ -21,6 +21,7 @@ import PersonalListsSettingsPage from '@/pages/secondary/PersonalListsSettingsPa @@ -21,6 +21,7 @@ import PersonalListsSettingsPage from '@/pages/secondary/PersonalListsSettingsPa
import NotePage from '@/pages/secondary/NotePage'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import FollowingListPage from '@/pages/secondary/FollowingListPage'
import FollowersListPage from '@/pages/secondary/FollowersListPage'
import MuteListPage from '@/pages/secondary/MuteListPage'
import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage'
import SecondaryRelayPage from '@/pages/secondary/RelayPage'
@ -41,6 +42,7 @@ export type ViewType = @@ -41,6 +42,7 @@ export type ViewType =
| 'hashtag'
| 'relay'
| 'following'
| 'followers'
| 'mute'
| 'bookmarks'
| 'pins'
@ -138,6 +140,10 @@ export class ComponentFactory { @@ -138,6 +140,10 @@ export class ComponentFactory {
return React.createElement(FollowingListPage, { id: profileId, index: 0, hideTitlebar: true })
}
static createFollowersListPage(profileId: string): ReactNode {
return React.createElement(FollowersListPage, { id: profileId, index: 0, hideTitlebar: true })
}
static createMuteListPage(_profileId: string): ReactNode {
return React.createElement(MuteListPage, { index: 0, hideTitlebar: true })
}
@ -230,6 +236,15 @@ export class NavigationService { @@ -230,6 +236,15 @@ export class NavigationService {
this.updateHistoryAndView(url, component, 'following')
}
/**
* Navigate to followers list (Nostr Archives)
*/
navigateToFollowersList(url: string): void {
const profileId = URLParser.extractProfileId(url.replace('/followers', ''))
const component = ComponentFactory.createFollowersListPage(profileId)
this.updateHistoryAndView(url, component, 'followers')
}
/**
* Navigate to mute list
*/
@ -278,6 +293,7 @@ export class NavigationService { @@ -278,6 +293,7 @@ export class NavigationService {
}
if (viewType === 'profile') {
if (pathname.includes('/following')) return 'Following'
if (pathname.includes('/followers')) return 'Followers'
if (pathname.includes('/relays')) return 'Relays and Storage Settings'
return 'Profile'
}
@ -294,6 +310,7 @@ export class NavigationService { @@ -294,6 +310,7 @@ export class NavigationService {
return 'Note'
}
if (viewType === 'following') return 'Following'
if (viewType === 'followers') return 'Followers'
if (viewType === 'mute') return 'Muted Users'
if (viewType === 'bookmarks') return 'Bookmarks'
if (viewType === 'notification-thread-follow') return 'Thread notifications (follow)'
@ -353,6 +370,10 @@ export function createNavigationHook(service: NavigationService) { @@ -353,6 +370,10 @@ export function createNavigationHook(service: NavigationService) {
navigateToFollowingList: (url: string) => service.navigateToFollowingList(url)
}),
useSmartFollowersListNavigation: () => ({
navigateToFollowersList: (url: string) => service.navigateToFollowersList(url)
}),
useSmartMuteListNavigation: () => ({
navigateToMuteList: (url: string) => service.navigateToMuteList(url)
}),

5
src/services/nostr-archives-api.service.ts

@ -58,6 +58,11 @@ class NostrArchivesApiService { @@ -58,6 +58,11 @@ class NostrArchivesApiService {
return () => this.availabilityListeners.delete(listener)
}
/** Call after `storage.setUseNostrArchivesApi` so hooks re-render. */
notifySettingsChanged(): void {
this.notifyAvailability()
}
private notifyAvailability(): void {
this.availabilityListeners.forEach((l) => l())
}

36
src/services/note-stats.service.ts

@ -41,6 +41,7 @@ import client, { eventService } from '@/services/client.service' @@ -41,6 +41,7 @@ import client, { eventService } from '@/services/client.service'
import { TEmoji } from '@/types'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
import type { TArchivesInteractionCounts } from '@/types/nostr-archives'
export type TNoteStats = {
likeIdSet: Set<string>
@ -57,9 +58,31 @@ export type TNoteStats = { @@ -57,9 +58,31 @@ export type TNoteStats = {
highlights: { id: string; pubkey: string; created_at: number }[]
/** Pubkeys whose NIP-51 bookmark list includes this note id (`e` tag). */
bookmarkPubkeySet?: Set<string>
/** Aggregate counts from Nostr Archives `/v1/events/{id}/interactions` (floor for display until relay lists catch up). */
archivesInteractions?: TArchivesInteractionCounts
updatedAt?: number
}
export function noteStatsHasResolvableCounts(stats?: Partial<TNoteStats>): boolean {
return stats?.updatedAt != null || stats?.archivesInteractions != null
}
export function displayListCountWithArchives(
listLen: number | undefined,
archives: TArchivesInteractionCounts | undefined,
field: 'reactions' | 'replies' | 'reposts'
): number {
return Math.max(listLen ?? 0, archives?.[field] ?? 0)
}
export function displayZapSatsWithArchives(
zaps: TNoteStats['zaps'] | undefined,
archives: TArchivesInteractionCounts | undefined
): number {
const fromList = zaps?.reduce((acc, zap) => acc + zap.amount, 0) ?? 0
return Math.max(fromList, archives?.zap_sats ?? 0)
}
class NoteStatsService {
static instance: NoteStatsService
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
@ -877,6 +900,19 @@ class NoteStatsService { @@ -877,6 +900,19 @@ class NoteStatsService {
return this.noteStatsMap.get(this.statsKey(id))
}
/** Merge Archives aggregate interaction counts; does not replace relay-derived lists. */
applyArchivesInteractionCounts(noteId: string, counts: TArchivesInteractionCounts): void {
const key = this.statsKey(noteId)
const old = this.noteStatsMap.get(key) ?? {}
this.noteStatsMap.set(key, { ...old, archivesInteractions: counts })
this.notifyNoteStats(key)
}
/** Batched prefetch via {@link queueArchivesInteractionPrefetch} (dynamic import avoids service cycle). */
prefetchArchivesInteractions(noteId: string): void {
void import('@/lib/note-stats-archives-prefetch').then((m) => m.queueArchivesInteractionPrefetch(noteId))
}
/** Same social `kinds` / tag filters as {@link fetchNoteStats} — for thread UI to load counted replies. */
getSocialStatsFiltersForEvent(event: Event): Filter[] {
const replaceableCoordinate = isReplaceableEvent(event.kind)

Loading…
Cancel
Save