refactor: extract shared utilities from chat adapters

Create reusable utility modules for chat adapters to reduce code
duplication and improve testability:

- event-fetcher.ts: fetchEvent(), fetchReplaceableEvent() with
  EventStore caching and timeout handling
- relay-utils.ts: getOutboxRelays(), getInboxRelays(), mergeRelays(),
  collectOutboxRelays() for NIP-65 relay list resolution
- message-utils.ts: zapReceiptToMessage(), nutzapToMessage(),
  eventToMessage(), and protocol-specific reply extractors
  (getNip10ReplyTo, getNip22ReplyTo, getQTagReplyTo)

Refactored NIP-22 adapter to use shared utilities, reducing code from
~1165 lines to ~830 lines (~30% reduction). Other adapters (NIP-10,
NIP-29, NIP-53) can be refactored similarly using the same pattern.
This commit is contained in:
Claude
2026-01-19 16:17:51 +00:00
parent d566dc7942
commit 99d320c7b9
5 changed files with 874 additions and 483 deletions

View File

@@ -1,5 +1,5 @@
import { Observable, firstValueFrom, combineLatest, of } from "rxjs";
import { map, first, toArray } from "rxjs/operators";
import { map, toArray } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import {
@@ -20,15 +20,21 @@ import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { publishEventToRelays } from "@/services/hub";
import accountManager from "@/services/accounts";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { normalizeURL, getTagValue } from "applesauce-core/helpers";
import { getTagValue } from "applesauce-core/helpers";
import { EventFactory } from "applesauce-core/event-factory";
import { getArticleTitle } from "applesauce-common/helpers";
import {
getZapAmount,
getZapSender,
getZapRecipient,
getArticleTitle,
} from "applesauce-common/helpers";
fetchEvent,
fetchReplaceableEvent,
getOutboxRelays,
mergeRelays,
zapReceiptToMessage,
eventToMessage,
getNip22ReplyTo,
AGGREGATOR_RELAYS,
} from "../utils";
const LOG_PREFIX = "[NIP-22]";
/**
* NIP-22 Adapter - Comments on Non-Kind-1 Events
@@ -49,9 +55,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
/**
* Parse identifier - accepts nevent (non-kind-1) or naddr format
* Examples:
* - nevent1... (with kind != 1)
* - naddr1... (addressable events like articles)
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Try nevent format
@@ -62,7 +65,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const { id, relays, author, kind } = decoded.data;
// Only handle non-kind-1 events (kind 1 is handled by NIP-10)
// If kind is unspecified, we'll check after fetching
if (kind === 1) {
return null;
}
@@ -85,10 +87,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
if (decoded.type === "naddr") {
const { kind, pubkey, identifier, relays } = decoded.data;
// Skip certain kinds handled by other adapters
// 39000 = NIP-29 group metadata
// 30311 = NIP-53 live activity
// 10009 = Group list
// Skip kinds handled by other adapters
if (kind === 39000 || kind === 30311 || kind === 10009) {
return null;
}
@@ -121,12 +120,9 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const relayHints = identifier.relays || [];
// Determine if this is an event pointer or address pointer
if ("id" in identifier.value) {
// Event pointer
return this.resolveFromEventPointer(identifier.value, relayHints);
} else {
// Address pointer
return this.resolveFromAddressPointer(identifier.value, relayHints);
}
}
@@ -138,39 +134,37 @@ export class Nip22Adapter extends ChatProtocolAdapter {
pointer: { id: string; relays?: string[]; author?: string; kind?: number },
relayHints: string[],
): Promise<Conversation> {
// 1. Fetch the provided event
const providedEvent = await this.fetchEvent(
pointer.id,
pointer.relays || relayHints,
);
const providedEvent = await fetchEvent(pointer.id, {
relayHints: pointer.relays || relayHints,
logPrefix: LOG_PREFIX,
});
if (!providedEvent) {
throw new Error("Event not found");
}
// 2. If this is a kind 1111 comment, resolve to the actual root
// If kind 1111 comment, resolve to actual root
if (providedEvent.kind === 1111) {
return this.resolveFromComment(providedEvent, relayHints);
}
// 3. If this is kind 1, reject (NIP-10 should handle it)
// Kind 1 should use NIP-10
if (providedEvent.kind === 1) {
throw new Error("Kind 1 notes should use NIP-10 thread chat");
}
// 4. This event IS the root - build conversation around it
const conversationRelays = await this.getCommentRelays(
providedEvent,
// This event IS the root
const conversationRelays = await this.buildRelays(
providedEvent.pubkey,
relayHints,
);
const title = this.extractTitle(providedEvent);
const participants = this.extractParticipants(providedEvent);
return {
id: `nip-22:${providedEvent.id}`,
type: "group",
protocol: "nip-22",
title,
participants,
title: this.extractTitle(providedEvent),
participants: this.extractParticipants(providedEvent),
metadata: {
rootEventId: providedEvent.id,
providedEventId: providedEvent.id,
@@ -189,18 +183,19 @@ export class Nip22Adapter extends ChatProtocolAdapter {
pointer: { kind: number; pubkey: string; identifier: string },
relayHints: string[],
): Promise<Conversation> {
// Fetch the replaceable event
const rootEvent = await this.fetchReplaceableEvent(
const rootEvent = await fetchReplaceableEvent(
pointer.kind,
pointer.pubkey,
pointer.identifier,
relayHints,
{
identifier: pointer.identifier,
relayHints,
logPrefix: LOG_PREFIX,
},
);
const coordinate = `${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`;
if (!rootEvent) {
// Even without the event, we can create a conversation based on the address
return {
id: `nip-22:${coordinate}`,
type: "group",
@@ -216,19 +211,17 @@ export class Nip22Adapter extends ChatProtocolAdapter {
};
}
const conversationRelays = await this.getCommentRelays(
rootEvent,
const conversationRelays = await this.buildRelays(
rootEvent.pubkey,
relayHints,
);
const title = this.extractTitle(rootEvent);
const participants = this.extractParticipants(rootEvent);
return {
id: `nip-22:${coordinate}`,
type: "group",
protocol: "nip-22",
title,
participants,
title: this.extractTitle(rootEvent),
participants: this.extractParticipants(rootEvent),
metadata: {
rootEventId: rootEvent.id,
rootAddress: pointer,
@@ -247,39 +240,35 @@ export class Nip22Adapter extends ChatProtocolAdapter {
comment: NostrEvent,
relayHints: string[],
): Promise<Conversation> {
// Find root reference using uppercase tags
const eTag = comment.tags.find((t) => t[0] === "E");
const aTag = comment.tags.find((t) => t[0] === "A");
const iTag = comment.tags.find((t) => t[0] === "I");
const kTag = comment.tags.find((t) => t[0] === "K");
const rootKind = kTag ? parseInt(kTag[1], 10) : undefined;
// Try E tag (event root)
if (eTag && eTag[1]) {
if (eTag?.[1]) {
const eventId = eTag[1];
const relay = eTag[2];
const rootPubkey = eTag[4];
const rootEvent = await this.fetchEvent(
eventId,
relay ? [relay, ...relayHints] : relayHints,
);
const rootEvent = await fetchEvent(eventId, {
relayHints: relay ? [relay, ...relayHints] : relayHints,
logPrefix: LOG_PREFIX,
});
if (rootEvent) {
const conversationRelays = await this.getCommentRelays(
rootEvent,
const conversationRelays = await this.buildRelays(
rootEvent.pubkey,
relayHints,
);
const title = this.extractTitle(rootEvent);
const participants = this.extractParticipants(rootEvent, comment);
return {
id: `nip-22:${eventId}`,
type: "group",
protocol: "nip-22",
title,
participants,
title: this.extractTitle(rootEvent),
participants: this.extractParticipants(rootEvent, comment),
metadata: {
rootEventId: eventId,
providedEventId: comment.id,
@@ -291,12 +280,12 @@ export class Nip22Adapter extends ChatProtocolAdapter {
};
}
// Root not found but we have the ID - create minimal conversation
// Root not found - create minimal conversation
return {
id: `nip-22:${eventId}`,
type: "group",
protocol: "nip-22",
title: `Comments on event`,
title: "Comments on event",
participants: rootPubkey
? [{ pubkey: rootPubkey, role: "op" }]
: [{ pubkey: comment.pubkey, role: "member" }],
@@ -311,7 +300,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
}
// Try A tag (addressable root)
if (aTag && aTag[1]) {
if (aTag?.[1]) {
const coordinate = aTag[1];
const parts = coordinate.split(":");
if (parts.length >= 3) {
@@ -320,29 +309,26 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const identifier = parts.slice(2).join(":");
const relay = aTag[2];
const rootEvent = await this.fetchReplaceableEvent(
kind,
pubkey,
const rootEvent = await fetchReplaceableEvent(kind, pubkey, {
identifier,
relay ? [relay, ...relayHints] : relayHints,
);
relayHints: relay ? [relay, ...relayHints] : relayHints,
logPrefix: LOG_PREFIX,
});
const rootAddress = { kind, pubkey, identifier };
if (rootEvent) {
const conversationRelays = await this.getCommentRelays(
rootEvent,
const conversationRelays = await this.buildRelays(
rootEvent.pubkey,
relayHints,
);
const title = this.extractTitle(rootEvent);
const participants = this.extractParticipants(rootEvent, comment);
return {
id: `nip-22:${coordinate}`,
type: "group",
protocol: "nip-22",
title,
participants,
title: this.extractTitle(rootEvent),
participants: this.extractParticipants(rootEvent, comment),
metadata: {
rootEventId: rootEvent.id,
rootAddress,
@@ -355,7 +341,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
};
}
// Root not found but we have the address
return {
id: `nip-22:${coordinate}`,
type: "group",
@@ -373,8 +358,8 @@ export class Nip22Adapter extends ChatProtocolAdapter {
}
}
// Try I tag (external identifier like URL)
if (iTag && iTag[1]) {
// Try I tag (external identifier)
if (iTag?.[1]) {
const externalId = iTag[1];
return {
@@ -386,7 +371,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
metadata: {
rootExternal: externalId,
providedEventId: comment.id,
rootKind: 0, // External content has no kind
rootKind: 0,
relays: relayHints.length > 0 ? relayHints : AGGREGATOR_RELAYS,
},
unreadCount: 0,
@@ -407,29 +392,18 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const rootAddress = conversation.metadata?.rootAddress;
const rootExternal = conversation.metadata?.rootExternal;
const relays = conversation.metadata?.relays || [];
const conversationId = conversation.id;
// Build filter based on root type
// Build filters based on root type
const filters: Filter[] = [];
if (rootEventId) {
// Comments referencing this event
filters.push({
kinds: [1111],
"#E": [rootEventId],
limit: options?.limit || 100,
});
// Reactions on comments
filters.push({
kinds: [7],
"#e": [rootEventId],
limit: 200,
});
// Zaps on comments
filters.push({
kinds: [9735],
"#e": [rootEventId],
limit: 100,
});
filters.push({ kinds: [7, 9735], "#e": [rootEventId], limit: 200 });
}
if (rootAddress) {
@@ -453,51 +427,32 @@ export class Nip22Adapter extends ChatProtocolAdapter {
return of([]);
}
if (options?.before) {
filters[0].until = options.before;
}
if (options?.after) {
filters[0].since = options.after;
}
if (options?.before) filters[0].until = options.before;
if (options?.after) filters[0].since = options.after;
// Clean up any existing subscription
const conversationId = conversation.id;
// Cleanup existing subscription
this.cleanup(conversationId);
// Start persistent subscription
const subscription = pool
.subscription(relays, filters, { eventStore })
.subscribe({
next: (_response) => {
// EOSE or event - both handled by EventStore
},
});
.subscribe();
// Store subscription for cleanup
this.subscriptions.set(conversationId, subscription);
// Build timeline observable based on root type
const commentFilters: Filter[] = [];
// Build timeline observable
const commentFilter: Filter = rootEventId
? { kinds: [1111, 7, 9735], "#E": [rootEventId] }
: rootAddress
? {
kinds: [1111, 7, 9735],
"#A": [
`${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`,
],
}
: { kinds: [1111], "#I": [rootExternal!] };
if (rootEventId) {
commentFilters.push({ kinds: [1111, 7, 9735], "#E": [rootEventId] });
commentFilters.push({ kinds: [7, 9735], "#e": [rootEventId] });
}
if (rootAddress) {
const coordinate = `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`;
commentFilters.push({ kinds: [1111, 7, 9735], "#A": [coordinate] });
}
if (rootExternal) {
commentFilters.push({ kinds: [1111], "#I": [rootExternal] });
}
// Combine all comment sources
const comments$ =
commentFilters.length > 0
? eventStore.timeline(commentFilters[0])
: of([]);
// Optionally fetch root event for display
const comments$ = eventStore.timeline(commentFilter);
const rootEvent$ = rootEventId
? eventStore.event(rootEventId)
: of(undefined);
@@ -506,27 +461,26 @@ export class Nip22Adapter extends ChatProtocolAdapter {
map(([rootEvent, commentEvents]) => {
const messages: Message[] = [];
// Add root event as first message (if it's a regular event, not addressable)
// Add root event as first message (if available and not a comment)
if (rootEvent && rootEvent.kind !== 1111) {
const rootMessage = this.rootEventToMessage(
rootEvent,
conversationId,
messages.push(
eventToMessage(rootEvent, {
conversationId,
protocol: "nip-22",
}),
);
if (rootMessage) {
messages.push(rootMessage);
}
}
// Convert comments to messages
const commentMessages = commentEvents
.map((event) =>
this.eventToMessage(event, conversationId, rootEventId),
)
.filter((msg): msg is Message => msg !== null);
for (const event of commentEvents) {
const msg = this.convertEventToMessage(
event,
conversationId,
rootEventId,
);
if (msg) messages.push(msg);
}
messages.push(...commentMessages);
// Sort by timestamp ascending (chronological order)
return messages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
@@ -542,6 +496,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const rootEventId = conversation.metadata?.rootEventId;
const rootAddress = conversation.metadata?.rootAddress;
const relays = conversation.metadata?.relays || [];
const conversationId = conversation.id;
const filters: Filter[] = [];
@@ -564,21 +519,16 @@ export class Nip22Adapter extends ChatProtocolAdapter {
});
}
if (filters.length === 0) {
return [];
}
if (filters.length === 0) return [];
const events = await firstValueFrom(
pool.request(relays, filters, { eventStore }).pipe(toArray()),
);
const conversationId = conversation.id;
const messages = events
.map((event) => this.eventToMessage(event, conversationId, rootEventId))
.filter((msg): msg is Message => msg !== null);
return messages.reverse();
return events
.map((e) => this.convertEventToMessage(e, conversationId, rootEventId))
.filter((msg): msg is Message => msg !== null)
.reverse();
}
/**
@@ -602,16 +552,13 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const rootKind = conversation.metadata?.rootKind;
const relays = conversation.metadata?.relays || [];
// Create event factory
const factory = new EventFactory();
factory.setSigner(activeSigner);
// Build NIP-22 tags
const tags: string[][] = [];
// Add root reference (uppercase tags)
if (rootEventId) {
// Fetch root to get author
const rootEvent = await firstValueFrom(eventStore.event(rootEventId), {
defaultValue: undefined,
});
@@ -626,9 +573,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
rootPubkey || "",
]);
tags.push(["K", (rootEventKind || 0).toString()]);
if (rootPubkey) {
tags.push(["P", rootPubkey]);
}
if (rootPubkey) tags.push(["P", rootPubkey]);
} else if (rootAddress) {
const coordinate = `${rootAddress.kind}:${rootAddress.pubkey}:${rootAddress.identifier}`;
tags.push(["A", coordinate, relays[0] || ""]);
@@ -636,7 +581,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
tags.push(["P", rootAddress.pubkey]);
} else if (rootExternal) {
tags.push(["I", rootExternal]);
tags.push(["K", "0"]); // External content has no kind
tags.push(["K", "0"]);
}
// Handle reply to another comment
@@ -647,7 +592,6 @@ export class Nip22Adapter extends ChatProtocolAdapter {
);
if (parentEvent) {
// Add parent reference (lowercase tags)
tags.push(["e", options.replyTo, relays[0] || "", parentEvent.pubkey]);
tags.push(["k", parentEvent.kind.toString()]);
tags.push(["p", parentEvent.pubkey]);
@@ -661,7 +605,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
}
}
// Add NIP-92 imeta tags for blob attachments
// Add NIP-92 imeta tags
if (options?.blobAttachments) {
for (const blob of options.blobAttachments) {
const imetaParts = [`url ${blob.url}`];
@@ -672,11 +616,9 @@ export class Nip22Adapter extends ChatProtocolAdapter {
}
}
// Create and sign kind 1111 event
const draft = await factory.build({ kind: 1111, content, tags });
const event = await factory.sign(draft);
// Publish to conversation relays
await publishEventToRelays(event, relays);
}
@@ -731,16 +673,14 @@ export class Nip22Adapter extends ChatProtocolAdapter {
getZapConfig(message: Message, conversation: Conversation): ZapConfig {
const relays = conversation.metadata?.relays || [];
const eventPointer = {
id: message.id,
author: message.author,
relays,
};
return {
supported: true,
recipientPubkey: message.author,
eventPointer,
eventPointer: {
id: message.id,
author: message.author,
relays,
},
relays,
};
}
@@ -752,30 +692,12 @@ export class Nip22Adapter extends ChatProtocolAdapter {
conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
const cachedEvent = await eventStore
.event(eventId)
.pipe(first())
.toPromise();
if (cachedEvent) {
return cachedEvent;
}
const relays = conversation.metadata?.relays || [];
if (relays.length === 0) {
console.warn("[NIP-22] No relays for loading reply message");
return null;
}
const filter: Filter = {
ids: [eventId],
limit: 1,
};
const events = await firstValueFrom(
pool.request(relays, [filter], { eventStore }).pipe(toArray()),
);
return events[0] || null;
return fetchEvent(eventId, {
relayHints: relays,
logPrefix: LOG_PREFIX,
});
}
/**
@@ -793,21 +715,43 @@ export class Nip22Adapter extends ChatProtocolAdapter {
};
}
// --- Private helpers ---
/**
* Extract a readable title from root event
* Build relay list from pubkey outboxes and hints
*/
private async buildRelays(
rootPubkey: string,
providedRelays: string[],
): Promise<string[]> {
const rootOutbox = await getOutboxRelays(rootPubkey, { maxRelays: 3 });
const activePubkey = accountManager.active$.value?.pubkey;
const userOutbox =
activePubkey && activePubkey !== rootPubkey
? await getOutboxRelays(activePubkey, { maxRelays: 2 })
: [];
return mergeRelays([providedRelays, rootOutbox, userOutbox], {
maxRelays: 10,
minRelays: 3,
});
}
/**
* Extract title from root event
*/
private extractTitle(rootEvent: NostrEvent): string {
// Try article title first (kind 30023)
// Try article title (kind 30023)
if (rootEvent.kind === 30023) {
const title = getArticleTitle(rootEvent);
if (title) return title;
}
// Try title tag
// Try title/name tags
const titleTag = getTagValue(rootEvent, "title");
if (titleTag) return titleTag;
// Try name tag (for badges, etc.)
const nameTag = getTagValue(rootEvent, "name");
if (nameTag) return nameTag;
@@ -816,19 +760,14 @@ export class Nip22Adapter extends ChatProtocolAdapter {
if (!content) return `Comments on kind ${rootEvent.kind}`;
const firstLine = content.split("\n")[0];
if (firstLine && firstLine.length <= 50) {
return firstLine;
}
if (content.length <= 50) {
return content;
}
if (firstLine && firstLine.length <= 50) return firstLine;
if (content.length <= 50) return content;
return content.slice(0, 47) + "...";
}
/**
* Extract participants from root and optional comment
* Extract participants from root event
*/
private extractParticipants(
rootEvent: NostrEvent,
@@ -845,10 +784,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
// Add p-tags from root
for (const tag of rootEvent.tags) {
if (tag[0] === "p" && tag[1] && tag[1] !== rootEvent.pubkey) {
participants.set(tag[1], {
pubkey: tag[1],
role: "member",
});
participants.set(tag[1], { pubkey: tag[1], role: "member" });
}
}
@@ -864,302 +800,39 @@ export class Nip22Adapter extends ChatProtocolAdapter {
}
/**
* Get relays for the comment thread
* Convert event to Message, handling different event types
*/
private async getCommentRelays(
rootEvent: NostrEvent,
providedRelays: string[],
): Promise<string[]> {
const relays = new Set<string>();
// 1. Provided relay hints
providedRelays.forEach((r) => relays.add(normalizeURL(r)));
// 2. Root author's outbox relays
try {
const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey);
rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r)));
} catch (err) {
console.warn("[NIP-22] Failed to get root author outbox:", err);
}
// 3. Active user's outbox
const activePubkey = accountManager.active$.value?.pubkey;
if (activePubkey && activePubkey !== rootEvent.pubkey) {
try {
const userOutbox = await this.getOutboxRelays(activePubkey);
userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r)));
} catch (err) {
console.warn("[NIP-22] Failed to get user outbox:", err);
}
}
// 4. Fallback to aggregator relays
if (relays.size < 3) {
AGGREGATOR_RELAYS.forEach((r) => relays.add(r));
}
return Array.from(relays).slice(0, 10);
}
/**
* Get outbox relays for a pubkey (NIP-65)
*/
private async getOutboxRelays(pubkey: string): Promise<string[]> {
const relayList = await firstValueFrom(
eventStore.replaceable(10002, pubkey, ""),
{ defaultValue: undefined },
);
if (!relayList) return [];
return relayList.tags
.filter((t) => {
if (t[0] !== "r") return false;
const marker = t[2];
return !marker || marker === "write";
})
.map((t) => normalizeURL(t[1]))
.slice(0, 5);
}
/**
* Fetch an event by ID
*/
private async fetchEvent(
eventId: string,
relayHints: string[] = [],
): Promise<NostrEvent | null> {
const cached = await firstValueFrom(eventStore.event(eventId), {
defaultValue: undefined,
});
if (cached) return cached;
const relays =
relayHints.length > 0 ? relayHints : await this.getDefaultRelays();
const filter: Filter = {
ids: [eventId],
limit: 1,
};
const events: NostrEvent[] = [];
const obs = pool.subscription(relays, [filter], { eventStore });
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve();
}, 5000);
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error(`[NIP-22] Fetch error:`, err);
sub.unsubscribe();
resolve();
},
});
});
return events[0] || null;
}
/**
* Fetch a replaceable event by address
*/
private async fetchReplaceableEvent(
kind: number,
pubkey: string,
identifier: string,
relayHints: string[] = [],
): Promise<NostrEvent | null> {
const cached = await firstValueFrom(
eventStore.replaceable(kind, pubkey, identifier),
{ defaultValue: undefined },
);
if (cached) return cached;
const relays =
relayHints.length > 0 ? relayHints : await this.getDefaultRelays();
const filter: Filter = {
kinds: [kind],
authors: [pubkey],
"#d": [identifier],
limit: 1,
};
const events: NostrEvent[] = [];
const obs = pool.subscription(relays, [filter], { eventStore });
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve();
}, 5000);
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error(`[NIP-22] Fetch error:`, err);
sub.unsubscribe();
resolve();
},
});
});
return events[0] || null;
}
/**
* Get default relays
*/
private async getDefaultRelays(): Promise<string[]> {
const activePubkey = accountManager.active$.value?.pubkey;
if (activePubkey) {
const outbox = await this.getOutboxRelays(activePubkey);
if (outbox.length > 0) return outbox.slice(0, 5);
}
return AGGREGATOR_RELAYS;
}
/**
* Convert root event to Message object
*/
private rootEventToMessage(
event: NostrEvent,
conversationId: string,
): Message | null {
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo: undefined,
protocol: "nip-22",
metadata: {
encrypted: false,
},
event,
};
}
/**
* Convert event to Message object
*/
private eventToMessage(
private convertEventToMessage(
event: NostrEvent,
conversationId: string,
rootEventId?: string,
): Message | null {
// Handle zap receipts
// Zap receipts
if (event.kind === 9735) {
return this.zapToMessage(event, conversationId);
return zapReceiptToMessage(event, {
conversationId,
protocol: "nip-22",
});
}
// Handle reactions - skip for now
// Skip reactions (handled separately in UI)
if (event.kind === 7) {
return null;
}
// Handle comments (kind 1111)
// Comments (kind 1111) - use NIP-22 reply extraction
if (event.kind === 1111) {
// Find parent reference (lowercase e tag)
const parentTag = event.tags.find((t) => t[0] === "e");
const replyTo = parentTag?.[1] || rootEventId;
return {
id: event.id,
return eventToMessage(event, {
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo,
protocol: "nip-22",
metadata: {
encrypted: false,
},
event,
};
getReplyTo: (e) => getNip22ReplyTo(e) || rootEventId,
});
}
// Other event types (the root itself)
return {
id: event.id,
// Other events (root)
return eventToMessage(event, {
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo: undefined,
protocol: "nip-22",
metadata: {
encrypted: false,
},
event,
};
}
/**
* Convert zap receipt to Message
*/
private zapToMessage(
zapReceipt: NostrEvent,
conversationId: string,
): Message {
const amount = getZapAmount(zapReceipt);
const sender = getZapSender(zapReceipt);
const recipient = getZapRecipient(zapReceipt);
const eTag = zapReceipt.tags.find((t) => t[0] === "e");
const replyTo = eTag?.[1];
const zapRequestTag = zapReceipt.tags.find((t) => t[0] === "description");
let comment = "";
if (zapRequestTag && zapRequestTag[1]) {
try {
const zapRequest = JSON.parse(zapRequestTag[1]) as NostrEvent;
comment = zapRequest.content || "";
} catch {
// Invalid JSON
}
}
return {
id: zapReceipt.id,
conversationId,
author: sender || zapReceipt.pubkey,
content: comment,
timestamp: zapReceipt.created_at,
type: "zap",
replyTo,
protocol: "nip-22",
metadata: {
zapAmount: amount,
zapRecipient: recipient,
},
event: zapReceipt,
};
});
}
}

