feat: nip-10 chat interface (#153)

* docs: add comprehensive NIP-10 thread chat design documentation

Add detailed design documents for implementing NIP-10 thread chat feature:

- nip10-thread-chat-design.md: Full architecture, data structures, adapter
  implementation plan, relay selection strategy, UI requirements, and 7-phase
  implementation checklist
- nip10-thread-chat-examples.md: Complete code examples showing identifier
  parsing, conversation resolution, message loading, reply sending with proper
  NIP-10 tags, and ChatViewer integration
- nip10-thread-chat-summary.md: Quick reference with visual comparisons,
  architecture diagrams, protocol comparison table, data flow, and FAQ

The feature will enable "chat nevent1..." to display kind 1 threaded
conversations as chat interfaces, with the root event prominently displayed
at the top and all replies shown as chat messages below.

Key design decisions:
- Use e-tags with NIP-10 markers (root/reply) instead of q-tags
- Merge multiple relay sources (seen, hints, outbox) for coverage
- Display root event centered with full feed renderer
- Reuse existing ChatViewer infrastructure via adapter pattern
- Support both nevent (with relay hints) and note (ID only) formats

* feat: implement NIP-10 thread chat support

Add complete NIP-10 thread chat implementation enabling "chat nevent..." to
display kind 1 threaded conversations as chat interfaces.

**Type Definitions**:
- Add ThreadIdentifier for nevent/note event pointers
- Add "nip-10" to ChatProtocol type
- Extend ConversationMetadata with thread-specific fields (rootEventId,
  providedEventId, threadDepth, relays)

**NIP-10 Adapter** (src/lib/chat/adapters/nip-10-adapter.ts):
- parseIdentifier: Decode nevent/note format, reject non-kind-1 events
- resolveConversation: Fetch provided event, find root via NIP-10 refs,
  determine conversation relays (merge hints, outbox, fallbacks)
- loadMessages: Subscribe to kind 1 replies, kind 7 reactions, kind 9735 zaps
- sendMessage: Build proper NIP-10 tags (root/reply markers), add p-tags for
  all participants
- sendReaction: Send kind 7 with proper event/author references
- Smart relay selection: Merges seen relays, nevent hints, author outbox,
  user outbox (limit 7 relays for performance)

**ChatViewer Updates**:
- Detect NIP-10 threads (protocol === "nip-10")
- Fetch and display root event at top (centered with KindRenderer)
- Show visual separator ("Replies") between root and messages
- Update empty state message for threads ("No replies yet...")
- Enhanced header: Show "Author • Preview" for thread chats
- Update getAdapter to handle "nip-10" protocol

**Chat Parser**:
- Add Nip10Adapter to priority list (before other adapters to catch
  nevent/note)
- Update error message with nevent/note format examples
- Update adapter priority documentation

**Component Enhancements**:
- ReplyPreview: Show "thread root" when replying to root event (NIP-10)
- RelaysDropdown: Support conversation.metadata.relays for thread relay
  breakdown
- ChatMessageContextMenu: Add "Zap" option to context menu (opens ZapWindow)

**Features**:
- Root event displayed with full feed renderer (can interact: like, zap, etc.)
- All replies shown as chat messages with proper threading
- Reply/React/Zap options on all messages
- Relay dropdown shows breakdown of thread relays
- Participants dropdown shows all thread participants
- @ mention autocomplete works for participants
- Proper NIP-10 tag structure for nested replies
- Smart relay selection for maximum reach

**Usage**:
  chat nevent1qqsxyz...  # Thread with relay hints
  chat note1abc...       # Thread with event ID only

Root event is centered at top, all replies below as chat messages. Sending
replies creates kind 1 events with proper NIP-10 root/reply markers and
p-tags for all participants.

* fix: remove unused imports and mark intentionally unused parameter

- Remove unused getTagValues import
- Prefix providedEvent parameter with underscore to indicate intentional non-use (reserved for future relay hint extraction)

* feat: hide reply preview when directly replying to root in NIP-10 threads

In NIP-10 thread chats, the root event is already displayed prominently at
the top, so showing a reply preview when replying directly to the root is
redundant.

Changes:
- Skip reply preview for regular messages when replying to thread root
- Skip reply preview for zap messages when zapping the thread root
- Root event remains visible at top as reference point

This reduces visual clutter and makes the chat feel cleaner when replies
are directed at the already-visible root post.

* refactor: simplify NIP-10 thread chat implementation

Major simplifications based on user feedback:
- Render root event as regular message instead of special feed renderer
- Remove special-case logic for hiding reply preview when replying to root
- Include root event in messages list using RxJS combineLatest
- Change root author role from "admin" to "op" (original poster)
- Fix NIP-10 protocol link handler in chat header
- Add "Chat" option to event context menu for kind 1 events
- Fix unused parameter warnings in nip-10-adapter

This refactoring reduces complexity and makes NIP-10 threads behave
more consistently with other chat protocols.

* feat: add zap support for NIP-10 thread chat messages

Implement getZapConfig method to enable zapping messages in NIP-10 threads:
- Returns message author as recipient
- Includes event pointer for the message being zapped
- Uses conversation metadata relays for zap receipt publishing
- Integrates with new zap infrastructure from main branch

* fix: pass protocol and identifier directly to chat window

Fix 'undefined protocol' error when opening chat from event menu.
Instead of passing raw nevent as args, directly pass the parsed
protocol and identifier props that ChatViewer expects.

* debug: add logging to diagnose chat protocol issue

Add console logging to:
- chat command argParser to see input args and parsed result
- ChatViewer to see received props

This will help diagnose why 'undefined protocol' error appears
when opening chat via command or event menu.

* refactor: improve NIP-10 thread chat UX and relay/participant handling

UI Improvements:
- Remove "thread root" marker from reply previews - treat all replies uniformly
- Hide "load older messages" for NIP-10 threads (all replies loaded reactively)
- Display "Thread" with note icon instead of "Group" for NIP-10 conversations
- Remove debug logging from chat command parser and ChatViewer

Participant Management:
- Derive participants dynamically from messages (like live-chat does)
- Root author (OP) always listed first with "op" role
- All unique message authors included in member list
- Updates in real-time as new people reply

Relay Management:
- Expand relay collection to include participant outbox relays
- Fetch relays from root author, provided event author, and p-tagged participants
- Check up to 5 participants for relay diversity
- Increase max relay limit from 7 to 10 for better coverage
- Add logging for relay collection debugging

This makes NIP-10 threads feel more like proper chat conversations with
accurate participant lists and better relay coverage across the thread.

* refactor: remove debug logging from NIP-10 adapter

Remove informational console.log statements to reduce noise in production.
Keep only console.warn and console.error for actual problems.

This makes the adapter production-ready while maintaining useful error
and warning messages for debugging issues.

* fix: resolve lint errors from logging cleanup

- Fix empty block statement by simplifying next handler
- Prefix unused parameters with underscore (_response, _err)
- All lint checks now pass with 0 errors

* refactor: use AGGREGATOR_RELAYS constant and remove design docs

Replace hardcoded relay URLs with AGGREGATOR_RELAYS constant:
- Import AGGREGATOR_RELAYS from @/services/loaders
- Use constant for fallback relays in getThreadRelays()
- Use constant for default relays in getDefaultRelays()

Remove design documentation files (no longer needed):
- docs/nip10-thread-chat-design.md
- docs/nip10-thread-chat-examples.md
- docs/nip10-thread-chat-summary.md

This improves maintainability by centralizing relay configuration
and reduces repository clutter.

* refactor: remove relay.nostr.band and update AGGREGATOR_RELAYS

relay.nostr.band is no longer operational, so remove it from the codebase:

AGGREGATOR_RELAYS changes:
- Removed: wss://relay.nostr.band/
- Removed: wss://purplepag.es/
- Added: wss://relay.snort.social/
- Added: wss://relay.damus.io/
- New list: nos.lol, relay.snort.social, relay.primal.net, relay.damus.io

Updated code:
- src/services/loaders.ts: Updated AGGREGATOR_RELAYS constant
- src/lib/chat/adapters/nip-53-adapter.ts: Use AGGREGATOR_RELAYS instead of hardcoded relays

Updated tests:
- All test files updated to expect new relay URLs
- Replaced relay.nostr.band references with relay.snort.social
- Replaced purplepag.es references with relay.snort.social
- Fixed URL formats to include trailing slashes for normalization

All 980 tests passing ✓

* fix: change grimRelays from let to const in supporters.ts

Fix lint error from rebase - grimRelays is never reassigned so it should
use const instead of let.

* style: reduce padding on sign-in message to match composer

Change from px-3 py-2 to px-2 py-1 to match the horizontal and vertical
padding of the logged-in message composer (px-2 py-1), ensuring
consistent height between logged-in and logged-out states.

* feat: make sign-in message clickable to open login dialog

Add clickable 'Sign in' link to the logged-out message composer:
- Import LoginDialog component
- Add showLogin state management
- Make 'Sign in' text an underlined button that opens the login dialog
- Add LoginDialog component with controlled state

This provides a better UX by allowing users to quickly sign in
directly from the chat interface.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-19 16:47:12 +01:00
committed by GitHub
parent 14f19a4517
commit 8f008ddd39
14 changed files with 1148 additions and 68 deletions

View File

@@ -11,6 +11,7 @@ import {
Paperclip,
Copy,
CopyCheck,
FileText,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { getZapRequest } from "applesauce-common/helpers/zap";
@@ -24,6 +25,7 @@ import type {
} from "@/types/chat";
import { CHAT_KINDS } from "@/types/chat";
// import { NipC7Adapter } from "@/lib/chat/adapters/nip-c7-adapter"; // Coming soon
import { Nip10Adapter } from "@/lib/chat/adapters/nip-10-adapter";
import { Nip29Adapter } from "@/lib/chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "@/lib/chat/adapters/nip-53-adapter";
import type { ChatProtocolAdapter } from "@/lib/chat/adapters/base-adapter";
@@ -41,6 +43,7 @@ import { StatusBadge } from "./live/StatusBadge";
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
import { useGrimoire } from "@/core/state";
import { Button } from "./ui/button";
import LoginDialog from "./nostr/LoginDialog";
import {
MentionEditor,
type MentionEditorHandle,
@@ -589,6 +592,9 @@ export function ChatViewer({
// State for tooltip open (for mobile tap support)
const [tooltipOpen, setTooltipOpen] = useState(false);
// State for login dialog
const [showLogin, setShowLogin] = useState(false);
// Handle sending messages with error handling
const handleSend = async (
content: string,
@@ -731,7 +737,9 @@ export function ChatViewer({
// Handle NIP badge click
const handleNipClick = useCallback(() => {
if (conversation?.protocol === "nip-29") {
if (conversation?.protocol === "nip-10") {
addWindow("nip", { number: 10 });
} else if (conversation?.protocol === "nip-29") {
addWindow("nip", { number: 29 });
} else if (conversation?.protocol === "nip-53") {
addWindow("nip", { number: 53 });
@@ -745,33 +753,63 @@ export function ChatViewer({
? conversation?.metadata?.liveActivity
: undefined;
// Derive participants from messages for live activities (unique pubkeys who have chatted)
// Derive participants from messages for live activities and NIP-10 threads
const derivedParticipants = useMemo(() => {
if (conversation?.type !== "live-chat" || !messages) {
return conversation?.participants || [];
}
// NIP-10 threads: derive from messages with OP first
if (protocol === "nip-10" && messages && conversation) {
const rootAuthor = conversation.metadata?.rootEventId
? messages.find((m) => m.id === conversation.metadata?.rootEventId)
?.author
: undefined;
const hostPubkey = liveActivity?.hostPubkey;
const participants: { pubkey: string; role: "host" | "member" }[] = [];
const participants: { pubkey: string; role: "op" | "member" }[] = [];
// Host always first
if (hostPubkey) {
participants.push({ pubkey: hostPubkey, role: "host" });
}
// Add other participants from messages (excluding host)
const seen = new Set(hostPubkey ? [hostPubkey] : []);
for (const msg of messages) {
if (msg.type !== "system" && !seen.has(msg.author)) {
seen.add(msg.author);
participants.push({ pubkey: msg.author, role: "member" });
// OP (root author) always first
if (rootAuthor) {
participants.push({ pubkey: rootAuthor, role: "op" });
}
// Add other participants from messages (excluding OP)
const seen = new Set(rootAuthor ? [rootAuthor] : []);
for (const msg of messages) {
if (msg.type !== "system" && !seen.has(msg.author)) {
seen.add(msg.author);
participants.push({ pubkey: msg.author, role: "member" });
}
}
return participants;
}
return participants;
// Live activities: derive from messages with host first
if (conversation?.type === "live-chat" && messages) {
const hostPubkey = liveActivity?.hostPubkey;
const participants: { pubkey: string; role: "host" | "member" }[] = [];
// Host always first
if (hostPubkey) {
participants.push({ pubkey: hostPubkey, role: "host" });
}
// Add other participants from messages (excluding host)
const seen = new Set(hostPubkey ? [hostPubkey] : []);
for (const msg of messages) {
if (msg.type !== "system" && !seen.has(msg.author)) {
seen.add(msg.author);
participants.push({ pubkey: msg.author, role: "member" });
}
}
return participants;
}
// Other protocols: use static participants from conversation
return conversation?.participants || [];
}, [
protocol,
conversation?.type,
conversation?.participants,
conversation?.metadata?.rootEventId,
messages,
liveActivity?.hostPubkey,
]);
@@ -874,9 +912,16 @@ export function ChatViewer({
conversation.type === "live-chat") && (
<span className="text-primary-foreground/60"></span>
)}
<span className="capitalize text-primary-foreground/80">
{conversation.type}
</span>
{conversation.protocol === "nip-10" ? (
<span className="flex items-center gap-1 text-primary-foreground/80">
<FileText className="size-3" />
Thread
</span>
) : (
<span className="capitalize text-primary-foreground/80">
{conversation.type}
</span>
)}
</div>
{/* Live Activity Status */}
{liveActivity?.status && (
@@ -946,7 +991,9 @@ export function ChatViewer({
alignToBottom
components={{
Header: () =>
hasMore && conversationResult.status === "success" ? (
hasMore &&
conversationResult.status === "success" &&
protocol !== "nip-10" ? (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}
@@ -1060,21 +1107,32 @@ export function ChatViewer({
{uploadDialog}
</div>
) : (
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
Sign in to send messages
<div className="border-t px-2 py-1 text-center text-sm text-muted-foreground">
<button
onClick={() => setShowLogin(true)}
className="hover:text-foreground transition-colors underline"
>
Sign in
</button>{" "}
to send messages
</div>
)}
{/* Login dialog */}
<LoginDialog open={showLogin} onOpenChange={setShowLogin} />
</div>
);
}
/**
* Get the appropriate adapter for a protocol
* Currently NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Currently NIP-10 (thread chat), NIP-29 (relay-based groups) and NIP-53 (live activity chat) are supported
* Other protocols will be enabled in future phases
*/
function getAdapter(protocol: ChatProtocol): ChatProtocolAdapter {
switch (protocol) {
case "nip-10":
return new Nip10Adapter();
// case "nip-c7": // Phase 1 - Simple chat (coming soon)
// return new NipC7Adapter();
case "nip-29":

View File

@@ -133,6 +133,18 @@ export function ChatMessageContextMenu({
setEmojiPickerOpen(true);
};
const openZapWindow = () => {
if (!zapConfig || !zapConfig.supported) return;
addWindow("zap", {
recipientPubkey: zapConfig.recipientPubkey,
eventPointer: zapConfig.eventPointer,
addressPointer: zapConfig.addressPointer,
customTags: zapConfig.customTags,
relays: zapConfig.relays,
});
};
const handleEmojiSelect = async (emoji: string, customEmoji?: EmojiTag) => {
if (!conversation || !adapter) {
console.error(
@@ -148,18 +160,6 @@ export function ChatMessageContextMenu({
}
};
const openZapWindow = () => {
if (!zapConfig || !zapConfig.supported) return;
addWindow("zap", {
recipientPubkey: zapConfig.recipientPubkey,
eventPointer: zapConfig.eventPointer,
addressPointer: zapConfig.addressPointer,
customTags: zapConfig.customTags,
relays: zapConfig.relays,
});
};
return (
<>
<ContextMenu>

View File

@@ -23,12 +23,15 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
// Get relays for this conversation (immutable pattern)
const liveActivityRelays = conversation.metadata?.liveActivity?.relays;
const metadataRelays = conversation.metadata?.relays;
const relays: string[] =
Array.isArray(liveActivityRelays) && liveActivityRelays.length > 0
? liveActivityRelays
: conversation.metadata?.relayUrl
? [conversation.metadata.relayUrl]
: [];
: Array.isArray(metadataRelays) && metadataRelays.length > 0
? metadataRelays
: conversation.metadata?.relayUrl
? [conversation.metadata.relayUrl]
: [];
// Pre-compute normalized URLs and state lookups in a single pass (O(n))
const relayData = relays.map((url) => {

View File

@@ -10,7 +10,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu, Copy, Check, FileJson, ExternalLink, Zap } from "lucide-react";
import {
Menu,
Copy,
Check,
FileJson,
ExternalLink,
Zap,
MessageSquare,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { JsonViewer } from "@/components/JsonViewer";
@@ -184,6 +192,29 @@ export function EventMenu({ event }: { event: NostrEvent }) {
});
};
const openChatWindow = () => {
// Only kind 1 notes support NIP-10 thread chat
if (event.kind === 1) {
const seenRelaysSet = getSeenRelays(event);
const relays = seenRelaysSet ? Array.from(seenRelaysSet) : [];
// Open chat with NIP-10 thread protocol
addWindow("chat", {
protocol: "nip-10",
identifier: {
type: "thread",
value: {
id: event.id,
relays,
author: event.pubkey,
kind: event.kind,
},
relays,
},
});
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -212,6 +243,12 @@ export function EventMenu({ event }: { event: NostrEvent }) {
<Zap className="size-4 mr-2 text-yellow-500" />
Zap
</DropdownMenuItem>
{event.kind === 1 && (
<DropdownMenuItem onClick={openChatWindow}>
<MessageSquare className="size-4 mr-2" />
Chat
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
{copied ? (

View File

@@ -1,5 +1,6 @@
import type { ChatCommandResult, GroupListIdentifier } from "@/types/chat";
// import { NipC7Adapter } from "./chat/adapters/nip-c7-adapter";
import { Nip10Adapter } from "./chat/adapters/nip-10-adapter";
import { Nip29Adapter } from "./chat/adapters/nip-29-adapter";
import { Nip53Adapter } from "./chat/adapters/nip-53-adapter";
import { nip19 } from "nostr-tools";
@@ -11,11 +12,12 @@ import { nip19 } from "nostr-tools";
* Parse a chat command identifier and auto-detect the protocol
*
* Tries each adapter's parseIdentifier() in priority order:
* 1. NIP-17 (encrypted DMs) - prioritized for privacy
* 2. NIP-28 (channels) - specific event format (kind 40)
* 3. NIP-29 (groups) - specific group ID format
* 4. NIP-53 (live chat) - specific addressable format (kind 30311)
* 5. NIP-C7 (simple chat) - fallback for generic pubkeys
* 1. NIP-10 (thread chat) - nevent/note format for kind 1 threads
* 2. NIP-17 (encrypted DMs) - prioritized for privacy
* 3. NIP-28 (channels) - specific event format (kind 40)
* 4. NIP-29 (groups) - specific group ID format
* 5. NIP-53 (live chat) - specific addressable format (kind 30311)
* 6. NIP-C7 (simple chat) - fallback for generic pubkeys
*
* @param args - Command arguments (first arg is the identifier)
* @returns Parsed result with protocol and identifier
@@ -62,6 +64,7 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
// Try each adapter in priority order
const adapters = [
new Nip10Adapter(), // NIP-10 - Thread chat (nevent/note)
// new Nip17Adapter(), // Phase 2
// new Nip28Adapter(), // Phase 3
new Nip29Adapter(), // Phase 4 - Relay groups
@@ -84,6 +87,10 @@ export function parseChatCommand(args: string[]): ChatCommandResult {
`Unable to determine chat protocol from identifier: ${identifier}
Currently supported formats:
- nevent1.../note1... (NIP-10 thread chat, kind 1 notes)
Examples:
chat nevent1qqsxyz... (thread with relay hints)
chat note1abc... (thread with event ID only)
- relay.com'group-id (NIP-29 relay group, wss:// prefix optional)
Examples:
chat relay.example.com'bitcoin-dev
@@ -99,7 +106,6 @@ Currently supported formats:
chat naddr1... (group list address)
More formats coming soon:
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)
- note/nevent (NIP-28 public channels)`,
- npub/nprofile/hex pubkey (NIP-C7/NIP-17 direct messages)`,
);
}

View File

@@ -0,0 +1,944 @@
import { Observable, firstValueFrom, combineLatest } from "rxjs";
import { map, first, toArray } from "rxjs/operators";
import type { Filter } from "nostr-tools";
import { nip19 } from "nostr-tools";
import {
ChatProtocolAdapter,
type SendMessageOptions,
type ZapConfig,
} from "./base-adapter";
import type {
Conversation,
Message,
ProtocolIdentifier,
ChatCapabilities,
LoadMessagesOptions,
Participant,
} from "@/types/chat";
import type { NostrEvent } from "@/types/nostr";
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 } from "applesauce-core/helpers";
import { EventFactory } from "applesauce-core/event-factory";
import { getNip10References } from "applesauce-common/helpers";
import {
getZapAmount,
getZapSender,
getZapRecipient,
} from "applesauce-common/helpers";
/**
* NIP-10 Adapter - Threaded Notes as Chat
*
* Features:
* - Turn any kind 1 note thread into a chat interface
* - Root event displayed prominently at top
* - All replies shown as chat messages
* - Proper NIP-10 tag structure (root/reply markers)
* - Smart relay selection (merges multiple sources)
*
* Thread ID format: nevent1... or note1...
* Events use "e" tags with markers ("root", "reply")
*/
export class Nip10Adapter extends ChatProtocolAdapter {
readonly protocol = "nip-10" as const;
readonly type = "group" as const; // Threads are multi-participant like groups
/**
* Parse identifier - accepts nevent or note format
* Examples:
* - nevent1qqsxyz... (with relay hints, author, kind)
* - note1abc... (simple event ID)
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Try note format first (simpler)
if (input.startsWith("note1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "note") {
const eventId = decoded.data as string;
return {
type: "thread",
value: { id: eventId },
relays: [],
};
}
} catch {
return null;
}
}
// Try nevent format (includes relay hints)
if (input.startsWith("nevent1")) {
try {
const decoded = nip19.decode(input);
if (decoded.type === "nevent") {
const { id, relays, author, kind } = decoded.data;
// If kind is specified and NOT kind 1, let other adapters handle
if (kind !== undefined && kind !== 1) {
return null;
}
return {
type: "thread",
value: { id, relays, author, kind },
relays: relays || [],
};
}
} catch {
return null;
}
}
return null;
}
/**
* Resolve conversation from thread identifier
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
if (identifier.type !== "thread") {
throw new Error(
`NIP-10 adapter cannot handle identifier type: ${identifier.type}`,
);
}
const pointer = identifier.value;
const relayHints = identifier.relays || [];
// 1. Fetch the provided event
const providedEvent = await this.fetchEvent(pointer.id, relayHints);
if (!providedEvent) {
throw new Error("Event not found");
}
if (providedEvent.kind !== 1) {
throw new Error(`Expected kind 1 note, got kind ${providedEvent.kind}`);
}
// 2. Parse NIP-10 references to find root
const refs = getNip10References(providedEvent);
let rootEvent: NostrEvent;
let rootId: string;
if (refs.root?.e) {
// This is a reply - fetch the root
rootId = refs.root.e.id;
const fetchedRoot = await this.fetchEvent(
rootId,
refs.root.e.relays || [],
);
if (!fetchedRoot) {
throw new Error("Thread root not found");
}
rootEvent = fetchedRoot;
} else {
// No root reference - this IS the root
rootEvent = providedEvent;
rootId = providedEvent.id;
}
// 3. Determine conversation relays
const conversationRelays = await this.getThreadRelays(
rootEvent,
providedEvent,
relayHints,
);
// 4. Extract title from root content
const title = this.extractTitle(rootEvent);
// 5. Build participants list from root and provided event
const participants = this.extractParticipants(rootEvent, providedEvent);
// 6. Build conversation object
return {
id: `nip-10:${rootId}`,
type: "group",
protocol: "nip-10",
title,
participants,
metadata: {
rootEventId: rootId,
providedEventId: providedEvent.id,
description: rootEvent.content.slice(0, 200), // First 200 chars
relays: conversationRelays,
},
unreadCount: 0,
};
}
/**
* Load messages for a thread
*/
loadMessages(
conversation: Conversation,
options?: LoadMessagesOptions,
): Observable<Message[]> {
const rootEventId = conversation.metadata?.rootEventId;
const relays = conversation.metadata?.relays || [];
if (!rootEventId) {
throw new Error("Root event ID required");
}
// Build filter for all thread events:
// - kind 1: replies to root
// - kind 7: reactions
// - kind 9735: zap receipts
const filters: Filter[] = [
// Replies: kind 1 events with e-tag pointing to root
{
kinds: [1],
"#e": [rootEventId],
limit: options?.limit || 100,
},
// Reactions: kind 7 events with e-tag pointing to root or replies
{
kinds: [7],
"#e": [rootEventId],
limit: 200, // Reactions are small, fetch more
},
// Zaps: kind 9735 receipts with e-tag pointing to root or replies
{
kinds: [9735],
"#e": [rootEventId],
limit: 100,
},
];
if (options?.before) {
filters[0].until = options.before;
}
if (options?.after) {
filters[0].since = options.after;
}
// Clean up any existing subscription
const conversationId = `nip-10:${rootEventId}`;
this.cleanup(conversationId);
// Start persistent subscription
const subscription = pool
.subscription(relays, filters, { eventStore })
.subscribe({
next: (_response) => {
// EOSE or event - both handled by EventStore
},
});
// Store subscription for cleanup
this.subscriptions.set(conversationId, subscription);
// Return observable from EventStore
// Combine root event with replies
const rootEvent$ = eventStore.event(rootEventId);
const replies$ = eventStore.timeline({
kinds: [1, 7, 9735],
"#e": [rootEventId],
});
return combineLatest([rootEvent$, replies$]).pipe(
map(([rootEvent, replyEvents]) => {
const messages: Message[] = [];
// Add root event as first message
if (rootEvent) {
const rootMessage = this.rootEventToMessage(
rootEvent,
conversationId,
rootEventId,
);
if (rootMessage) {
messages.push(rootMessage);
}
}
// Convert replies to messages
const replyMessages = replyEvents
.map((event) =>
this.eventToMessage(event, conversationId, rootEventId),
)
.filter((msg): msg is Message => msg !== null);
messages.push(...replyMessages);
// Sort by timestamp ascending (chronological order)
return messages.sort((a, b) => a.timestamp - b.timestamp);
}),
);
}
/**
* Load more historical messages (pagination)
*/
async loadMoreMessages(
conversation: Conversation,
before: number,
): Promise<Message[]> {
const rootEventId = conversation.metadata?.rootEventId;
const relays = conversation.metadata?.relays || [];
if (!rootEventId) {
throw new Error("Root event ID required");
}
// Same filters as loadMessages but with until for pagination
const filters: Filter[] = [
{
kinds: [1],
"#e": [rootEventId],
until: before,
limit: 50,
},
{
kinds: [7],
"#e": [rootEventId],
until: before,
limit: 100,
},
{
kinds: [9735],
"#e": [rootEventId],
until: before,
limit: 50,
},
];
// One-shot request to fetch older messages
const events = await firstValueFrom(
pool.request(relays, filters, { eventStore }).pipe(toArray()),
);
const conversationId = `nip-10:${rootEventId}`;
// Convert events to messages
const messages = events
.map((event) => this.eventToMessage(event, conversationId, rootEventId))
.filter((msg): msg is Message => msg !== null);
// Reverse for ascending chronological order
return messages.reverse();
}
/**
* Send a message (reply) to the thread
*/
async sendMessage(
conversation: Conversation,
content: string,
options?: SendMessageOptions,
): Promise<void> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const rootEventId = conversation.metadata?.rootEventId;
const relays = conversation.metadata?.relays || [];
if (!rootEventId) {
throw new Error("Root event ID required");
}
// Fetch root event for building tags
const rootEvent = await firstValueFrom(eventStore.event(rootEventId), {
defaultValue: undefined,
});
if (!rootEvent) {
throw new Error("Root event not found in store");
}
// Create event factory
const factory = new EventFactory();
factory.setSigner(activeSigner);
// Build NIP-10 tags
const tags: string[][] = [];
// Determine if we're replying to root or to another reply
if (options?.replyTo && options.replyTo !== rootEventId) {
// Replying to another reply
const parentEvent = await firstValueFrom(
eventStore.event(options.replyTo),
{ defaultValue: undefined },
);
if (!parentEvent) {
throw new Error("Parent event not found");
}
// Add root marker (always first)
tags.push(["e", rootEventId, relays[0] || "", "root", rootEvent.pubkey]);
// Add reply marker (the direct parent)
tags.push([
"e",
options.replyTo,
relays[0] || "",
"reply",
parentEvent.pubkey,
]);
// Add p-tag for root author
tags.push(["p", rootEvent.pubkey]);
// Add p-tag for parent author (if different)
if (parentEvent.pubkey !== rootEvent.pubkey) {
tags.push(["p", parentEvent.pubkey]);
}
// Add p-tags from parent event (all mentioned users)
for (const tag of parentEvent.tags) {
if (tag[0] === "p" && tag[1]) {
const pubkey = tag[1];
// Don't duplicate tags
if (!tags.some((t) => t[0] === "p" && t[1] === pubkey)) {
tags.push(["p", pubkey]);
}
}
}
} else {
// Replying directly to root
tags.push(["e", rootEventId, relays[0] || "", "root", rootEvent.pubkey]);
// Add p-tag for root author
tags.push(["p", rootEvent.pubkey]);
// Add p-tags from root event
for (const tag of rootEvent.tags) {
if (tag[0] === "p" && tag[1]) {
const pubkey = tag[1];
// Don't duplicate tags
if (!tags.some((t) => t[0] === "p" && t[1] === pubkey)) {
tags.push(["p", pubkey]);
}
}
}
}
// Add NIP-30 emoji tags
if (options?.emojiTags) {
for (const emoji of options.emojiTags) {
tags.push(["emoji", emoji.shortcode, emoji.url]);
}
}
// Add NIP-92 imeta tags for blob attachments
if (options?.blobAttachments) {
for (const blob of options.blobAttachments) {
const imetaParts = [`url ${blob.url}`];
if (blob.sha256) imetaParts.push(`x ${blob.sha256}`);
if (blob.mimeType) imetaParts.push(`m ${blob.mimeType}`);
if (blob.size) imetaParts.push(`size ${blob.size}`);
tags.push(["imeta", ...imetaParts]);
}
}
// Create and sign kind 1 event
const draft = await factory.build({ kind: 1, content, tags });
const event = await factory.sign(draft);
// Publish to conversation relays
await publishEventToRelays(event, relays);
}
/**
* Send a reaction (kind 7) to a message in the thread
*/
async sendReaction(
conversation: Conversation,
messageId: string,
emoji: string,
customEmoji?: { shortcode: string; url: string },
): Promise<void> {
const activePubkey = accountManager.active$.value?.pubkey;
const activeSigner = accountManager.active$.value?.signer;
if (!activePubkey || !activeSigner) {
throw new Error("No active account or signer");
}
const relays = conversation.metadata?.relays || [];
// Fetch the message being reacted to
const messageEvent = await firstValueFrom(eventStore.event(messageId), {
defaultValue: undefined,
});
if (!messageEvent) {
throw new Error("Message event not found");
}
// Create event factory
const factory = new EventFactory();
factory.setSigner(activeSigner);
const tags: string[][] = [
["e", messageId], // Event being reacted to
["k", "1"], // Kind of event being reacted to
["p", messageEvent.pubkey], // Author of message
];
// Add NIP-30 custom emoji tag if provided
if (customEmoji) {
tags.push(["emoji", customEmoji.shortcode, customEmoji.url]);
}
// Create and sign kind 7 event
const draft = await factory.build({ kind: 7, content: emoji, tags });
const event = await factory.sign(draft);
// Publish to conversation relays
await publishEventToRelays(event, relays);
}
/**
* Get zap configuration for a message in a NIP-10 thread
* Returns configuration for how zap requests should be constructed
*/
getZapConfig(message: Message, conversation: Conversation): ZapConfig {
// Get relays from conversation metadata
const relays = conversation.metadata?.relays || [];
// Build eventPointer for the message being zapped
const eventPointer = {
id: message.id,
author: message.author,
relays,
};
// Recipient is the message author
return {
supported: true,
recipientPubkey: message.author,
eventPointer,
relays,
};
}
/**
* Load a replied-to message by ID
*/
async loadReplyMessage(
conversation: Conversation,
eventId: string,
): Promise<NostrEvent | null> {
// First check EventStore - might already be loaded
const cachedEvent = await eventStore
.event(eventId)
.pipe(first())
.toPromise();
if (cachedEvent) {
return cachedEvent;
}
// Not in store, fetch from conversation relays
const relays = conversation.metadata?.relays || [];
if (relays.length === 0) {
console.warn("[NIP-10] 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;
}
/**
* Get capabilities of NIP-10 protocol
*/
getCapabilities(): ChatCapabilities {
return {
supportsEncryption: false,
supportsThreading: true,
supportsModeration: false,
supportsRoles: false,
supportsGroupManagement: false,
canCreateConversations: false,
requiresRelay: false,
};
}
/**
* Extract a readable title from root event content
*/
private extractTitle(rootEvent: NostrEvent): string {
const content = rootEvent.content.trim();
if (!content) return `Thread by ${rootEvent.pubkey.slice(0, 8)}...`;
// Try to get first line
const firstLine = content.split("\n")[0];
if (firstLine && firstLine.length <= 50) {
return firstLine;
}
// Truncate to 50 chars
if (content.length <= 50) {
return content;
}
return content.slice(0, 47) + "...";
}
/**
* Extract unique participants from thread
*/
private extractParticipants(
rootEvent: NostrEvent,
providedEvent: NostrEvent,
): Participant[] {
const participants = new Map<string, Participant>();
// Root author is always first
participants.set(rootEvent.pubkey, {
pubkey: rootEvent.pubkey,
role: "op", // Root author is "op" (original poster) of the thread
});
// Add p-tags from root event
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",
});
}
}
// Add provided event author (if different)
if (providedEvent.pubkey !== rootEvent.pubkey) {
participants.set(providedEvent.pubkey, {
pubkey: providedEvent.pubkey,
role: "member",
});
}
// Add p-tags from provided event
for (const tag of providedEvent.tags) {
if (tag[0] === "p" && tag[1] && tag[1] !== providedEvent.pubkey) {
participants.set(tag[1], {
pubkey: tag[1],
role: "member",
});
}
}
return Array.from(participants.values());
}
/**
* Determine best relays for the thread
* Includes relays from root author, provided event author, p-tagged participants, and active user
*/
private async getThreadRelays(
rootEvent: NostrEvent,
providedEvent: 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 (NIP-65) - highest priority
try {
const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey);
rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r)));
} catch (err) {
console.warn("[NIP-10] Failed to get root author outbox:", err);
}
// 3. Collect unique participant pubkeys from both events' p-tags
const participantPubkeys = new Set<string>();
// Add p-tags from root event
for (const tag of rootEvent.tags) {
if (tag[0] === "p" && tag[1]) {
participantPubkeys.add(tag[1]);
}
}
// Add p-tags from provided event
for (const tag of providedEvent.tags) {
if (tag[0] === "p" && tag[1]) {
participantPubkeys.add(tag[1]);
}
}
// Add provided event author if different from root
if (providedEvent.pubkey !== rootEvent.pubkey) {
participantPubkeys.add(providedEvent.pubkey);
}
// 4. Fetch outbox relays from participant subset (limit to avoid slowdown)
// Take first 5 participants to get relay diversity without excessive fetching
const participantsToCheck = Array.from(participantPubkeys).slice(0, 5);
for (const pubkey of participantsToCheck) {
try {
const outbox = await this.getOutboxRelays(pubkey);
// Add 1 relay from each participant for diversity
if (outbox.length > 0) {
relays.add(normalizeURL(outbox[0]));
}
} catch (_err) {
// Silently continue if participant has no relay list
}
}
// 5. Active user's outbox (for publishing replies)
const activePubkey = accountManager.active$.value?.pubkey;
if (activePubkey && !participantPubkeys.has(activePubkey)) {
try {
const userOutbox = await this.getOutboxRelays(activePubkey);
userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r)));
} catch (err) {
console.warn("[NIP-10] Failed to get user outbox:", err);
}
}
// 6. Fallback to aggregator relays if we have too few
if (relays.size < 3) {
AGGREGATOR_RELAYS.forEach((r) => relays.add(r));
}
// Limit to 10 relays max for performance
return Array.from(relays).slice(0, 10);
}
/**
* Helper: 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 [];
// Extract write relays (r tags with "write" or no marker)
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); // Limit to 5
}
/**
* Helper: Fetch an event by ID from relays
*/
private async fetchEvent(
eventId: string,
relayHints: string[] = [],
): Promise<NostrEvent | null> {
// Check EventStore first
const cached = await firstValueFrom(eventStore.event(eventId), {
defaultValue: undefined,
});
if (cached) return cached;
// Not in store - fetch from relays
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") {
// EOSE received
clearTimeout(timeout);
sub.unsubscribe();
resolve();
} else {
// Event received
events.push(response);
}
},
error: (err) => {
clearTimeout(timeout);
console.error(`[NIP-10] Fetch error:`, err);
sub.unsubscribe();
resolve();
},
});
});
return events[0] || null;
}
/**
* Helper: Get default relays to use when no hints provided
*/
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);
}
// Fallback to aggregator relays
return AGGREGATOR_RELAYS;
}
/**
* Convert root event to Message object
*/
private rootEventToMessage(
event: NostrEvent,
conversationId: string,
_rootEventId: string,
): Message | null {
if (event.kind !== 1) {
return null;
}
// Root event has no replyTo field
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo: undefined,
protocol: "nip-10",
metadata: {
encrypted: false,
},
event,
};
}
/**
* Convert Nostr event to Message object
*/
private eventToMessage(
event: NostrEvent,
conversationId: string,
rootEventId: string,
): Message | null {
// Handle zap receipts (kind 9735)
if (event.kind === 9735) {
return this.zapToMessage(event, conversationId);
}
// Handle reactions (kind 7) - skip for now, handled via MessageReactions
if (event.kind === 7) {
return null;
}
// Handle replies (kind 1)
if (event.kind === 1) {
const refs = getNip10References(event);
// Determine what this reply is responding to
let replyTo: string | undefined;
if (refs.reply?.e) {
// Replying to another reply
replyTo = refs.reply.e.id;
} else if (refs.root?.e) {
// Replying directly to root
replyTo = refs.root.e.id;
} else {
// Malformed or legacy reply - assume replying to root
replyTo = rootEventId;
}
return {
id: event.id,
conversationId,
author: event.pubkey,
content: event.content,
timestamp: event.created_at,
type: "user",
replyTo,
protocol: "nip-10",
metadata: {
encrypted: false,
},
event,
};
}
console.warn(`[NIP-10] Unknown event kind: ${event.kind}`);
return null;
}
/**
* Convert zap receipt to Message object
*/
private zapToMessage(
zapReceipt: NostrEvent,
conversationId: string,
): Message {
// Extract zap metadata using applesauce helpers
const amount = getZapAmount(zapReceipt);
const sender = getZapSender(zapReceipt);
const recipient = getZapRecipient(zapReceipt);
// Find what event is being zapped (e-tag in zap receipt)
const eTag = zapReceipt.tags.find((t) => t[0] === "e");
const replyTo = eTag?.[1];
// Get zap request event for comment
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-10",
metadata: {
zapAmount: amount,
zapRecipient: recipient,
},
event: zapReceipt,
};
}
}

View File

@@ -21,6 +21,7 @@ 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 {
parseLiveActivity,
getLiveStatus,
@@ -720,7 +721,7 @@ export class Nip53Adapter extends ChatProtocolAdapter {
}
// Default fallback relays for live activities
return ["wss://relay.damus.io", "wss://nos.lol", "wss://purplepag.es"];
return AGGREGATOR_RELAYS;
}
/**

View File

@@ -296,7 +296,7 @@ describe("relayReferences transformer", () => {
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://purplepag.es",
"wss://relay.primal.net",
"wss://nostr.wine",
];

View File

@@ -11,9 +11,10 @@ vi.mock("@/services/relay-list-cache", () => ({
// Mock the loaders for AGGREGATOR_RELAYS
vi.mock("@/services/loaders", () => ({
AGGREGATOR_RELAYS: [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://nos.lol/",
"wss://relay.snort.social/",
"wss://relay.primal.net/",
"wss://relay.damus.io/",
],
}));
@@ -152,7 +153,7 @@ describe("selectZapRelays", () => {
expect(result.relays.length).toBeGreaterThan(0);
expect(result.sources.fallback.length).toBeGreaterThan(0);
expect(result.relays).toContain("wss://relay.damus.io");
expect(result.relays).toContain("wss://relay.damus.io/");
});
it("should use fallback when recipient has empty relay list", async () => {

View File

@@ -385,9 +385,9 @@ describe("eventLoader", () => {
// Should only have aggregator relays (normalized with trailing slash)
expect(relays).toContain("wss://nos.lol/");
expect(relays).toContain("wss://nos.lol/");
expect(relays).toContain("wss://purplepag.es/");
expect(relays).toContain("wss://relay.snort.social/");
expect(relays).toContain("wss://relay.primal.net/");
expect(relays).toContain("wss://relay.damus.io/");
});
it("should limit cached relays to 3", () => {

View File

@@ -53,8 +53,9 @@ function extractRelayContext(event: NostrEvent): {
// IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys
export const AGGREGATOR_RELAYS = [
"wss://nos.lol/",
"wss://purplepag.es/",
"wss://relay.snort.social/",
"wss://relay.primal.net/",
"wss://relay.damus.io/",
];
// Base event loader (used internally)

View File

@@ -76,7 +76,7 @@ describe("selectRelaysForFilter", () => {
const relayListEvent = createRelayListEvent(testSecretKeys[0], [
["r", "wss://relay.damus.io"],
["r", "wss://nos.lol"],
["r", "wss://purplepag.es", "read"],
["r", "wss://relay.snort.social", "read"],
]);
// Add to event store
@@ -99,7 +99,7 @@ describe("selectRelaysForFilter", () => {
result.relays.includes("wss://nos.lol/");
expect(hasWriteRelay).toBe(true);
// Should NOT include read-only relay
expect(result.relays).not.toContain("wss://purplepag.es/");
expect(result.relays).not.toContain("wss://relay.snort.social/");
});
it("should handle multiple authors", async () => {
@@ -141,7 +141,7 @@ describe("selectRelaysForFilter", () => {
const relayListEvent = createRelayListEvent(testSecretKeys[2], [
["r", "wss://relay.damus.io", "write"],
["r", "wss://nos.lol", "read"],
["r", "wss://purplepag.es", "read"],
["r", "wss://relay.snort.social", "read"],
]);
eventStore.add(relayListEvent);
@@ -160,7 +160,7 @@ describe("selectRelaysForFilter", () => {
// Should include at least one read relay - selectOptimalRelays may pick subset
const hasReadRelay =
result.relays.includes("wss://nos.lol/") ||
result.relays.includes("wss://purplepag.es/");
result.relays.includes("wss://relay.snort.social/");
expect(hasReadRelay).toBe(true);
// Should NOT include write-only relay
expect(result.relays).not.toContain("wss://relay.damus.io/");

View File

@@ -70,7 +70,7 @@ class SupportersService {
private async subscribeToZapReceipts() {
try {
// Start with hardcoded relays for immediate cold start
let grimRelays = [...GRIMOIRE_ZAP_RELAYS];
const grimRelays = [...GRIMOIRE_ZAP_RELAYS];
// Fetch relay list in background (non-blocking)
// Don't await - let it happen in parallel with subscription

View File

@@ -14,7 +14,13 @@ export const CHAT_KINDS = [
/**
* Chat protocol identifier
*/
export type ChatProtocol = "nip-c7" | "nip-17" | "nip-28" | "nip-29" | "nip-53";
export type ChatProtocol =
| "nip-c7"
| "nip-17"
| "nip-28"
| "nip-29"
| "nip-53"
| "nip-10";
/**
* Conversation type
@@ -24,7 +30,7 @@ export type ConversationType = "dm" | "channel" | "group" | "live-chat";
/**
* Participant role in a conversation
*/
export type ParticipantRole = "admin" | "moderator" | "member" | "host";
export type ParticipantRole = "admin" | "moderator" | "member" | "host" | "op";
/**
* Participant in a conversation
@@ -61,8 +67,8 @@ export interface ConversationMetadata {
// NIP-29 group
groupId?: string; // host'group-id format
relayUrl?: string; // Relay enforcing group rules
description?: string; // Group description
relayUrl?: string; // Relay URL for single-relay protocols
description?: string; // Group/thread description
icon?: string; // Group icon/picture URL
// NIP-53 live chat
@@ -76,6 +82,12 @@ export interface ConversationMetadata {
// NIP-17 DM
encrypted?: boolean;
giftWrapped?: boolean;
// NIP-10 thread
rootEventId?: string; // Thread root event ID
providedEventId?: string; // Original event from nevent (may be reply)
threadDepth?: number; // Approximate depth of thread
relays?: string[]; // Relays for this conversation
}
/**
@@ -206,6 +218,22 @@ export interface GroupListIdentifier {
relays?: string[];
}
/**
* NIP-10 thread identifier (kind 1 note thread)
*/
export interface ThreadIdentifier {
type: "thread";
/** Event pointer to the provided event (may be root or a reply) */
value: {
id: string;
relays?: string[];
author?: string;
kind?: number;
};
/** Relay hints from nevent encoding */
relays?: string[];
}
/**
* Protocol-specific identifier - discriminated union
* Returned by adapter parseIdentifier()
@@ -216,7 +244,8 @@ export type ProtocolIdentifier =
| DMIdentifier
| NIP05Identifier
| ChannelIdentifier
| GroupListIdentifier;
| GroupListIdentifier
| ThreadIdentifier;
/**
* Chat command parsing result