From 96c6161ec957a1be40cc6a54afb7f6ba34dd3b67 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 3 Jun 2026 23:15:55 +0200 Subject: [PATCH] Implement nostrarchive API --- .cursor/rules/nostr-archives-integration.mdc | 21 ++ src/constants.ts | 12 + src/hooks/index.tsx | 1 + src/hooks/useNostrArchivesAvailable.ts | 11 + src/lib/nostr-archives-event.ts | 71 ++++ src/lib/nostr-archives-ingest.ts | 73 ++++ src/services/local-storage.service.ts | 14 + src/services/nostr-archives-api.service.ts | 369 +++++++++++++++++++ src/types/nostr-archives.ts | 42 +++ 9 files changed, 614 insertions(+) create mode 100644 .cursor/rules/nostr-archives-integration.mdc create mode 100644 src/hooks/useNostrArchivesAvailable.ts create mode 100644 src/lib/nostr-archives-event.ts create mode 100644 src/lib/nostr-archives-ingest.ts create mode 100644 src/services/nostr-archives-api.service.ts create mode 100644 src/types/nostr-archives.ts diff --git a/.cursor/rules/nostr-archives-integration.mdc b/.cursor/rules/nostr-archives-integration.mdc new file mode 100644 index 00000000..d9ab83bc --- /dev/null +++ b/.cursor/rules/nostr-archives-integration.mdc @@ -0,0 +1,21 @@ +# Nostr Archives integration + +When calling `nostrArchivesApi` or adding Archives-only UI: + +## Persist events + +- Any **verified** Nostr event from Archives must go through `persistArchivesEventsIfNew` / `persistArchivesPayloadEvents` (or API methods that call `getAndPersist`). +- That writes to **session cache** (`client.addEventToCache`) and **IndexedDB archive** (`queuePersistSeenEvent` via ingest). +- Skip duplicates: already in session or archive row. +- Unverified / slim API rows (no valid `sig`) are not persisted; use relay fetch as fallback. + +## Graceful failure + +- API methods return `TArchivesApiResult` — **never throw** to UI. +- When `ok: false` or `!nostrArchivesApi.isAvailable()`: hide Archives-only widgets or use existing relay/local paths. +- Circuit breaker opens after 2 failures for 60s; respect `storage.getUseNostrArchivesApi()`. +- Do not block core flows (post, reply, feed) on Archives. + +## Rate limit + +- Use `nostrArchivesApi` service only (100 req/min client budget). Batch metadata and interaction prefetch. diff --git a/src/constants.ts b/src/constants.ts index 7066edad..fd36df61 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -390,6 +390,8 @@ export const StorageKey = { DEFAULT_EXPIRATION_ENABLED: 'defaultExpirationEnabled', DEFAULT_EXPIRATION_MONTHS: 'defaultExpirationMonths', SHOW_RSS_FEED: 'showRssFeed', + /** When not `'false'`, allow Nostr Archives REST for discovery/stats (default on). */ + USE_NOSTR_ARCHIVES_API: 'useNostrArchivesApi', PANE_MODE: 'paneMode', ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', /** @deprecated Removed — personal-relay read policy is always on when logged in. */ @@ -543,7 +545,17 @@ export const GIF_RELAY_URLS = [ 'wss://relay.gifbuddy.lol' ] +/** Nostr Archives NIP-50 search relay — https://nostrarchives.com/docs */ +export const NOSTR_ARCHIVES_SEARCH_RELAY_URL = 'wss://search.nostrarchives.com' + +/** REST API base — https://api.nostrarchives.com (120 req/min per IP). */ +export const NOSTR_ARCHIVES_API_BASE_URL = 'https://api.nostrarchives.com' + +/** Client-side budget below the public 120/min cap. */ +export const NOSTR_ARCHIVES_API_RATE_LIMIT_PER_MIN = 100 + export const SEARCHABLE_RELAY_URLS = [ + NOSTR_ARCHIVES_SEARCH_RELAY_URL, 'wss://search.nos.today', 'wss://nostr.wine', 'wss://relay.noswhere.com', diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index f072b6fe..020fba01 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -10,6 +10,7 @@ export * from './useFetchProfile' export * from './useFetchRelayInfo' export * from './useFetchRelayList' export * from './useSearchProfiles' +export * from './useNostrArchivesAvailable' export * from './useMediaExtraction' export * from './useEmojiInfosForEvent' export * from './useNip84HighlightTargetEvents' diff --git a/src/hooks/useNostrArchivesAvailable.ts b/src/hooks/useNostrArchivesAvailable.ts new file mode 100644 index 00000000..433ce4fd --- /dev/null +++ b/src/hooks/useNostrArchivesAvailable.ts @@ -0,0 +1,11 @@ +import nostrArchivesApi from '@/services/nostr-archives-api.service' +import { useSyncExternalStore } from 'react' + +/** Re-render when Archives API availability may change (settings / circuit breaker). */ +export function useNostrArchivesAvailable(): boolean { + return useSyncExternalStore( + (onStoreChange) => nostrArchivesApi.subscribeAvailability(onStoreChange), + () => nostrArchivesApi.isAvailable(), + () => false + ) +} diff --git a/src/lib/nostr-archives-event.ts b/src/lib/nostr-archives-event.ts new file mode 100644 index 00000000..5ddc8720 --- /dev/null +++ b/src/lib/nostr-archives-event.ts @@ -0,0 +1,71 @@ +import logger from '@/lib/logger' +import type { Event } from 'nostr-tools' +import { validateEvent, verifyEvent } from 'nostr-tools' + +const ARCHIVES_ENGAGEMENT_KEYS = new Set([ + 'reactions', + 'replies', + 'reposts', + 'zap_sats', + 'count', + 'total_sats', + 'amount_sats', + 'event', + 'profiles' +]) + +/** Strip Archives enrichment fields before treating a row as a Nostr event. */ +export function stripArchivesEngagementFields(raw: Record): Record { + const out: Record = {} + for (const [k, v] of Object.entries(raw)) { + if (!ARCHIVES_ENGAGEMENT_KEYS.has(k)) out[k] = v + } + return out +} + +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 (typeof ev.content !== 'string') return false + if (typeof ev.sig !== 'string' || ev.sig.length < 64) return false + if (!Array.isArray(ev.tags)) return false + return true +} + +/** + * Convert an Archives JSON event (full or enriched) into a verified Nostr {@link Event}, or null if invalid. + */ +export function archivesJsonToVerifiedEvent(raw: unknown): Event | null { + if (!raw || typeof raw !== 'object') return null + const stripped = stripArchivesEngagementFields(raw as Record) + const nested = stripped.event + const base = + nested && typeof nested === 'object' ? stripArchivesEngagementFields(nested as Record) : stripped + + const id = typeof base.id === 'string' ? base.id.toLowerCase() : '' + 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) + 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']) : [] + + const candidate = { + id, + pubkey, + kind, + created_at, + content, + sig, + tags + } as Event + + if (!isPersistableNostrEventShape(candidate)) return null + if (!validateEvent(candidate)) return null + if (!verifyEvent(candidate)) { + logger.debug('[nostr-archives] skipped unverified event', { id: id.slice(0, 8) }) + return null + } + return candidate +} diff --git a/src/lib/nostr-archives-ingest.ts b/src/lib/nostr-archives-ingest.ts new file mode 100644 index 00000000..d19d27fe --- /dev/null +++ b/src/lib/nostr-archives-ingest.ts @@ -0,0 +1,73 @@ +import { archivesJsonToVerifiedEvent } from '@/lib/nostr-archives-event' +import logger from '@/lib/logger' +import client from '@/services/client.service' +import { loadArchivedEventForFetch } from '@/services/event-archive.service' + +/** + * Persist verified events from Nostr Archives into the session cache and IndexedDB archive queue. + * Skips rows already in session or on-disk archive (no redundant writes). + */ +export async function persistArchivesEventsIfNew( + rawEvents: readonly unknown[] +): Promise<{ ingested: number; skipped: number }> { + let ingested = 0 + let skipped = 0 + + for (const raw of rawEvents) { + const ev = archivesJsonToVerifiedEvent(raw) + if (!ev) { + skipped += 1 + continue + } + const id = ev.id.toLowerCase() + if (client.peekSessionCachedEvent(id)) { + skipped += 1 + continue + } + const archived = await loadArchivedEventForFetch(id) + if (archived) { + skipped += 1 + client.addEventToCache(archived, { explicitNoteLookupHexId: id }) + continue + } + client.addEventToCache(ev, { explicitNoteLookupHexId: id }) + ingested += 1 + } + + if (ingested > 0) { + logger.debug('[nostr-archives] persisted events to cache/archive', { ingested, skipped }) + } + return { ingested, skipped } +} + +/** Collect event-shaped values from mixed API payloads (lists, note page, thread). */ +export function collectRawEventsFromArchivesPayload(payload: unknown): unknown[] { + if (!payload || typeof payload !== 'object') return [] + const o = payload as Record + const out: unknown[] = [] + + const push = (v: unknown) => { + if (v != null) out.push(v) + } + + push(o.event) + if (Array.isArray(o.events)) o.events.forEach(push) + if (Array.isArray(o.notes)) { + for (const n of o.notes) { + if (n && typeof n === 'object') { + const row = n as Record + push(row.event ?? row) + } + } + } + if (Array.isArray(o.replies)) o.replies.forEach(push) + if (Array.isArray(o.ancestors)) o.ancestors.forEach(push) + + return out +} + +export async function persistArchivesPayloadEvents(payload: unknown): Promise { + const raws = collectRawEventsFromArchivesPayload(payload) + if (raws.length === 0) return + await persistArchivesEventsIfNew(raws) +} diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 6d009eb0..d8051bac 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -80,6 +80,7 @@ const SETTINGS_KEYS = [ StorageKey.DEFAULT_EXPIRATION_ENABLED, StorageKey.DEFAULT_EXPIRATION_MONTHS, StorageKey.SHOW_RSS_FEED, + StorageKey.USE_NOSTR_ARCHIVES_API, StorageKey.PANE_MODE ] as const @@ -118,6 +119,8 @@ class LocalStorageService { private defaultExpirationEnabled: boolean = false private defaultExpirationMonths: number = 6 private showRssFeed: boolean = true + /** Nostr Archives REST (discovery, stats prefetch). Default on; set `'false'` to disable. */ + private useNostrArchivesApi: boolean = true private panelMode: 'single' | 'double' = 'single' private addRandomRelaysToPublish: boolean = true private showPublishSuccessToasts: boolean = false @@ -614,6 +617,8 @@ class LocalStorageService { } const showRssStr = get(StorageKey.SHOW_RSS_FEED) if (showRssStr != null) this.showRssFeed = showRssStr === 'true' + const archivesApiStr = get(StorageKey.USE_NOSTR_ARCHIVES_API) + if (archivesApiStr != null) this.useNostrArchivesApi = archivesApiStr !== 'false' const paneStr = get(StorageKey.PANE_MODE) if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr } @@ -1000,6 +1005,15 @@ class LocalStorageService { this.persistSetting(StorageKey.SHOW_RSS_FEED, show.toString()) } + getUseNostrArchivesApi(): boolean { + return this.useNostrArchivesApi + } + + setUseNostrArchivesApi(enabled: boolean) { + this.useNostrArchivesApi = enabled + this.persistSetting(StorageKey.USE_NOSTR_ARCHIVES_API, enabled ? 'true' : 'false') + } + getShowPublishSuccessToasts(): boolean { return this.showPublishSuccessToasts } diff --git a/src/services/nostr-archives-api.service.ts b/src/services/nostr-archives-api.service.ts new file mode 100644 index 00000000..5ea7fa53 --- /dev/null +++ b/src/services/nostr-archives-api.service.ts @@ -0,0 +1,369 @@ +/** + * Nostr Archives REST client (https://nostrarchives.com/docs). + * + * Cross-cutting rules: + * - Never throw to callers; return {@link TArchivesApiResult} and let UI use relay/local fallbacks. + * - Circuit breaker after repeated failures so offline/API-down does not spam requests. + * - Verified events from responses are written to session cache + IndexedDB via {@link persistArchivesPayloadEvents}. + */ +import { + NOSTR_ARCHIVES_API_BASE_URL, + NOSTR_ARCHIVES_API_RATE_LIMIT_PER_MIN +} from '@/constants' +import { persistArchivesPayloadEvents, persistArchivesEventsIfNew } from '@/lib/nostr-archives-ingest' +import { archivesJsonToVerifiedEvent } from '@/lib/nostr-archives-event' +import logger from '@/lib/logger' +import storage from '@/services/local-storage.service' +import type { + TArchivesApiResult, + TArchivesInteractionCounts, + TArchivesNotePageBundle, + TArchivesProfileMetadata, + TArchivesSocialGraph +} from '@/types/nostr-archives' +import type { Event } from 'nostr-tools' + +const CIRCUIT_FAILURE_THRESHOLD = 2 +const CIRCUIT_COOLDOWN_MS = 60_000 +const REQUEST_TIMEOUT_MS = 12_000 + +class NostrArchivesApiService { + static instance: NostrArchivesApiService + + private requestTimestamps: number[] = [] + private consecutiveFailures = 0 + private circuitOpenUntil = 0 + private availabilityListeners = new Set<() => void>() + + constructor() { + if (!NostrArchivesApiService.instance) { + NostrArchivesApiService.instance = this + } + return NostrArchivesApiService.instance + } + + isEnabled(): boolean { + return storage.getUseNostrArchivesApi() + } + + /** False when disabled, circuit open, or recent failures — callers should hide Archives-only UI. */ + isAvailable(): boolean { + if (!this.isEnabled()) return false + if (Date.now() < this.circuitOpenUntil) return false + return true + } + + subscribeAvailability(listener: () => void): () => void { + this.availabilityListeners.add(listener) + return () => this.availabilityListeners.delete(listener) + } + + private notifyAvailability(): void { + this.availabilityListeners.forEach((l) => l()) + } + + private recordSuccess(): void { + const wasUnavailable = !this.isAvailable() + this.consecutiveFailures = 0 + this.circuitOpenUntil = 0 + if (wasUnavailable) this.notifyAvailability() + } + + private recordFailure(): void { + this.consecutiveFailures += 1 + if (this.consecutiveFailures >= CIRCUIT_FAILURE_THRESHOLD) { + this.circuitOpenUntil = Date.now() + CIRCUIT_COOLDOWN_MS + logger.info('[nostr-archives] API circuit open', { cooldownMs: CIRCUIT_COOLDOWN_MS }) + this.notifyAvailability() + } + } + + private consumeRateLimit(): boolean { + const now = Date.now() + const windowStart = now - 60_000 + this.requestTimestamps = this.requestTimestamps.filter((t) => t >= windowStart) + if (this.requestTimestamps.length >= NOSTR_ARCHIVES_API_RATE_LIMIT_PER_MIN) { + return false + } + this.requestTimestamps.push(now) + return true + } + + private async fetchJson(path: string, init?: RequestInit): Promise> { + if (!this.isEnabled()) { + return { ok: false, reason: 'disabled' } + } + if (Date.now() < this.circuitOpenUntil) { + return { ok: false, reason: 'circuit_open' } + } + if (!this.consumeRateLimit()) { + return { ok: false, reason: 'rate_limited' } + } + + const url = `${NOSTR_ARCHIVES_API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}` + const ac = new AbortController() + const timer = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS) + + try { + const res = await fetch(url, { + ...init, + signal: ac.signal, + headers: { + Accept: 'application/json', + ...(init?.headers ?? {}) + } + }) + clearTimeout(timer) + + if (!res.ok) { + this.recordFailure() + return { ok: false, reason: 'http', status: res.status } + } + + const text = await res.text() + try { + const data = text ? JSON.parse(text) : null + this.recordSuccess() + return { ok: true, data } + } catch { + this.recordFailure() + return { ok: false, reason: 'parse', status: res.status } + } + } catch (err) { + clearTimeout(timer) + this.recordFailure() + const aborted = err instanceof Error && err.name === 'AbortError' + logger.debug('[nostr-archives] fetch failed', { + path, + aborted, + message: err instanceof Error ? err.message : String(err) + }) + return { ok: false, reason: 'network' } + } + } + + private async getAndPersist(path: string): Promise> { + const res = await this.fetchJson(path) + if (res.ok) { + void persistArchivesPayloadEvents(res.data).catch(() => {}) + } + return res + } + + async getEventInteractions(eventId: string): Promise> { + const hex = eventId.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(hex)) { + return { ok: false, reason: 'parse' } + } + const res = await this.fetchJson(`/v1/events/${hex}/interactions`) + if (!res.ok) return res + const d = res.data as Record + return { + ok: true, + data: { + reactions: Number(d.reactions) || 0, + replies: Number(d.replies) || 0, + reposts: Number(d.reposts) || 0, + zap_sats: Number(d.zap_sats) || 0 + } + } + } + + async getSocialGraph( + pubkey: string, + opts?: { followsLimit?: number; followersLimit?: number; followsOffset?: number; followersOffset?: number } + ): Promise> { + const pk = pubkey.trim() + const q = new URLSearchParams() + q.set('follows_limit', String(opts?.followsLimit ?? 0)) + q.set('followers_limit', String(opts?.followersLimit ?? 0)) + q.set('follows_offset', String(opts?.followsOffset ?? 0)) + q.set('followers_offset', String(opts?.followersOffset ?? 0)) + const res = await this.fetchJson(`/v1/social/${encodeURIComponent(pk)}?${q}`) + if (!res.ok) return res + const d = res.data as Record + const mapList = (part: unknown): { count: number; pubkeys: string[] } => { + const p = part as Record | undefined + const pubkeys = Array.isArray(p?.pubkeys) + ? (p.pubkeys as unknown[]).filter((x): x is string => typeof x === 'string') + : [] + return { count: Number(p?.count) || pubkeys.length, pubkeys } + } + return { + ok: true, + data: { + pubkey: typeof d.pubkey === 'string' ? d.pubkey : pk, + follows: mapList(d.follows), + followers: mapList(d.followers) + } + } + } + + async getEventById(eventId: string): Promise> { + const hex = eventId.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(hex)) { + return { ok: false, reason: 'parse' } + } + const res = await this.getAndPersist(`/v1/events/${hex}`) + if (!res.ok) return res + const ev = archivesJsonToVerifiedEvent(res.data) + if (!ev) return { ok: false, reason: 'parse' } + return { ok: true, data: ev } + } + + async getNotePage(eventId: string, limit = 50): Promise> { + const hex = eventId.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(hex)) { + return { ok: false, reason: 'parse' } + } + const res = await this.getAndPersist(`/v1/pages/note/${hex}?limit=${Math.min(200, Math.max(1, limit))}`) + if (!res.ok) return res + const d = res.data as Record + const event = archivesJsonToVerifiedEvent(d.event) + if (!event) return { ok: false, reason: 'parse' } + + const replies: Event[] = [] + if (Array.isArray(d.replies)) { + for (const r of d.replies) { + const ev = archivesJsonToVerifiedEvent(r) + if (ev) replies.push(ev) + } + } + await persistArchivesEventsIfNew([event, ...replies]) + + const interactionsRaw = (d.interactions ?? d) as Record + const profiles = + d.profiles && typeof d.profiles === 'object' + ? (d.profiles as Record) + : {} + + return { + ok: true, + data: { + event, + replies, + profiles, + interactions: { + reactions: Number(interactionsRaw.reactions) || 0, + replies: Number(interactionsRaw.replies) || 0, + reposts: Number(interactionsRaw.reposts) || 0, + zap_sats: Number(interactionsRaw.zap_sats) || 0 + } + } + } + } + + async searchNotes(params: { + q?: string + limit?: number + offset?: number + order?: 'newest' | 'oldest' | 'engagement' + author?: string + }): Promise> { + const q = new URLSearchParams() + if (params.q?.trim()) q.set('q', params.q.trim()) + q.set('limit', String(Math.min(100, Math.max(1, params.limit ?? 20)))) + q.set('offset', String(Math.max(0, params.offset ?? 0))) + if (params.order) q.set('order', params.order) + if (params.author?.trim()) q.set('author', params.author.trim()) + + const res = await this.getAndPersist(`/v1/notes/search?${q}`) + if (!res.ok) return res + const d = res.data as Record + const notes: Event[] = [] + if (Array.isArray(d.notes)) { + for (const n of d.notes) { + const ev = archivesJsonToVerifiedEvent( + n && typeof n === 'object' && 'event' in (n as object) ? (n as { event: unknown }).event : n + ) + if (ev) notes.push(ev) + } + } + return { ok: true, data: { notes, total: Number(d.total) || notes.length } } + } + + async searchGeneral(params: { + q: string + type?: 'all' | 'profiles' | 'notes' + limit?: number + offset?: number + }): Promise< + TArchivesApiResult<{ + profiles: TArchivesProfileMetadata[] + notes: Event[] + resolved?: unknown + }> + > { + const q = new URLSearchParams() + q.set('q', params.q.trim()) + if (params.type) q.set('type', params.type) + q.set('limit', String(Math.min(100, Math.max(1, params.limit ?? 20)))) + q.set('offset', String(Math.max(0, params.offset ?? 0))) + + const res = await this.getAndPersist(`/v1/search?${q}`) + if (!res.ok) return res + const d = res.data as Record + if (d.resolved != null) { + return { ok: true, data: { profiles: [], notes: [], resolved: d.resolved } } + } + + const profiles = Array.isArray(d.profiles) + ? (d.profiles as TArchivesProfileMetadata[]) + : [] + const notes: Event[] = [] + if (Array.isArray(d.notes)) { + for (const n of d.notes) { + const ev = archivesJsonToVerifiedEvent(n) + if (ev) notes.push(ev) + } + } + return { ok: true, data: { profiles, notes } } + } + + async searchSuggest( + q: string, + limit = 5 + ): Promise> { + const trimmed = q.trim() + if (trimmed.length < 2) { + return { ok: true, data: { suggestions: [] } } + } + const res = await this.fetchJson( + `/v1/search/suggest?${new URLSearchParams({ q: trimmed, limit: String(Math.min(10, Math.max(1, limit))) })}` + ) + if (!res.ok) return res + const d = res.data as Record + const suggestions = Array.isArray(d.suggestions) + ? (d.suggestions as TArchivesProfileMetadata[]) + : [] + return { ok: true, data: { suggestions } } + } + + async fetchProfilesMetadata( + pubkeys: readonly string[] + ): Promise> { + const list = [...new Set(pubkeys.map((p) => p.trim().toLowerCase()).filter((p) => /^[0-9a-f]{64}$/.test(p)))] + if (list.length === 0) { + return { ok: true, data: { profiles: [] } } + } + + const merged: TArchivesProfileMetadata[] = [] + for (let i = 0; i < list.length; i += 500) { + const chunk = list.slice(i, i + 500) + const res = await this.fetchJson('/v1/profiles/metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkeys: chunk }) + }) + if (!res.ok) return res + const d = res.data as Record + if (Array.isArray(d.profiles)) { + merged.push(...(d.profiles as TArchivesProfileMetadata[])) + } + } + return { ok: true, data: { profiles: merged } } + } +} + +const nostrArchivesApi = new NostrArchivesApiService() +export default nostrArchivesApi diff --git a/src/types/nostr-archives.ts b/src/types/nostr-archives.ts new file mode 100644 index 00000000..e009885f --- /dev/null +++ b/src/types/nostr-archives.ts @@ -0,0 +1,42 @@ +import type { Event } from 'nostr-tools' + +/** Result of an Archives HTTP call — never throws; UI uses relay/local fallbacks when `ok` is false. */ +export type TArchivesApiResult = + | { ok: true; data: T } + | { + ok: false + reason: 'disabled' | 'circuit_open' | 'rate_limited' | 'network' | 'http' | 'parse' + status?: number + } + +export type TArchivesInteractionCounts = { + reactions: number + replies: number + reposts: number + zap_sats: number +} + +export type TArchivesSocialGraph = { + pubkey: string + follows: { count: number; pubkeys: string[] } + followers: { count: number; pubkeys: string[] } +} + +export type TArchivesProfileMetadata = { + pubkey: string + display_name?: string + name?: string + preferred_name?: string + picture?: string + about?: string + nip05?: string + lud16?: string + follower_count?: number +} + +export type TArchivesNotePageBundle = { + event: Event + replies: Event[] + profiles: Record + interactions: TArchivesInteractionCounts +}