Compare commits

...

5 Commits

Author SHA1 Message Date
Jörg Lohrer bab2895848 spa(task 12): replies-loader für kind:1 mit a-tag-filter
Fügt `loadReplies(dtag)` an loaders.ts an. Filter `#a` auf das
addressable-Event-Format "30023:<pubkey>:<dtag>" findet alle kind:1
Replies auf den Post. Sortiert aufsteigend (älteste zuerst).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:19:21 +02:00
Jörg Lohrer 09f2ce8b49 spa: loader für postlist, post, profile 2026-04-15 16:40:21 +02:00
Jörg Lohrer 078423a1b2 spa: read-relays-store mit bootstrap aus kind:10002 2026-04-15 16:37:41 +02:00
Jörg Lohrer 0bf9bf3bf2 spa: outbox-relay-loader für kind:10002 mit fallback 2026-04-15 16:33:27 +02:00
Jörg Lohrer 6f9f53c561 spa: relaypool-singleton via applesauce-relay 2026-04-15 16:10:06 +02:00
4 changed files with 277 additions and 0 deletions

View File

@ -0,0 +1,146 @@
import { get } from 'svelte/store';
import { lastValueFrom, timeout, toArray, EMPTY, tap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import type { NostrEvent } from 'applesauce-core/helpers/event';
import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter';
import { pool } from './pool';
import { readRelays } from '$lib/stores/readRelays';
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
/** Re-export als sprechenden Alias */
export type { NostrEvent };
/** Profile-Content (kind:0) */
export interface Profile {
name?: string;
display_name?: string;
picture?: string;
banner?: string;
about?: string;
website?: string;
nip05?: string;
lud16?: string;
}
type Filter = ApplesauceFilter;
interface CollectOpts {
onEvent?: (ev: NostrEvent) => void;
hardTimeoutMs?: number;
}
/**
* Startet eine Request-Subscription und sammelt alle gelieferten Events
* bis EOSE (pool.request completes nach EOSE) oder Hard-Timeout.
*/
async function collectEvents(
relays: string[],
filter: Filter,
opts: CollectOpts = {}
): Promise<NostrEvent[]> {
const events = await lastValueFrom(
pool.request(relays, filter).pipe(
tap((ev: NostrEvent) => opts.onEvent?.(ev)),
timeout(opts.hardTimeoutMs ?? RELAY_HARD_TIMEOUT_MS),
toArray(),
catchError(() => EMPTY)
),
{ defaultValue: [] as NostrEvent[] }
);
return events;
}
/** Dedup per d-Tag: neueste (created_at) wins */
function dedupByDtag(events: NostrEvent[]): NostrEvent[] {
const byDtag = new Map<string, NostrEvent>();
for (const ev of events) {
const d = ev.tags.find((t) => t[0] === 'd')?.[1];
if (!d) continue;
const existing = byDtag.get(d);
if (!existing || ev.created_at > existing.created_at) {
byDtag.set(d, ev);
}
}
return [...byDtag.values()];
}
/** Alle kind:30023-Posts des Autors, neueste zuerst */
export async function loadPostList(
onEvent?: (ev: NostrEvent) => void
): Promise<NostrEvent[]> {
const relays = get(readRelays);
const events = await collectEvents(
relays,
{ kinds: [30023], authors: [AUTHOR_PUBKEY_HEX], limit: 200 },
{ onEvent }
);
const deduped = dedupByDtag(events);
return deduped.sort((a, b) => {
const ap = parseInt(
a.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${a.created_at}`,
10
);
const bp = parseInt(
b.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${b.created_at}`,
10
);
return bp - ap;
});
}
/** Einzelpost per d-Tag */
export async function loadPost(dtag: string): Promise<NostrEvent | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [30023],
authors: [AUTHOR_PUBKEY_HEX],
'#d': [dtag],
limit: 1
});
if (events.length === 0) return null;
return events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
}
/** Profil-Event kind:0 (neueste Version) */
export async function loadProfile(): Promise<Profile | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [0],
authors: [AUTHOR_PUBKEY_HEX],
limit: 1
});
if (events.length === 0) return null;
const latest = events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
try {
return JSON.parse(latest.content) as Profile;
} catch {
return null;
}
}
/** Post-Adresse im `a`-Tag-Format: "30023:<pubkey>:<dtag>" */
function eventAddress(pubkey: string, dtag: string): string {
return `30023:${pubkey}:${dtag}`;
}
/**
* Alle kind:1-Replies auf einen Post, chronologisch aufsteigend (älteste zuerst).
* Streamt via onEvent, wenn angegeben.
*/
export async function loadReplies(
dtag: string,
onEvent?: (ev: NostrEvent) => void
): Promise<NostrEvent[]> {
const relays = get(readRelays);
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
const events = await collectEvents(
relays,
{ kinds: [1], '#a': [address], limit: 500 },
{ onEvent }
);
return events.sort((a, b) => a.created_at - b.created_at);
}

