40 changed files with 1515 additions and 56 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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