40 changed files with 1515 additions and 56 deletions
@ -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. |
||||
``` |
||||
@ -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> |
||||
) |
||||
} |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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() |
||||
}) |
||||
}) |
||||
@ -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) |
||||
}) |
||||
}) |
||||
@ -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 |
||||
} |
||||
@ -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 } |
||||
} |
||||
@ -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(() => {}) |
||||
} |
||||
@ -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 } |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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 |
||||
Loading…
Reference in new issue