View File

@ -0,0 +1,7 @@
import { RelayPool } from 'applesauce-relay';
/**
* Singleton-Pool für alle Nostr-Requests der SPA.
* applesauce-relay verwaltet Reconnects, Subscriptions, deduping intern.
*/
export const pool = new RelayPool();

View File

@ -0,0 +1,95 @@
import { lastValueFrom, timeout, toArray, EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import type { NostrEvent } from 'applesauce-core/helpers/event';
import { pool } from './pool';
import {
AUTHOR_PUBKEY_HEX,
BOOTSTRAP_RELAY,
FALLBACK_READ_RELAYS,
RELAY_TIMEOUT_MS
} from './config';
export interface OutboxRelay {
url: string;
/** true = zum Lesen zu nutzen (kein dritter Tag-Wert oder "read") */
read: boolean;
/** true = zum Schreiben zu nutzen (kein dritter Tag-Wert oder "write") */
write: boolean;
}
/**
* Lädt die NIP-65-Relay-Liste (kind:10002) des Autors vom Bootstrap-Relay.
* Fallback auf FALLBACK_READ_RELAYS, wenn das Event nicht innerhalb von
* RELAY_TIMEOUT_MS gefunden wird.
*
* Interpretation des dritten Tag-Werts:
* - nicht gesetzt read + write
* - "read" nur read
* - "write" nur write
*/
export async function loadOutboxRelays(): Promise<OutboxRelay[]> {
const event = await firstEvent();
if (!event) {
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
}
const relays: OutboxRelay[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'r' || !tag[1]) continue;
const mode = tag[2];
relays.push({
url: tag[1],
read: mode !== 'write',
write: mode !== 'read'
});
}
if (relays.length === 0) {
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
}
return relays;
}
/** Nur die Read-URLs aus OutboxRelay[] */
export function readUrls(relays: OutboxRelay[]): string[] {
return relays.filter((r) => r.read).map((r) => r.url);
}
/** Nur die Write-URLs aus OutboxRelay[] */
export function writeUrls(relays: OutboxRelay[]): string[] {
return relays.filter((r) => r.write).map((r) => r.url);
}
// ---------- Internes --------------------------------------------------------
/**
* Fragt das neueste kind:10002-Event vom Bootstrap-Relay ab.
* Sammelt alle Events bis EOSE (`pool.request(...)` emittiert nur Events
* und completes bei EOSE), nimmt das neueste, oder null falls keines.
*/
async function firstEvent(): Promise<NostrEvent | null> {
try {
const events = await lastValueFrom(
pool
.request([BOOTSTRAP_RELAY], {
kinds: [10002],
authors: [AUTHOR_PUBKEY_HEX],
limit: 1
})
.pipe(
timeout(RELAY_TIMEOUT_MS),
toArray(),
catchError(() => EMPTY)
),
{ defaultValue: [] as NostrEvent[] }
);
if (events.length === 0) return null;
return events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
} catch {
return null;
}
}

View File

@ -0,0 +1,29 @@
import { writable, type Readable } from 'svelte/store';
import { loadOutboxRelays, readUrls } from '$lib/nostr/relays';
import { FALLBACK_READ_RELAYS } from '$lib/nostr/config';
/**
* Store mit der aktuellen Read-Relay-Liste.
* Initial = FALLBACK_READ_RELAYS, damit die SPA sofort abfragen kann;
* sobald loadOutboxRelays() fertig ist, wird der Store aktualisiert.
*
* Singleton-Initialisierung: bootstrapReadRelays() wird genau einmal beim ersten
* Import aufgerufen.
*/
const store = writable<string[]>([...FALLBACK_READ_RELAYS]);
let bootstrapped = false;
export function bootstrapReadRelays(): void {
if (bootstrapped) return;
bootstrapped = true;
loadOutboxRelays()
.then((relays) => {
const urls = readUrls(relays);
if (urls.length > 0) store.set(urls);
})
.catch(() => {
// Store behält seinen initialen FALLBACK-Zustand
});
}
export const readRelays: Readable<string[]> = store;