9 changed files with 614 additions and 0 deletions
@ -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. |
||||||
@ -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 |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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 |
||||||
@ -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…
Reference in new issue