View File

@@ -0,0 +1,188 @@
/**
* Shared event fetching utilities for chat adapters
*
* Provides reusable functions for fetching events from relays
* with EventStore caching and timeout handling.
*/
import { firstValueFrom } from "rxjs";
import type { Filter } from "nostr-tools";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { getOutboxRelays, AGGREGATOR_RELAYS } from "./relay-utils";
import accountManager from "@/services/accounts";
export interface FetchEventOptions {
/** Relay hints to try first */
relayHints?: string[];
/** Timeout in milliseconds (default: 5000) */
timeout?: number;
/** Log prefix for debugging */
logPrefix?: string;
}
export interface FetchReplaceableOptions extends FetchEventOptions {
/** The d-tag identifier */
identifier: string;
}
/**
* Fetch an event by ID
*
* First checks EventStore cache, then fetches from relays.
* Uses provided relay hints, falling back to user's outbox + aggregator relays.
*
* @param eventId - The event ID to fetch
* @param options - Fetch options
* @returns The event or null if not found
*/
export async function fetchEvent(
eventId: string,
options: FetchEventOptions = {},
): Promise<NostrEvent | null> {
const {
relayHints = [],
timeout = 5000,
logPrefix = "[ChatUtils]",
} = options;
// Check EventStore first
const cached = await firstValueFrom(eventStore.event(eventId), {
defaultValue: undefined,
});
if (cached) return cached;
// Determine relays to use
const relays = await resolveRelays(relayHints);
if (relays.length === 0) {
console.warn(`${logPrefix} No relays available for fetching event`);
return null;
}
const filter: Filter = {
ids: [eventId],
limit: 1,
};
return fetchWithTimeout(relays, [filter], timeout, logPrefix);
}
/**
* Fetch a replaceable event by kind, pubkey, and identifier
*
* First checks EventStore cache, then fetches from relays.
*
* @param kind - Event kind
* @param pubkey - Author pubkey
* @param options - Fetch options including identifier
* @returns The event or null if not found
*/
export async function fetchReplaceableEvent(
kind: number,
pubkey: string,
options: FetchReplaceableOptions,
): Promise<NostrEvent | null> {
const {
identifier,
relayHints = [],
timeout = 5000,
logPrefix = "[ChatUtils]",
} = options;
// Check EventStore first
const cached = await firstValueFrom(
eventStore.replaceable(kind, pubkey, identifier),
{ defaultValue: undefined },
);
if (cached) return cached;
// Determine relays to use
const relays = await resolveRelays(relayHints);
if (relays.length === 0) {
console.warn(
`${logPrefix} No relays available for fetching replaceable event`,
);
return null;
}
const filter: Filter = {
kinds: [kind],
authors: [pubkey],
"#d": [identifier],
limit: 1,
};
return fetchWithTimeout(relays, [filter], timeout, logPrefix);
}
/**
* Fetch events matching a filter with timeout
*
* Returns the first event found or null after timeout/EOSE.
*
* @internal
*/
async function fetchWithTimeout(
relays: string[],
filters: Filter[],
timeout: number,
logPrefix: string,
): Promise<NostrEvent | null> {
const events: NostrEvent[] = [];
const obs = pool.subscription(relays, filters, { eventStore });
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
resolve();
}, timeout);
const sub = obs.subscribe({
next: (response) => {
if (typeof response === "string") {
// EOSE received
clearTimeout(timer);
sub.unsubscribe();
resolve();
} else {
events.push(response);
}
},
error: (err) => {
clearTimeout(timer);
console.error(`${logPrefix} Fetch error:`, err);
sub.unsubscribe();
resolve();
},
});
});
return events[0] || null;
}
/**
* Resolve relays to use for fetching
*
* Priority: provided hints > user's outbox > aggregator relays
*
* @internal
*/
async function resolveRelays(relayHints: string[]): Promise<string[]> {
if (relayHints.length > 0) {
return relayHints;
}
// Try user's outbox relays
const activePubkey = accountManager.active$.value?.pubkey;
if (activePubkey) {
const outbox = await getOutboxRelays(activePubkey);
if (outbox.length > 0) {
return outbox.slice(0, 5);
}
}
// Fall back to aggregator relays
return AGGREGATOR_RELAYS;
}

