From a047a5b8484ff151790173d96bb27d66ed047f30 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 4 Jun 2026 00:03:09 +0200 Subject: [PATCH] more nostr archives --- .cursor/plans/nostr-archives-rollout.md | 165 ++++++++++++++ src/PageManager.tsx | 23 ++ src/components/NoteList/index.tsx | 3 +- src/components/NoteStats/LikeButton.tsx | 12 +- src/components/NoteStats/ReplyButton.tsx | 14 +- src/components/NoteStats/RepostButton.tsx | 14 +- src/components/NoteStats/ZapButton.tsx | 8 +- src/components/NoteStats/index.tsx | 1 + src/components/Profile/SmartFollowers.tsx | 32 +++ src/components/Profile/index.tsx | 2 + src/components/ProfileList/index.tsx | 4 +- src/components/ProfileListBySearch/index.tsx | 32 ++- .../SearchResult/FullTextSearchByRelay.tsx | 208 ++++++++++++++--- src/contexts/primary-note-view-context.tsx | 1 + src/hooks/useFetchEvent.tsx | 16 ++ src/hooks/useNostrArchivesSocial.ts | 40 ++++ src/i18n/locales/de.ts | 12 + src/i18n/locales/en.ts | 12 + src/lib/archives-profile-metadata.ts | 38 ++++ src/lib/document-meta.ts | 4 + src/lib/link.ts | 4 + src/lib/nostr-archives-event.test.ts | 69 ++++++ src/lib/nostr-archives-event.ts | 6 +- .../nostr-archives-search-resolved.test.ts | 26 +++ src/lib/nostr-archives-search-resolved.ts | 137 +++++++++++ src/lib/nostr-archives-search.ts | 37 +++ src/lib/note-page-load-pipeline.ts | 69 ++++++ src/lib/note-stats-archives-prefetch.ts | 94 ++++++++ src/lib/profile-metadata-batch.ts | 67 ++++++ src/lib/thread-context-local.ts | 6 + .../secondary/FollowersListPage/index.tsx | 215 ++++++++++++++++++ .../secondary/GeneralSettingsPage/index.tsx | 18 ++ src/pages/secondary/NotePage/index.tsx | 17 +- src/pages/secondary/SearchPage/index.tsx | 64 +++++- src/providers/ThreadProfileBatchProvider.tsx | 25 +- src/routes.tsx | 2 + src/services/client.service.ts | 12 + src/services/navigation.service.ts | 21 ++ src/services/nostr-archives-api.service.ts | 5 + src/services/note-stats.service.ts | 36 +++ 40 files changed, 1515 insertions(+), 56 deletions(-) create mode 100644 .cursor/plans/nostr-archives-rollout.md create mode 100644 src/components/Profile/SmartFollowers.tsx create mode 100644 src/hooks/useNostrArchivesSocial.ts create mode 100644 src/lib/archives-profile-metadata.ts create mode 100644 src/lib/nostr-archives-event.test.ts create mode 100644 src/lib/nostr-archives-search-resolved.test.ts create mode 100644 src/lib/nostr-archives-search-resolved.ts create mode 100644 src/lib/nostr-archives-search.ts create mode 100644 src/lib/note-page-load-pipeline.ts create mode 100644 src/lib/note-stats-archives-prefetch.ts create mode 100644 src/lib/profile-metadata-batch.ts create mode 100644 src/pages/secondary/FollowersListPage/index.tsx 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>(() => new Map()) const bottomRef = useRef(null) + const profileBatchGenRef = useRef(0) const loadMoreInFlight = useRef(false) const untilRef = useRef(until) untilRef.current = until @@ -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() + 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({ } }} > - + ))} {phase === 'ready' && hasMore && pubkeys.length > 0 && ( diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 3a04ec39..a3996bbf 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -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({ 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({ alexandriaEmptyHref?: string | null }) { const { t } = useTranslation() + const archivesAvailable = useNostrArchivesAvailable() const runGeneration = useRef(0) const [localRow, setLocalRow] = useState(null) + const [archivesRow, setArchivesRow] = useState(null) const [hits, setHits] = useState([]) const q = searchQuery.trim() @@ -223,64 +231,168 @@ 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() + const t0Local = performance.now() + const t0Archives = performance.now() + + const localPromise = collectLocalEventsForTextSearch({ + query: q, + allowedKinds: kindsArr, + sessionCap: 220, + idbMergedLimit: 120, + archiveScanMaxMs: 15_000, + includeOtherStoresFullText: true, + fullTextStoreHitCap: 260 + }) + + 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 { - const mergedLocal = await collectLocalEventsForTextSearch({ - query: q, - allowedKinds: kindsArr, - sessionCap: 220, - idbMergedLimit: 120, - archiveScanMaxMs: 15_000, - includeOtherStoresFullText: true, - fullTextStoreHitCap: 260 - }) + mergedLocal = await localPromise if (myRun !== runGeneration.current) return - - const visible = mergedLocal + 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() + 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 (
@@ -319,6 +431,34 @@ export default function FullTextSearchByRelay({ ) : null} + {archivesRow ? ( +
  • + + + {t('Full-text search source archives')} + + 0 + ? 'text-foreground' + : 'text-muted-foreground' + )} + > + {formatLocalStatusLabel(archivesRow, t)} + {archivesRow.ms != null && archivesRow.phase !== 'loading' ? ( + · {archivesRow.ms} ms + ) : null} + + {archivesRow.phase === 'loading' ? ( + + ) : null} +
  • + ) : null} ) : null} @@ -340,16 +480,26 @@ export default function FullTextSearchByRelay({ >
    {t('Full-text search seen on label')} - {t('Full-text search local archive badge')} + {hit.source === 'archives' + ? t('Full-text search archives badge') + : t('Full-text search local archive badge')}
    { 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 diff --git a/src/hooks/useNostrArchivesSocial.ts b/src/hooks/useNostrArchivesSocial.ts new file mode 100644 index 00000000..a76e74b8 --- /dev/null +++ b/src/hooks/useNostrArchivesSocial.ts @@ -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(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 + } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index e17c68d6..668b38fa 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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 { 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 { '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}}', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 76a7173c..e1a5ca55 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { 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 { '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}}', diff --git a/src/lib/archives-profile-metadata.ts b/src/lib/archives-profile-metadata.ts new file mode 100644 index 00000000..67513b53 --- /dev/null +++ b/src/lib/archives-profile-metadata.ts @@ -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() + 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 +} diff --git a/src/lib/document-meta.ts b/src/lib/document-meta.ts index e9bcdb5f..29677394 100644 --- a/src/lib/document-meta.ts +++ b/src/lib/document-meta.ts @@ -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}` diff --git a/src/lib/link.ts b/src/lib/link.ts index d47cfc2b..ef90227b 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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` diff --git a/src/lib/nostr-archives-event.test.ts b/src/lib/nostr-archives-event.test.ts new file mode 100644 index 00000000..03c96d04 --- /dev/null +++ b/src/lib/nostr-archives-event.test.ts @@ -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() + }) +}) diff --git a/src/lib/nostr-archives-event.ts b/src/lib/nostr-archives-event.ts index 5ddc8720..4ac7e953 100644 --- a/src/lib/nostr-archives-event.ts +++ b/src/lib/nostr-archives-event.ts @@ -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([ '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): 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 { 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']) : [] diff --git a/src/lib/nostr-archives-search-resolved.test.ts b/src/lib/nostr-archives-search-resolved.test.ts new file mode 100644 index 00000000..e85244e5 --- /dev/null +++ b/src/lib/nostr-archives-search-resolved.test.ts @@ -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) + }) +}) diff --git a/src/lib/nostr-archives-search-resolved.ts b/src/lib/nostr-archives-search-resolved.ts new file mode 100644 index 00000000..627f03ef --- /dev/null +++ b/src/lib/nostr-archives-search-resolved.ts @@ -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 + + 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 + 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 { + 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 +} diff --git a/src/lib/nostr-archives-search.ts b/src/lib/nostr-archives-search.ts new file mode 100644 index 00000000..14081e23 --- /dev/null +++ b/src/lib/nostr-archives-search.ts @@ -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 { + 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 } +} diff --git a/src/lib/note-page-load-pipeline.ts b/src/lib/note-page-load-pipeline.ts new file mode 100644 index 00000000..3e6fcd9d --- /dev/null +++ b/src/lib/note-page-load-pipeline.ts @@ -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 { + 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 { + 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(() => {}) +} diff --git a/src/lib/note-stats-archives-prefetch.ts b/src/lib/note-stats-archives-prefetch.ts new file mode 100644 index 00000000..7a10d912 --- /dev/null +++ b/src/lib/note-stats-archives-prefetch.ts @@ -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() +const inFlight = new Set() +const recentById = new Map() +let batchTimer: ReturnType | 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 { + 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 } diff --git a/src/lib/profile-metadata-batch.ts b/src/lib/profile-metadata-batch.ts new file mode 100644 index 00000000..6c0acae0 --- /dev/null +++ b/src/lib/profile-metadata-batch.ts @@ -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() + 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 { + const deduped = normalizeHexPubkeys(pubkeys) + if (deduped.length === 0) return [] + + registerProfileBatchPubkeys(deduped) + try { + const byPk = new Map() + + 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) + } +} diff --git a/src/lib/thread-context-local.ts b/src/lib/thread-context-local.ts index a170fb9f..3a64174d 100644 --- a/src/lib/thread-context-local.ts +++ b/src/lib/thread-context-local.ts @@ -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( 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 diff --git a/src/pages/secondary/FollowersListPage/index.tsx b/src/pages/secondary/FollowersListPage/index.tsx new file mode 100644 index 00000000..b0c69c95 --- /dev/null +++ b/src/pages/secondary/FollowersListPage/index.tsx @@ -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([]) + const [totalCount, setTotalCount] = useState(null) + const [hasMore, setHasMore] = useState(false) + const [phase, setPhase] = useState<'idle' | 'loading' | 'ready' | 'unavailable'>('idle') + const [jsonOpen, setJsonOpen] = useState(false) + const [followersJsonPayload, setFollowersJsonPayload] = useState(null) + const bottomRef = useRef(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 ( + + + + + + + + openFollowersJson()}> + + {t('View JSON')} + + + +
    + ) + } + displayScrollToTopButton + > + setJsonOpen(false)} /> + {phase === 'unavailable' ? ( +

    {t('Followers list unavailable')}

    + ) : phase === 'loading' && followers.length === 0 ? ( +

    {t('loading...')}

    + ) : followers.length === 0 ? ( +

    {t('No followers found')}

    + ) : ( + <> + {totalCount != null ? ( +

    {t('Nostr Archives followers hint')}

    + ) : null} + + + )} +
    + + ) + } +) +FollowersListPage.displayName = 'FollowersListPage' +export default FollowersListPage diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx index 0f6fda64..6aa0fce3 100644 --- a/src/pages/secondary/GeneralSettingsPage/index.tsx +++ b/src/pages/secondary/GeneralSettingsPage/index.tsx @@ -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 const [contentKey, setContentKey] = useState(0) const bump = useCallback(() => setContentKey((k) => k + 1), []) const [language, setLanguage] = useState(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 + + + { + setUseNostrArchivesApi(checked) + storage.setUseNostrArchivesApi(checked) + nostrArchivesApi.notifySettingsChanged() + }} + /> + {/* DEPRECATED: Double-panel setting removed for technical debt reduction */}
    diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 1565cde8..7942b790 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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 }: // Fetch profile for author (for OpenGraph metadata) const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey) + const [archivesSeedProfiles, setArchivesSeedProfiles] = useState([]) + + 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 }: return ( - + { + 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 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 } // Default behavior - route to SearchPage - push(toSearch(params)) - bumpLocationRevision() + navigateFromSearchParams(params) } return ( diff --git a/src/providers/ThreadProfileBatchProvider.tsx b/src/providers/ThreadProfileBatchProvider.tsx index 50a0bf85..25d4c7a0 100644 --- a/src/providers/ThreadProfileBatchProvider.tsx +++ b/src/providers/ThreadProfileBatchProvider.tsx @@ -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 { */ 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({ 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({ } 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({ genRef.current += 1 const gen = genRef.current loadedRef.current.clear() - setBatch(emptyBatch()) + + const seeded = new Map() + 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 diff --git a/src/routes.tsx b/src/routes.tsx index a43b280e..814ea2cb 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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 = [ { 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) }, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 35e72f33..c273a313 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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' 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 { 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( diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index d8f077f0..d909495a 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -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 = | 'hashtag' | 'relay' | 'following' + | 'followers' | 'mute' | 'bookmarks' | 'pins' @@ -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 { 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 { } 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 { 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)' @@ -352,6 +369,10 @@ export function createNavigationHook(service: NavigationService) { useSmartFollowingListNavigation: () => ({ navigateToFollowingList: (url: string) => service.navigateToFollowingList(url) }), + + useSmartFollowersListNavigation: () => ({ + navigateToFollowersList: (url: string) => service.navigateToFollowersList(url) + }), useSmartMuteListNavigation: () => ({ navigateToMuteList: (url: string) => service.navigateToMuteList(url) diff --git a/src/services/nostr-archives-api.service.ts b/src/services/nostr-archives-api.service.ts index 5ea7fa53..a287a5c4 100644 --- a/src/services/nostr-archives-api.service.ts +++ b/src/services/nostr-archives-api.service.ts @@ -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()) } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 26972c48..180347f5 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -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 @@ -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 + /** 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): 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> = new Map() @@ -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)