Browse Source

Implement nostrarchive API

imwald
Silberengel 2 weeks ago
parent
commit
96c6161ec9
  1. 21
      .cursor/rules/nostr-archives-integration.mdc
  2. 12
      src/constants.ts
  3. 1
      src/hooks/index.tsx
  4. 11
      src/hooks/useNostrArchivesAvailable.ts
  5. 71
      src/lib/nostr-archives-event.ts
  6. 73
      src/lib/nostr-archives-ingest.ts
  7. 14
      src/services/local-storage.service.ts
  8. 369
      src/services/nostr-archives-api.service.ts
  9. 42
      src/types/nostr-archives.ts

21
.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.

12
src/constants.ts

@ -390,6 +390,8 @@ export const StorageKey = {
DEFAULT_EXPIRATION_ENABLED: 'defaultExpirationEnabled', DEFAULT_EXPIRATION_ENABLED: 'defaultExpirationEnabled',
DEFAULT_EXPIRATION_MONTHS: 'defaultExpirationMonths', DEFAULT_EXPIRATION_MONTHS: 'defaultExpirationMonths',
SHOW_RSS_FEED: 'showRssFeed', SHOW_RSS_FEED: 'showRssFeed',
/** When not `'false'`, allow Nostr Archives REST for discovery/stats (default on). */
USE_NOSTR_ARCHIVES_API: 'useNostrArchivesApi',
PANE_MODE: 'paneMode', PANE_MODE: 'paneMode',
ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish',
/** @deprecated Removed — personal-relay read policy is always on when logged in. */ /** @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' '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 = [ export const SEARCHABLE_RELAY_URLS = [
NOSTR_ARCHIVES_SEARCH_RELAY_URL,
'wss://search.nos.today', 'wss://search.nos.today',
'wss://nostr.wine', 'wss://nostr.wine',
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',

1
src/hooks/index.tsx

@ -10,6 +10,7 @@ export * from './useFetchProfile'
export * from './useFetchRelayInfo' export * from './useFetchRelayInfo'
export * from './useFetchRelayList' export * from './useFetchRelayList'
export * from './useSearchProfiles' export * from './useSearchProfiles'
export * from './useNostrArchivesAvailable'
export * from './useMediaExtraction' export * from './useMediaExtraction'
export * from './useEmojiInfosForEvent' export * from './useEmojiInfosForEvent'
export * from './useNip84HighlightTargetEvents' export * from './useNip84HighlightTargetEvents'

11
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
)
}

71
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
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<string, unknown>)
const nested = stripped.event
const base =
nested && typeof nested === 'object' ? stripArchivesEngagementFields(nested as Record<string, unknown>) : 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
}

73
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<string, unknown>
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<string, unknown>
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<void> {
const raws = collectRawEventsFromArchivesPayload(payload)
if (raws.length === 0) return
await persistArchivesEventsIfNew(raws)
}

14
src/services/local-storage.service.ts

@ -80,6 +80,7 @@ const SETTINGS_KEYS = [
StorageKey.DEFAULT_EXPIRATION_ENABLED, StorageKey.DEFAULT_EXPIRATION_ENABLED,
StorageKey.DEFAULT_EXPIRATION_MONTHS, StorageKey.DEFAULT_EXPIRATION_MONTHS,
StorageKey.SHOW_RSS_FEED, StorageKey.SHOW_RSS_FEED,
StorageKey.USE_NOSTR_ARCHIVES_API,
StorageKey.PANE_MODE StorageKey.PANE_MODE
] as const ] as const
@ -118,6 +119,8 @@ class LocalStorageService {
private defaultExpirationEnabled: boolean = false private defaultExpirationEnabled: boolean = false
private defaultExpirationMonths: number = 6 private defaultExpirationMonths: number = 6
private showRssFeed: boolean = true 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 panelMode: 'single' | 'double' = 'single'
private addRandomRelaysToPublish: boolean = true private addRandomRelaysToPublish: boolean = true
private showPublishSuccessToasts: boolean = false private showPublishSuccessToasts: boolean = false
@ -614,6 +617,8 @@ class LocalStorageService {
} }
const showRssStr = get(StorageKey.SHOW_RSS_FEED) const showRssStr = get(StorageKey.SHOW_RSS_FEED)
if (showRssStr != null) this.showRssFeed = showRssStr === 'true' 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) const paneStr = get(StorageKey.PANE_MODE)
if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr
} }
@ -1000,6 +1005,15 @@ class LocalStorageService {
this.persistSetting(StorageKey.SHOW_RSS_FEED, show.toString()) 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 { getShowPublishSuccessToasts(): boolean {
return this.showPublishSuccessToasts return this.showPublishSuccessToasts
} }

369
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<TArchivesApiResult<unknown>> {
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<TArchivesApiResult<unknown>> {
const res = await this.fetchJson(path)
if (res.ok) {
void persistArchivesPayloadEvents(res.data).catch(() => {})
}
return res
}
async getEventInteractions(eventId: string): Promise<TArchivesApiResult<TArchivesInteractionCounts>> {
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<string, unknown>
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<TArchivesApiResult<TArchivesSocialGraph>> {
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<string, unknown>
const mapList = (part: unknown): { count: number; pubkeys: string[] } => {
const p = part as Record<string, unknown> | 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<TArchivesApiResult<Event>> {
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<TArchivesApiResult<TArchivesNotePageBundle>> {
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<string, unknown>
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<string, unknown>
const profiles =
d.profiles && typeof d.profiles === 'object'
? (d.profiles as Record<string, TArchivesProfileMetadata>)
: {}
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<TArchivesApiResult<{ notes: Event[]; total: number }>> {
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<string, unknown>
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<string, unknown>
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<TArchivesApiResult<{ suggestions: TArchivesProfileMetadata[] }>> {
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<string, unknown>
const suggestions = Array.isArray(d.suggestions)
? (d.suggestions as TArchivesProfileMetadata[])
: []
return { ok: true, data: { suggestions } }
}
async fetchProfilesMetadata(
pubkeys: readonly string[]
): Promise<TArchivesApiResult<{ profiles: TArchivesProfileMetadata[] }>> {
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<string, unknown>
if (Array.isArray(d.profiles)) {
merged.push(...(d.profiles as TArchivesProfileMetadata[]))
}
}
return { ok: true, data: { profiles: merged } }
}
}
const nostrArchivesApi = new NostrArchivesApiService()
export default nostrArchivesApi

42
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<T> =
| { 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<string, TArchivesProfileMetadata>
interactions: TArchivesInteractionCounts
}
Loading…
Cancel
Save