View File

@@ -0,0 +1,53 @@
/**
* Shared utilities for chat adapters
*
* This module provides reusable utilities for:
* - Event fetching (with caching and timeout handling)
* - Relay resolution (outbox/inbox relays, merging)
* - Message conversion (zaps, nutzaps, generic events)
*
* @example
* ```typescript
* import {
* fetchEvent,
* fetchReplaceableEvent,
* getOutboxRelays,
* mergeRelays,
* zapReceiptToMessage,
* eventToMessage,
* getNip10ReplyTo,
* } from "@/lib/chat/utils";
* ```
*/
// Event fetching utilities
export {
fetchEvent,
fetchReplaceableEvent,
type FetchEventOptions,
type FetchReplaceableOptions,
} from "./event-fetcher";
// Relay utilities
export {
getOutboxRelays,
getInboxRelays,
mergeRelays,
collectOutboxRelays,
AGGREGATOR_RELAYS,
type OutboxRelayOptions,
type MergeRelaysOptions,
} from "./relay-utils";
// Message conversion utilities
export {
zapReceiptToMessage,
nutzapToMessage,
eventToMessage,
getNip10ReplyTo,
getNip22ReplyTo,
getQTagReplyTo,
type ZapToMessageOptions,
type NutzapToMessageOptions,
type EventToMessageOptions,
} from "./message-utils";

View File

@@ -0,0 +1,254 @@
/**
* Shared message utilities for chat adapters
*
* Provides reusable functions for converting Nostr events
* to chat Message objects.
*/
import type { NostrEvent } from "@/types/nostr";
import type { Message, ChatProtocol } from "@/types/chat";
import {
getZapAmount,
getZapSender,
getZapRecipient,
getZapRequest,
} from "applesauce-common/helpers/zap";
export interface ZapToMessageOptions {
/** The conversation ID for the message */
conversationId: string;
/** The protocol to set on the message */
protocol: ChatProtocol;
}
/**
* Convert a zap receipt (kind 9735) to a Message
*
* Extracts zap metadata using applesauce helpers and builds
* a Message object with type "zap".
*
* @param zapReceipt - The kind 9735 zap receipt event
* @param options - Options including conversationId and protocol
* @returns A Message object with type "zap"
*/
export function zapReceiptToMessage(
zapReceipt: NostrEvent,
options: ZapToMessageOptions,
): Message {
const { conversationId, protocol } = options;
// Extract zap metadata using applesauce helpers
const amount = getZapAmount(zapReceipt);
const sender = getZapSender(zapReceipt);
const recipient = getZapRecipient(zapReceipt);
const zapRequest = getZapRequest(zapReceipt);
// Convert from msats to sats
const amountInSats = amount ? Math.floor(amount / 1000) : 0;
// Get zap comment from request content
const comment = zapRequest?.content || "";
// Find the event being zapped (e-tag)
const eTag = zapReceipt.tags.find((t) => t[0] === "e");
const replyTo = eTag?.[1];
return {
id: zapReceipt.id,
conversationId,
author: sender || zapReceipt.pubkey,
content: comment,
timestamp: zapReceipt.created_at,
type: "zap",
replyTo,
protocol,
metadata: {
encrypted: false,
zapAmount: amountInSats,
zapRecipient: recipient,
},
event: zapReceipt,
};
}
export interface NutzapToMessageOptions extends ZapToMessageOptions {}
/**
* Convert a nutzap event (kind 9321) to a Message
*
* NIP-61 nutzaps are P2PK-locked Cashu token transfers.
* Extracts proof amounts and builds a Message with type "zap".
*
* @param nutzapEvent - The kind 9321 nutzap event
* @param options - Options including conversationId and protocol
* @returns A Message object with type "zap"
*/
export function nutzapToMessage(
nutzapEvent: NostrEvent,
options: NutzapToMessageOptions,
): Message {
const { conversationId, protocol } = options;
// Sender is the event author
const sender = nutzapEvent.pubkey;
// Recipient is the p-tag value
const pTag = nutzapEvent.tags.find((t) => t[0] === "p");
const recipient = pTag?.[1] || "";
// Reply target is the e-tag (the event being nutzapped)
const eTag = nutzapEvent.tags.find((t) => t[0] === "e");
const replyTo = eTag?.[1];
// Amount is sum of proof amounts from all proof tags
// NIP-61 allows multiple proof tags, each containing JSON-encoded Cashu proofs
let amount = 0;
for (const tag of nutzapEvent.tags) {
if (tag[0] === "proof" && tag[1]) {
try {
const proof = JSON.parse(tag[1]);
// Proof can be a single object or an array of proofs
if (Array.isArray(proof)) {
amount += proof.reduce(
(sum: number, p: { amount?: number }) => sum + (p.amount || 0),
0,
);
} else if (typeof proof === "object" && proof.amount) {
amount += proof.amount;
}
} catch {
// Invalid proof JSON, skip this tag
}
}
}
// Unit defaults to "sat" per NIP-61
const unitTag = nutzapEvent.tags.find((t) => t[0] === "unit");
const unit = unitTag?.[1] || "sat";
// Comment is in the content field
const comment = nutzapEvent.content || "";
return {
id: nutzapEvent.id,
conversationId,
author: sender,
content: comment,
timestamp: nutzapEvent.created_at,
type: "zap",
replyTo,
protocol,
metadata: {
encrypted: false,
zapAmount: amount,
zapRecipient: recipient,
nutzapUnit: unit,
},
event: nutzapEvent,
};
}
export interface EventToMessageOptions {
/** The conversation ID for the message */
conversationId: string;
/** The protocol to set on the message */
protocol: ChatProtocol;
/** Function to extract replyTo from the event */
getReplyTo?: (event: NostrEvent) => string | undefined;
/** Message type (defaults to "user") */
type?: "user" | "system";
}
/**
* Convert a generic event to a Message
*
* This is a base conversion that works for most chat message types.
* Adapters can customize the replyTo extraction via options.
*
* @param event - The Nostr event to convert
* @param options - Options including conversationId, protocol, and replyTo extractor
* @returns A Message object
*/
export function eventToMessage(
event: NostrEvent,
options: EventToMessageOptions,
): Message {
const { conversationId, protocol, getReplyTo, type = "user" } = options;
const replyTo = getReplyTo ? getReplyTo(event) : undefined;
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type,
replyTo,
protocol,
metadata: {
encrypted: false,
},
event,
};
}
/**
* Extract reply-to event ID from NIP-10 style e-tags
*
* Looks for "reply" marker first, then falls back to root.
* Works for kind 1 (notes) and kind 1311 (live chat).
*
* @param event - The event to extract reply-to from
* @param rootEventId - Optional root event ID (for fallback)
* @returns The reply-to event ID or undefined
*/
export function getNip10ReplyTo(
event: NostrEvent,
rootEventId?: string,
): string | undefined {
const eTags = event.tags.filter((t) => t[0] === "e");
// Find explicit reply marker
const replyTag = eTags.find((t) => t[3] === "reply");
if (replyTag) return replyTag[1];
// Find explicit root marker (if no reply, it's a direct reply to root)
const rootTag = eTags.find((t) => t[3] === "root");
if (rootTag) return rootTag[1];
// Legacy: single e-tag means reply to that event
if (eTags.length === 1 && !eTags[0][3]) {
return eTags[0][1];
}
// Fallback to provided root
return rootEventId;
}
/**
* Extract reply-to event ID from NIP-22 style e-tags (comments)
*
* NIP-22 uses lowercase e tag for parent reference.
*
* @param event - The kind 1111 comment event
* @returns The reply-to event ID or undefined
*/
export function getNip22ReplyTo(event: NostrEvent): string | undefined {
// Lowercase e tag is the parent comment reference
const parentTag = event.tags.find((t) => t[0] === "e");
return parentTag?.[1];
}
/**
* Extract reply-to event ID from q-tag (NIP-29 style)
*
* NIP-29 groups use q-tag for quote/reply references.
*
* @param event - The event to extract reply-to from
* @returns The reply-to event ID or undefined
*/
export function getQTagReplyTo(event: NostrEvent): string | undefined {
const qTag = event.tags.find((t) => t[0] === "q");
return qTag?.[1];
}

View File

@@ -0,0 +1,223 @@
/**
* Shared relay utilities for chat adapters
*
* Provides reusable functions for relay selection,
* outbox resolution, and relay set merging.
*/
import { firstValueFrom } from "rxjs";
import { normalizeURL } from "applesauce-core/helpers";
import eventStore from "@/services/event-store";
import { AGGREGATOR_RELAYS as LOADERS_AGGREGATOR_RELAYS } from "@/services/loaders";
// Re-export aggregator relays for convenience
export const AGGREGATOR_RELAYS = LOADERS_AGGREGATOR_RELAYS;
export interface OutboxRelayOptions {
/** Maximum number of relays to return (default: 5) */
maxRelays?: number;
/** Log prefix for debugging */
logPrefix?: string;
}
/**
* Get outbox (write) relays for a pubkey via NIP-65
*
* Fetches kind 10002 relay list and extracts write relays.
* Falls back to empty array if no relay list found.
*
* @param pubkey - The pubkey to get outbox relays for
* @param options - Options
* @returns Array of normalized relay URLs
*/
export async function getOutboxRelays(
pubkey: string,
options: OutboxRelayOptions = {},
): Promise<string[]> {
const { maxRelays = 5, logPrefix = "[RelayUtils]" } = options;
try {
const relayList = await firstValueFrom(
eventStore.replaceable(10002, pubkey, ""),
{ defaultValue: undefined },
);
if (!relayList) return [];
// Extract write relays (r tags with "write" marker or no marker)
const writeRelays = relayList.tags
.filter((t) => {
if (t[0] !== "r") return false;
const marker = t[2];
return !marker || marker === "write";
})
.map((t) => {
try {
return normalizeURL(t[1]);
} catch {
return t[1]; // Return unnormalized if normalization fails
}
})
.filter(Boolean);
return writeRelays.slice(0, maxRelays);
} catch (err) {
console.warn(
`${logPrefix} Failed to get outbox relays for ${pubkey}:`,
err,
);
return [];
}
}
/**
* Get inbox (read) relays for a pubkey via NIP-65
*
* Fetches kind 10002 relay list and extracts read relays.
* Falls back to empty array if no relay list found.
*
* @param pubkey - The pubkey to get inbox relays for
* @param options - Options
* @returns Array of normalized relay URLs
*/
export async function getInboxRelays(
pubkey: string,
options: OutboxRelayOptions = {},
): Promise<string[]> {
const { maxRelays = 5, logPrefix = "[RelayUtils]" } = options;
try {
const relayList = await firstValueFrom(
eventStore.replaceable(10002, pubkey, ""),
{ defaultValue: undefined },
);
if (!relayList) return [];
// Extract read relays (r tags with "read" marker or no marker)
const readRelays = relayList.tags
.filter((t) => {
if (t[0] !== "r") return false;
const marker = t[2];
return !marker || marker === "read";
})
.map((t) => {
try {
return normalizeURL(t[1]);
} catch {
return t[1];
}
})
.filter(Boolean);
return readRelays.slice(0, maxRelays);
} catch (err) {
console.warn(`${logPrefix} Failed to get inbox relays for ${pubkey}:`, err);
return [];
}
}
export interface MergeRelaysOptions {
/** Maximum total relays to return (default: 10) */
maxRelays?: number;
/** Fallback relays if result is empty or below minimum */
fallbackRelays?: string[];
/** Minimum relays needed before adding fallback (default: 3) */
minRelays?: number;
}
/**
* Merge multiple relay sources into a deduplicated, normalized list
*
* Relays are added in order of priority (first sources have higher priority).
* Duplicates are removed using normalized URLs.
*
* @param relaySources - Arrays of relay URLs in priority order
* @param options - Merge options
* @returns Deduplicated array of normalized relay URLs
*/
export function mergeRelays(
relaySources: string[][],
options: MergeRelaysOptions = {},
): string[] {
const {
maxRelays = 10,
fallbackRelays = AGGREGATOR_RELAYS,
minRelays = 3,
} = options;
const seen = new Set<string>();
const result: string[] = [];
// Add relays from each source in order
for (const source of relaySources) {
for (const relay of source) {
if (!relay) continue;
try {
const normalized = normalizeURL(relay);
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(normalized);
}
} catch {
// Skip invalid URLs
}
// Stop if we have enough
if (result.length >= maxRelays) {
return result;
}
}
}
// Add fallback relays if we don't have enough
if (result.length < minRelays) {
for (const relay of fallbackRelays) {
try {
const normalized = normalizeURL(relay);
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(normalized);
}
} catch {
// Skip invalid URLs
}
if (result.length >= maxRelays) {
break;
}
}
}
return result;
}
/**
* Collect relays from multiple pubkeys' outboxes
*
* Fetches outbox relays for each pubkey and merges them.
* Useful for getting relays for thread participants.
*
* @param pubkeys - Array of pubkeys to get outboxes for
* @param options - Options including max relays per pubkey
* @returns Merged array of relay URLs
*/
export async function collectOutboxRelays(
pubkeys: string[],
options: OutboxRelayOptions & MergeRelaysOptions = {},
): Promise<string[]> {
const { maxRelays: perPubkeyMax = 3 } = options;
const relaySources: string[][] = [];
for (const pubkey of pubkeys.slice(0, 5)) {
// Limit to 5 pubkeys
const outbox = await getOutboxRelays(pubkey, { maxRelays: perPubkeyMax });
if (outbox.length > 0) {
relaySources.push(outbox);
}
}
return mergeRelays(relaySources, options);
}