mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
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
This commit is contained in:
612
docs/nip10-thread-chat-design.md
Normal file
612
docs/nip10-thread-chat-design.md
Normal file
@@ -0,0 +1,612 @@
|
||||
# NIP-10 Thread Chat Design
|
||||
|
||||
## Overview
|
||||
|
||||
Support `chat nevent1...` / `chat note1...` to treat NIP-10 threaded conversations as chat interfaces. The conversation revolves around the thread root event, with all replies displayed as chat messages.
|
||||
|
||||
## User Experience
|
||||
|
||||
### Command
|
||||
|
||||
```bash
|
||||
chat nevent1qqsxyz... # Open thread as chat
|
||||
chat note1abc... # Open thread as chat (converts to nevent internally)
|
||||
```
|
||||
|
||||
### Visual Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [Root Event Title or Truncated] │ ← Header (conversation title)
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ ROOT EVENT │ │ ← Centered, full feed renderer
|
||||
│ │ (feed renderer) │ │ (BaseEventContainer, actions, etc.)
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ ────────────────────────────────── │ ← Separator
|
||||
│ │
|
||||
│ Alice: Hey, what do you think? │ ← Replies as chat messages
|
||||
│ └─ ↳ root │ (show what they're replying to)
|
||||
│ │
|
||||
│ Bob: I agree with Alice! │
|
||||
│ └─ ↳ Alice │
|
||||
│ │
|
||||
│ ⚡ 1000 Alice │ ← Zaps as special messages
|
||||
│ │
|
||||
│ [──────────────────] [Send] │ ← Composer
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Protocol Identifier
|
||||
|
||||
**New type in `src/types/chat.ts`**:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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: EventPointer;
|
||||
/** Relay hints from nevent encoding */
|
||||
relays?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
Update `ProtocolIdentifier` union:
|
||||
```typescript
|
||||
export type ProtocolIdentifier =
|
||||
| GroupIdentifier
|
||||
| LiveActivityIdentifier
|
||||
| DMIdentifier
|
||||
| NIP05Identifier
|
||||
| ChannelIdentifier
|
||||
| GroupListIdentifier
|
||||
| ThreadIdentifier; // ← Add this
|
||||
```
|
||||
|
||||
Update `ChatProtocol` type:
|
||||
```typescript
|
||||
export type ChatProtocol =
|
||||
| "nip-c7"
|
||||
| "nip-17"
|
||||
| "nip-28"
|
||||
| "nip-29"
|
||||
| "nip-53"
|
||||
| "nip-10"; // ← Add this
|
||||
```
|
||||
|
||||
### 2. Chat Parser
|
||||
|
||||
**Update `src/lib/chat-parser.ts`**:
|
||||
|
||||
```typescript
|
||||
import { Nip10Adapter } from "./chat/adapters/nip-10-adapter";
|
||||
|
||||
export function parseChatCommand(args: string[]): ChatCommandResult {
|
||||
// ... existing code ...
|
||||
|
||||
// Try each adapter in priority order
|
||||
const adapters = [
|
||||
new Nip10Adapter(), // ← Add before others (to catch nevent/note)
|
||||
new Nip29Adapter(),
|
||||
new Nip53Adapter(),
|
||||
];
|
||||
|
||||
// ... rest of function ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. NIP-10 Adapter
|
||||
|
||||
**New file: `src/lib/chat/adapters/nip-10-adapter.ts`**
|
||||
|
||||
#### Core Methods
|
||||
|
||||
**`parseIdentifier(input: string)`**:
|
||||
- Match `nevent1...` or `note1...` strings
|
||||
- Decode to EventPointer
|
||||
- Return ThreadIdentifier if kind 1 or unknown kind
|
||||
- Return null for other kinds (let other adapters handle)
|
||||
|
||||
**`resolveConversation(identifier: ThreadIdentifier)`**:
|
||||
1. Fetch provided event from relays
|
||||
2. If kind ≠ 1, throw error
|
||||
3. Parse NIP-10 references with `getNip10References(event)`
|
||||
4. Find root event:
|
||||
- If `refs.root` exists: fetch root event
|
||||
- Else: provided event IS the root
|
||||
5. Determine conversation relays (see Relay Strategy below)
|
||||
6. Extract title from root content (first line or truncate to ~50 chars)
|
||||
7. Build participants list from all p-tags in thread
|
||||
8. Return Conversation object
|
||||
|
||||
**`loadMessages(conversation: Conversation)`**:
|
||||
1. Subscribe to:
|
||||
- All kind 1 replies (e-tags pointing to root)
|
||||
- All kind 7 reactions (e-tags pointing to root or replies)
|
||||
- All kind 9735 zaps (e-tags pointing to root or replies)
|
||||
2. Convert events to Message objects:
|
||||
- Parse NIP-10 references to determine reply hierarchy
|
||||
- For direct replies to root: `replyTo = root.id`
|
||||
- For nested replies: `replyTo = refs.reply.e?.id`
|
||||
- Zaps: extract amount, sender, recipient from zap event
|
||||
3. Return Observable<Message[]> sorted by created_at
|
||||
|
||||
**`loadMoreMessages(conversation: Conversation, before: number)`**:
|
||||
- Same filter as loadMessages but with `until: before`
|
||||
- One-shot request for pagination
|
||||
|
||||
**`sendMessage(conversation: Conversation, content: string, options?)`**:
|
||||
1. Create kind 1 event
|
||||
2. Add NIP-10 tags:
|
||||
- Root tag: `["e", rootId, rootRelay, "root", rootAuthor]`
|
||||
- Reply tag (if replying to a reply): `["e", parentId, parentRelay, "reply", parentAuthor]`
|
||||
- If replying directly to root: only root tag
|
||||
3. Add p-tags:
|
||||
- Root author
|
||||
- Parent author (if different)
|
||||
- All authors mentioned in parent event
|
||||
4. Add emoji tags (NIP-30) if provided
|
||||
5. Add imeta tags (NIP-92) for attachments
|
||||
6. Publish to conversation relays
|
||||
|
||||
**`sendReaction(conversation: Conversation, messageId: string, emoji: string)`**:
|
||||
1. Create kind 7 event
|
||||
2. Add tags:
|
||||
- `["e", messageId]` - event being reacted to
|
||||
- `["k", "1"]` - kind of event being reacted to
|
||||
- `["p", messageAuthor]` - author of message
|
||||
3. Add NIP-30 custom emoji tag if provided
|
||||
4. Publish to conversation relays
|
||||
|
||||
**`loadReplyMessage(conversation: Conversation, eventId: string)`**:
|
||||
- Check EventStore first
|
||||
- If not found, fetch from conversation relays
|
||||
- Return NostrEvent or null
|
||||
|
||||
**`getCapabilities()`**:
|
||||
```typescript
|
||||
return {
|
||||
supportsEncryption: false,
|
||||
supportsThreading: true,
|
||||
supportsModeration: false,
|
||||
supportsRoles: false,
|
||||
supportsGroupManagement: false,
|
||||
canCreateConversations: false,
|
||||
requiresRelay: false,
|
||||
};
|
||||
```
|
||||
|
||||
#### Relay Strategy
|
||||
|
||||
Determine relays using this priority:
|
||||
|
||||
1. **Root event relays** (from `eventStore` seen relays)
|
||||
2. **Provided event relays** (from nevent relay hints)
|
||||
3. **Root author outbox** (kind 10002 relay list)
|
||||
4. **Active user's inbox** (for receiving replies)
|
||||
|
||||
Merge all sources, deduplicate, limit to top 5-7 relays.
|
||||
|
||||
Implementation:
|
||||
```typescript
|
||||
async function getThreadRelays(
|
||||
rootEvent: NostrEvent,
|
||||
providedEvent: NostrEvent,
|
||||
providedRelays: string[] = []
|
||||
): Promise<string[]> {
|
||||
const relays = new Set<string>();
|
||||
|
||||
// 1. Seen relays from EventStore
|
||||
const rootSeenRelays = eventStore.getSeenRelays?.(rootEvent.id) || [];
|
||||
rootSeenRelays.forEach(r => relays.add(normalizeURL(r)));
|
||||
|
||||
// 2. Provided event hints
|
||||
providedRelays.forEach(r => relays.add(normalizeURL(r)));
|
||||
|
||||
// 3. Root author's outbox relays
|
||||
const rootOutbox = await getOutboxRelays(rootEvent.pubkey);
|
||||
rootOutbox.slice(0, 3).forEach(r => relays.add(normalizeURL(r)));
|
||||
|
||||
// 4. Active user's outbox/inbox (for publishing replies)
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (activePubkey) {
|
||||
const userOutbox = await getOutboxRelays(activePubkey);
|
||||
userOutbox.slice(0, 2).forEach(r => relays.add(normalizeURL(r)));
|
||||
}
|
||||
|
||||
// Limit to 7 relays max
|
||||
return Array.from(relays).slice(0, 7);
|
||||
}
|
||||
```
|
||||
|
||||
#### Event to Message Conversion
|
||||
|
||||
```typescript
|
||||
private eventToMessage(
|
||||
event: NostrEvent,
|
||||
conversationId: string,
|
||||
rootId: string
|
||||
): Message {
|
||||
if (event.kind === 9735) {
|
||||
// Zap receipt
|
||||
return this.zapToMessage(event, conversationId, rootId);
|
||||
}
|
||||
|
||||
// Kind 1 reply
|
||||
const refs = getNip10References(event);
|
||||
|
||||
// Determine reply target
|
||||
let replyTo: string | undefined;
|
||||
if (refs.reply?.e) {
|
||||
replyTo = refs.reply.e.id; // Replying to another reply
|
||||
} else if (refs.root?.e) {
|
||||
replyTo = refs.root.e.id; // Replying to root
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private zapToMessage(
|
||||
zapReceipt: NostrEvent,
|
||||
conversationId: string,
|
||||
rootId: string
|
||||
): Message {
|
||||
const zapRequest = getZapRequest(zapReceipt);
|
||||
const amount = getZapAmount(zapReceipt);
|
||||
const sender = getZapSender(zapReceipt);
|
||||
const recipient = getZapRecipient(zapReceipt);
|
||||
|
||||
// Find what event is being zapped (from e-tag in zap receipt)
|
||||
const eTag = zapReceipt.tags.find(t => t[0] === "e");
|
||||
const replyTo = eTag?.[1];
|
||||
|
||||
// Get comment from zap request
|
||||
const comment = zapRequest?.content || "";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4. ChatViewer Changes
|
||||
|
||||
**Update `src/components/ChatViewer.tsx`**:
|
||||
|
||||
Add special rendering mode for NIP-10 threads:
|
||||
|
||||
```typescript
|
||||
export function ChatViewer({
|
||||
protocol,
|
||||
identifier,
|
||||
customTitle,
|
||||
headerPrefix,
|
||||
}: ChatViewerProps) {
|
||||
// ... existing code ...
|
||||
|
||||
// Check if this is a NIP-10 thread
|
||||
const isThreadChat = protocol === "nip-10";
|
||||
|
||||
// Fetch root event for thread display
|
||||
const rootEventId = conversation?.metadata?.rootEventId;
|
||||
const rootEvent = use$(
|
||||
() => rootEventId ? eventStore.event(rootEventId) : undefined,
|
||||
[rootEventId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="pl-2 pr-0 border-b w-full py-0.5">
|
||||
{/* ... existing header ... */}
|
||||
</div>
|
||||
|
||||
{/* Message timeline */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* NIP-10 Thread: Show root event at top */}
|
||||
{isThreadChat && rootEvent && (
|
||||
<div className="border-b bg-muted/20">
|
||||
<div className="max-w-2xl mx-auto py-4 px-3">
|
||||
<KindRenderer event={rootEvent} depth={0} />
|
||||
</div>
|
||||
<div className="h-px bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages list (scrollable) */}
|
||||
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
|
||||
<Virtuoso
|
||||
// ... existing virtuoso config ...
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{isThreadChat
|
||||
? "No replies yet. Start the conversation!"
|
||||
: "No messages yet. Start the conversation!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
{canSign ? (
|
||||
<div className="border-t px-2 py-1 pb-0">
|
||||
{/* ... existing composer ... */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
Sign in to reply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Conversation Metadata
|
||||
|
||||
**Update `ConversationMetadata` in `src/types/chat.ts`**:
|
||||
|
||||
```typescript
|
||||
export interface ConversationMetadata {
|
||||
// ... existing fields ...
|
||||
|
||||
// NIP-10 thread
|
||||
rootEventId?: string; // Thread root event ID
|
||||
providedEventId?: string; // Original event from nevent (may be reply)
|
||||
threadDepth?: number; // Approximate depth of thread
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Reply Preview Updates
|
||||
|
||||
**Update `src/components/chat/ReplyPreview.tsx`**:
|
||||
|
||||
Current implementation already supports showing replied-to messages. For NIP-10 threads, we need to:
|
||||
|
||||
1. Show "↳ root" when replying directly to root
|
||||
2. Show "↳ username" when replying to another reply
|
||||
3. Fetch events from thread relays
|
||||
|
||||
This should mostly work with existing code, but we can enhance the display:
|
||||
|
||||
```typescript
|
||||
export const ReplyPreview = memo(function ReplyPreview({
|
||||
replyToId,
|
||||
adapter,
|
||||
conversation,
|
||||
onScrollToMessage,
|
||||
}: ReplyPreviewProps) {
|
||||
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
|
||||
|
||||
// For NIP-10 threads, check if replying to root
|
||||
const isRoot = conversation.metadata?.rootEventId === replyToId;
|
||||
|
||||
// ... existing fetch logic ...
|
||||
|
||||
if (!replyEvent) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
↳ Replying to {isRoot ? "thread root" : replyToId.slice(0, 8)}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-xs text-muted-foreground flex items-baseline gap-1 mb-0.5 overflow-hidden cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={handleClick}
|
||||
title="Click to scroll to message"
|
||||
>
|
||||
<span className="flex-shrink-0">↳</span>
|
||||
{isRoot ? (
|
||||
<span className="font-medium">thread root</span>
|
||||
) : (
|
||||
<UserName pubkey={replyEvent.pubkey} className="font-medium flex-shrink-0" />
|
||||
)}
|
||||
<div className="line-clamp-1 overflow-hidden flex-1 min-w-0">
|
||||
<RichText
|
||||
event={replyEvent}
|
||||
options={{ showMedia: false, showEventEmbeds: false }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 7. Message Context Menu
|
||||
|
||||
NIP-10 threads should support:
|
||||
- Copy event ID / nevent
|
||||
- Copy raw JSON
|
||||
- Open in new window
|
||||
- Quote (copy with > prefix)
|
||||
- **View full thread** (new action - opens `chat nevent` for this message)
|
||||
|
||||
This can be added to existing `ChatMessageContextMenu` component.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: Core Infrastructure
|
||||
- [ ] Add `ThreadIdentifier` to `src/types/chat.ts`
|
||||
- [ ] Add `"nip-10"` to `ChatProtocol` type
|
||||
- [ ] Update `ConversationMetadata` with thread fields
|
||||
- [ ] Create `src/lib/chat/adapters/nip-10-adapter.ts` skeleton
|
||||
|
||||
### Phase 2: Identifier Parsing
|
||||
- [ ] Implement `parseIdentifier()` for nevent/note
|
||||
- [ ] Add Nip10Adapter to chat-parser.ts
|
||||
- [ ] Test with various nevent formats
|
||||
|
||||
### Phase 3: Conversation Resolution
|
||||
- [ ] Implement `resolveConversation()`:
|
||||
- [ ] Fetch provided event
|
||||
- [ ] Find root event via NIP-10 refs
|
||||
- [ ] Determine conversation relays
|
||||
- [ ] Extract title and participants
|
||||
- [ ] Test with root events and reply events
|
||||
|
||||
### Phase 4: Message Loading
|
||||
- [ ] Implement `loadMessages()`:
|
||||
- [ ] Subscribe to replies (kind 1)
|
||||
- [ ] Subscribe to reactions (kind 7)
|
||||
- [ ] Subscribe to zaps (kind 9735)
|
||||
- [ ] Convert to Message objects
|
||||
- [ ] Implement `loadMoreMessages()` for pagination
|
||||
- [ ] Test with threads of varying sizes
|
||||
|
||||
### Phase 5: Message Sending
|
||||
- [ ] Implement `sendMessage()`:
|
||||
- [ ] Build NIP-10 tags (root + reply)
|
||||
- [ ] Add p-tags for participants
|
||||
- [ ] Support emoji tags
|
||||
- [ ] Support imeta attachments
|
||||
- [ ] Implement `sendReaction()`
|
||||
- [ ] Test reply hierarchy
|
||||
|
||||
### Phase 6: UI Integration
|
||||
- [ ] Update ChatViewer for thread mode:
|
||||
- [ ] Render root event at top (centered)
|
||||
- [ ] Add separator between root and replies
|
||||
- [ ] Adjust composer placeholder
|
||||
- [ ] Update ReplyPreview for "thread root" display
|
||||
- [ ] Test visual layout
|
||||
|
||||
### Phase 7: Polish
|
||||
- [ ] Add loading states
|
||||
- [ ] Add error handling
|
||||
- [ ] Add "View full thread" context menu action
|
||||
- [ ] Update help text in chat-parser error
|
||||
- [ ] Write tests
|
||||
- [ ] Update CLAUDE.md documentation
|
||||
|
||||
## Edge Cases
|
||||
|
||||
1. **Provided event is deleted**: Show error, can't resolve thread
|
||||
2. **Root event not found**: Treat provided event as root
|
||||
3. **Very deep threads** (>100 replies): Pagination should handle this
|
||||
4. **Multiple roots claimed**: Trust marked e-tags, fallback to first e-tag
|
||||
5. **Mixed protocols**: nevent might point to kind 9 (NIP-29) - let Nip29Adapter handle
|
||||
6. **No relay hints**: Use fallback relay strategy (author outbox + user outbox)
|
||||
7. **Private relays**: May fail to fetch - show "unable to load thread" error
|
||||
8. **Quote reposts vs replies**: NIP-10 doesn't distinguish - treat all e-tags as replies
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- `nip-10-adapter.test.ts`:
|
||||
- parseIdentifier with various formats
|
||||
- eventToMessage conversion
|
||||
- NIP-10 tag building for replies
|
||||
|
||||
### Integration Tests
|
||||
- Resolve conversation from root event
|
||||
- Resolve conversation from reply event
|
||||
- Load messages with reactions and zaps
|
||||
- Send reply with proper NIP-10 tags
|
||||
- Pagination
|
||||
|
||||
### Manual Tests
|
||||
- Open thread from root event
|
||||
- Open thread from nested reply (should show full thread)
|
||||
- Reply to root
|
||||
- Reply to reply (test hierarchy)
|
||||
- Send reaction and zap
|
||||
- Load older messages
|
||||
- Test with threads containing media, links, mentions
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Thread tree view**: Option to show replies in tree structure instead of flat chat
|
||||
2. **Smart relay selection**: Learn which relays have the most complete thread
|
||||
3. **Thread health indicator**: Show "X/Y replies loaded" if some are missing
|
||||
4. **Thread export**: Export thread as markdown or JSON
|
||||
5. **Thread notifications**: Subscribe to new replies (NIP-XX)
|
||||
6. **Threaded zaps**: Show zap amount on specific reply being zapped
|
||||
7. **Quote highlighting**: When replying, highlight quoted text
|
||||
8. **Draft persistence**: Save draft replies per thread
|
||||
|
||||
## Related NIPs
|
||||
|
||||
- **NIP-10**: Text note references (threading) - core spec
|
||||
- **NIP-19**: bech32 encoding (nevent, note formats)
|
||||
- **NIP-30**: Custom emoji
|
||||
- **NIP-57**: Zaps
|
||||
- **NIP-92**: Media attachments (imeta)
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Update `CLAUDE.md`:
|
||||
|
||||
```markdown
|
||||
## Chat System
|
||||
|
||||
**Current Status**: NIP-10 (threaded notes), NIP-29 (relay groups), and NIP-53 (live chats) are supported.
|
||||
|
||||
### NIP-10 Thread Chat
|
||||
|
||||
Turn any kind 1 note thread into a chat interface:
|
||||
|
||||
```bash
|
||||
chat nevent1qqsxyz... # Open thread as chat
|
||||
chat note1abc... # Also works (converts to nevent)
|
||||
```
|
||||
|
||||
**Format**: Thread root is displayed at top (centered, full feed renderer), all replies below as chat messages.
|
||||
|
||||
**Reply Handling**: Sends kind 1 events with proper NIP-10 markers (root + reply tags).
|
||||
|
||||
**Relay Selection**: Combines root event relays, provided hints, author outbox, and user outbox.
|
||||
```
|
||||
|
||||
## Questions for Consideration
|
||||
|
||||
1. **Root event interactions**: Should users be able to react/zap the root event from the chat UI?
|
||||
- **Answer**: Yes, show actions bar on hover (same as feed renderer)
|
||||
|
||||
2. **Reply depth indicator**: Should we show visual threading (indentation) or keep flat?
|
||||
- **Answer**: Keep flat initially, add tree view as future enhancement
|
||||
|
||||
3. **Title length**: How to truncate root content for chat title?
|
||||
- **Answer**: First line OR truncate to 50 chars with "..." suffix
|
||||
|
||||
4. **Empty threads**: What if root has no replies?
|
||||
- **Answer**: Show root event + empty state "No replies yet"
|
||||
|
||||
5. **Cross-protocol**: Can NIP-10 thread include NIP-29 group messages?
|
||||
- **Answer**: No, strictly kind 1 events only
|
||||
|
||||
6. **Root event scrolling**: Should clicking "thread root" in reply preview scroll to top?
|
||||
- **Answer**: Yes, scroll to top (where root is displayed)
|
||||
972
docs/nip10-thread-chat-examples.md
Normal file
972
docs/nip10-thread-chat-examples.md
Normal file
@@ -0,0 +1,972 @@
|
||||
# NIP-10 Thread Chat - Implementation Examples
|
||||
|
||||
This document provides concrete code examples for implementing NIP-10 thread chat support.
|
||||
|
||||
## Example 1: Parsing nevent/note Identifiers
|
||||
|
||||
```typescript
|
||||
// src/lib/chat/adapters/nip-10-adapter.ts
|
||||
|
||||
import { nip19 } from "nostr-tools";
|
||||
import type { EventPointer } from "applesauce-core/helpers";
|
||||
import type { ProtocolIdentifier, ThreadIdentifier } from "@/types/chat";
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
## Example 2: Resolving Thread Conversation
|
||||
|
||||
```typescript
|
||||
// src/lib/chat/adapters/nip-10-adapter.ts
|
||||
|
||||
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 || [];
|
||||
|
||||
console.log(`[NIP-10] Fetching event ${pointer.id.slice(0, 8)}...`);
|
||||
|
||||
// 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;
|
||||
console.log(`[NIP-10] Fetching root event ${rootId.slice(0, 8)}...`);
|
||||
|
||||
const rootPointer: EventPointer = {
|
||||
id: rootId,
|
||||
relays: refs.root.e.relays,
|
||||
author: refs.root.e.author,
|
||||
};
|
||||
|
||||
const fetchedRoot = await this.fetchEvent(rootId, rootPointer.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;
|
||||
console.log(`[NIP-10] Provided event is the root`);
|
||||
}
|
||||
|
||||
// 3. Determine conversation relays
|
||||
const conversationRelays = await this.getThreadRelays(
|
||||
rootEvent,
|
||||
providedEvent,
|
||||
relayHints,
|
||||
);
|
||||
|
||||
console.log(`[NIP-10] Using ${conversationRelays.length} relays:`, conversationRelays);
|
||||
|
||||
// 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", // Use "group" type for multi-participant threads
|
||||
protocol: "nip-10",
|
||||
title,
|
||||
participants,
|
||||
metadata: {
|
||||
rootEventId: rootId,
|
||||
providedEventId: providedEvent.id,
|
||||
description: rootEvent.content.slice(0, 200), // First 200 chars for tooltip
|
||||
},
|
||||
unreadCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: "admin", // Root author is "admin" 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
|
||||
*/
|
||||
private async getThreadRelays(
|
||||
rootEvent: NostrEvent,
|
||||
providedEvent: NostrEvent,
|
||||
providedRelays: string[],
|
||||
): Promise<string[]> {
|
||||
const relays = new Set<string>();
|
||||
|
||||
// 1. Seen relays from EventStore (if available)
|
||||
if (eventStore.getSeenRelays) {
|
||||
const rootSeenRelays = eventStore.getSeenRelays(rootEvent.id) || [];
|
||||
rootSeenRelays.forEach((r) => relays.add(normalizeURL(r)));
|
||||
}
|
||||
|
||||
// 2. Provided relay hints
|
||||
providedRelays.forEach((r) => relays.add(normalizeURL(r)));
|
||||
|
||||
// 3. Root author's outbox relays (NIP-65)
|
||||
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);
|
||||
}
|
||||
|
||||
// 4. Active user's outbox (for publishing replies)
|
||||
const activePubkey = accountManager.active$.value?.pubkey;
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback to popular relays if we have too few
|
||||
if (relays.size < 3) {
|
||||
["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"].forEach(
|
||||
(r) => relays.add(r),
|
||||
);
|
||||
}
|
||||
|
||||
// Limit to 7 relays max for performance
|
||||
return Array.from(relays).slice(0, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
console.log(`[NIP-10] Fetch timeout for ${eventId.slice(0, 8)}...`);
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
## Example 3: Loading Thread Messages
|
||||
|
||||
```typescript
|
||||
// src/lib/chat/adapters/nip-10-adapter.ts
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
console.log(`[NIP-10] Loading thread ${rootEventId.slice(0, 8)}...`);
|
||||
|
||||
// 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) => {
|
||||
if (typeof response === "string") {
|
||||
console.log("[NIP-10] EOSE received");
|
||||
} else {
|
||||
console.log(
|
||||
`[NIP-10] Received event k${response.kind}: ${response.id.slice(0, 8)}...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store subscription for cleanup
|
||||
this.subscriptions.set(conversationId, subscription);
|
||||
|
||||
// Return observable from EventStore
|
||||
// We need to merge all three filters into a single observable
|
||||
return eventStore
|
||||
.timeline({ kinds: [1, 7, 9735], "#e": [rootEventId] })
|
||||
.pipe(
|
||||
map((events) => {
|
||||
// Filter out the root event itself (we don't want it in messages list)
|
||||
const threadEvents = events.filter((e) => e.id !== rootEventId);
|
||||
|
||||
// Convert events to messages
|
||||
const messages = threadEvents
|
||||
.map((event) => this.eventToMessage(event, conversationId, rootEventId))
|
||||
.filter((msg): msg is Message => msg !== null);
|
||||
|
||||
console.log(`[NIP-10] Timeline has ${messages.length} messages`);
|
||||
|
||||
// Sort by timestamp ascending (chronological order)
|
||||
return messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Example 4: Sending Replies with NIP-10 Tags
|
||||
|
||||
```typescript
|
||||
// src/lib/chat/adapters/nip-10-adapter.ts
|
||||
|
||||
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);
|
||||
|
||||
console.log(`[NIP-10] Publishing reply with ${tags.length} tags to ${relays.length} relays`);
|
||||
|
||||
// Publish to conversation relays
|
||||
await publishEventToRelays(event, relays);
|
||||
}
|
||||
```
|
||||
|
||||
## Example 5: Converting Events to Messages
|
||||
|
||||
```typescript
|
||||
// src/lib/chat/adapters/nip-10-adapter.ts
|
||||
|
||||
/**
|
||||
* 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, we'll handle via MessageReactions component
|
||||
if (event.kind === 7) {
|
||||
return null; // Reactions are shown inline, not as separate messages
|
||||
}
|
||||
|
||||
// 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 zapRequest = getZapRequest(zapReceipt);
|
||||
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 comment from zap request
|
||||
const comment = zapRequest?.content || "";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Example 6: ChatViewer Root Event Display
|
||||
|
||||
```typescript
|
||||
// src/components/ChatViewer.tsx
|
||||
|
||||
export function ChatViewer({
|
||||
protocol,
|
||||
identifier,
|
||||
customTitle,
|
||||
headerPrefix,
|
||||
}: ChatViewerProps) {
|
||||
// ... existing setup code ...
|
||||
|
||||
// Check if this is a NIP-10 thread
|
||||
const isThreadChat = protocol === "nip-10";
|
||||
|
||||
// Fetch root event for thread display
|
||||
const rootEventId = conversation?.metadata?.rootEventId;
|
||||
const rootEvent = use$(
|
||||
() => (rootEventId ? eventStore.event(rootEventId) : undefined),
|
||||
[rootEventId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="pl-2 pr-0 border-b w-full py-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
{headerPrefix}
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{customTitle || conversation.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
|
||||
<MembersDropdown participants={derivedParticipants} />
|
||||
<RelaysDropdown conversation={conversation} />
|
||||
{isThreadChat && (
|
||||
<button className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
NIP-10
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message timeline */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* NIP-10 Thread: Show root event at top */}
|
||||
{isThreadChat && rootEvent && (
|
||||
<div className="border-b bg-muted/10 flex-shrink-0">
|
||||
<div className="max-w-2xl mx-auto py-4 px-3">
|
||||
{/* Use KindRenderer to render root with full feed functionality */}
|
||||
<KindRenderer event={rootEvent} depth={0} />
|
||||
</div>
|
||||
{/* Visual separator */}
|
||||
<div className="flex items-center gap-2 px-3 py-1 text-xs text-muted-foreground">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span>Replies</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable messages list */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{messagesWithMarkers && messagesWithMarkers.length > 0 ? (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={messagesWithMarkers}
|
||||
initialTopMostItemIndex={messagesWithMarkers.length - 1}
|
||||
followOutput="smooth"
|
||||
alignToBottom
|
||||
// ... rest of virtuoso config ...
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{isThreadChat ? "No replies yet. Start the conversation!" : "No messages yet. Start the conversation!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
{canSign ? (
|
||||
<div className="border-t px-2 py-1 pb-0">
|
||||
{replyTo && (
|
||||
<ComposerReplyPreview
|
||||
replyToId={replyTo}
|
||||
onClear={() => setReplyTo(undefined)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{/* ... existing composer ... */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
Sign in to reply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Example 7: Usage Examples
|
||||
|
||||
### Opening a Thread from Root Event
|
||||
|
||||
```bash
|
||||
# User clicks on a tweet in their feed
|
||||
# Extract event ID: abc123...
|
||||
chat nevent1qqsabc123...
|
||||
# → Opens thread chat with root at top
|
||||
```
|
||||
|
||||
### Opening a Thread from Reply Event
|
||||
|
||||
```bash
|
||||
# User clicks on a reply deep in a thread
|
||||
# Extract event ID: xyz789... (this is a reply, not root)
|
||||
chat nevent1qqsxyz789...
|
||||
# → Adapter fetches root, opens full thread
|
||||
```
|
||||
|
||||
### Replying to Root
|
||||
|
||||
```
|
||||
User types: "Great point!"
|
||||
Click Reply button on root event
|
||||
→ Sends kind 1 with:
|
||||
["e", "<root-id>", "<relay>", "root", "<root-author>"]
|
||||
["p", "<root-author>"]
|
||||
```
|
||||
|
||||
### Replying to a Reply
|
||||
|
||||
```
|
||||
User clicks Reply on Alice's message (which replied to root)
|
||||
User types: "I agree with Alice"
|
||||
→ Sends kind 1 with:
|
||||
["e", "<root-id>", "<relay>", "root", "<root-author>"]
|
||||
["e", "<alice-msg-id>", "<relay>", "reply", "<alice-pubkey>"]
|
||||
["p", "<root-author>"]
|
||||
["p", "<alice-pubkey>"]
|
||||
```
|
||||
|
||||
### Visual Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ chat nevent1qqsxyz... │ User enters command
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Nip10Adapter.parseIdentifier() │ Parse nevent
|
||||
│ → Returns ThreadIdentifier │
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Nip10Adapter.resolveConversation() │ Fetch events, find root
|
||||
│ → Fetches provided event │
|
||||
│ → Parses NIP-10 refs │
|
||||
│ → Fetches root event │
|
||||
│ → Determines relays │
|
||||
│ → Returns Conversation │
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ ChatViewer renders │ Display UI
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ Root Event (KindRenderer) │ │ Root at top
|
||||
│ └────────────────────────────────────┘ │
|
||||
│ ────────────────────────────────────── │
|
||||
│ Alice: Great post! │ Replies as messages
|
||||
│ Bob: +1 │
|
||||
│ [──────────────────] [Send] │ Composer
|
||||
└──────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Nip10Adapter.loadMessages() │ Subscribe to replies
|
||||
│ → Subscribes to kind 1 replies │
|
||||
│ → Subscribes to kind 7 reactions │
|
||||
│ → Subscribes to kind 9735 zaps │
|
||||
│ → Returns Observable<Message[]> │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Testing Example
|
||||
|
||||
```typescript
|
||||
// src/lib/chat/adapters/nip-10-adapter.test.ts
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Nip10Adapter } from "./nip-10-adapter";
|
||||
|
||||
describe("Nip10Adapter", () => {
|
||||
const adapter = new Nip10Adapter();
|
||||
|
||||
describe("parseIdentifier", () => {
|
||||
it("should parse nevent with relay hints", () => {
|
||||
const nevent = "nevent1qqsabc123..."; // Valid nevent
|
||||
const result = adapter.parseIdentifier(nevent);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("thread");
|
||||
expect(result?.value.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should parse note (event ID only)", () => {
|
||||
const note = "note1abc123..."; // Valid note
|
||||
const result = adapter.parseIdentifier(note);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("thread");
|
||||
});
|
||||
|
||||
it("should return null for non-note/nevent input", () => {
|
||||
const result = adapter.parseIdentifier("npub1...");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for nevent with kind 9 (group message)", () => {
|
||||
// nevent encoding includes kind: 9
|
||||
const nevent = "nevent1...kind9...";
|
||||
const result = adapter.parseIdentifier(nevent);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTitle", () => {
|
||||
it("should use first line if under 50 chars", () => {
|
||||
const event = {
|
||||
content: "Short title\nLonger content here...",
|
||||
pubkey: "abc",
|
||||
};
|
||||
// @ts-ignore - testing private method
|
||||
const title = adapter.extractTitle(event);
|
||||
expect(title).toBe("Short title");
|
||||
});
|
||||
|
||||
it("should truncate long content", () => {
|
||||
const event = {
|
||||
content: "A".repeat(100),
|
||||
pubkey: "abc",
|
||||
};
|
||||
// @ts-ignore
|
||||
const title = adapter.extractTitle(event);
|
||||
expect(title).toHaveLength(50);
|
||||
expect(title).toEndWith("...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventToMessage", () => {
|
||||
it("should convert kind 1 reply to Message", () => {
|
||||
const event = {
|
||||
id: "reply123",
|
||||
kind: 1,
|
||||
pubkey: "alice",
|
||||
content: "Great point!",
|
||||
created_at: 1234567890,
|
||||
tags: [
|
||||
["e", "root123", "", "root", "bob"],
|
||||
["p", "bob"],
|
||||
],
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const message = adapter.eventToMessage(event, "nip-10:root123", "root123");
|
||||
|
||||
expect(message).toBeTruthy();
|
||||
expect(message?.type).toBe("user");
|
||||
expect(message?.replyTo).toBe("root123");
|
||||
expect(message?.content).toBe("Great point!");
|
||||
});
|
||||
|
||||
it("should return null for kind 7 (reactions handled separately)", () => {
|
||||
const event = {
|
||||
id: "reaction123",
|
||||
kind: 7,
|
||||
pubkey: "alice",
|
||||
content: "🔥",
|
||||
created_at: 1234567890,
|
||||
tags: [["e", "msg123"]],
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const message = adapter.eventToMessage(event, "nip-10:root123", "root123");
|
||||
expect(message).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
472
docs/nip10-thread-chat-summary.md
Normal file
472
docs/nip10-thread-chat-summary.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# NIP-10 Thread Chat - Quick Reference
|
||||
|
||||
## What It Is
|
||||
|
||||
Turn any kind 1 Nostr thread into a chat interface, with the root event displayed prominently at the top and all replies shown as chat messages below.
|
||||
|
||||
## Command Format
|
||||
|
||||
```bash
|
||||
chat nevent1qqsxyz... # Full nevent with relay hints
|
||||
chat note1abc... # Simple note ID (less reliable)
|
||||
```
|
||||
|
||||
## Visual Comparison
|
||||
|
||||
### Before (Traditional Feed View)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Alice: Check out this cool feature │ ← Root event
|
||||
│ [Like] [Repost] [Reply] [Zap] │
|
||||
├─────────────────────────────────────┤
|
||||
│ Bob: Great idea! │ ← Reply (separate event)
|
||||
│ [Like] [Repost] [Reply] [Zap] │
|
||||
├─────────────────────────────────────┤
|
||||
│ Carol: I agree with Bob │ ← Reply (separate event)
|
||||
│ [Like] [Repost] [Reply] [Zap] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### After (Thread Chat View)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📌 Check out this cool feature... │ ← Header (thread title)
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Alice │ │ ← Root event (centered)
|
||||
│ │ Check out this │ │ Full feed renderer
|
||||
│ │ cool feature I built │ │ (can like, zap, etc.)
|
||||
│ │ [Like] [Zap] [Share] │ │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ ─────────── Replies ────────────── │ ← Visual separator
|
||||
│ │
|
||||
│ Bob: Great idea! │ ← Replies as chat messages
|
||||
│ └─ ↳ thread root │ (simpler, chat-like)
|
||||
│ │
|
||||
│ Carol: I agree with Bob │
|
||||
│ └─ ↳ Bob │
|
||||
│ │
|
||||
│ ⚡ 1000 Alice │ ← Zaps inline
|
||||
│ │
|
||||
│ [Type a message...] 📎 [Send] │ ← Chat composer
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ User Input │
|
||||
│ chat nevent1qqsxyz... │
|
||||
└───────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ src/lib/chat-parser.ts │
|
||||
│ Tries each adapter's parseIdentifier(): │
|
||||
│ 1. Nip10Adapter (nevent/note) ← NEW │
|
||||
│ 2. Nip29Adapter (relay'group) │
|
||||
│ 3. Nip53Adapter (naddr live chat) │
|
||||
└───────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ src/lib/chat/adapters/nip-10-adapter.ts ← NEW │
|
||||
│ │
|
||||
│ parseIdentifier(input) │
|
||||
│ • Match nevent/note format │
|
||||
│ • Decode to EventPointer │
|
||||
│ • Return ThreadIdentifier │
|
||||
│ │
|
||||
│ resolveConversation(identifier) │
|
||||
│ • Fetch provided event │
|
||||
│ • Parse NIP-10 refs → find root │
|
||||
│ • Determine relays (merge sources) │
|
||||
│ • Extract title from root │
|
||||
│ • Return Conversation │
|
||||
│ │
|
||||
│ loadMessages(conversation) │
|
||||
│ • Subscribe: kind 1 replies │
|
||||
│ • Subscribe: kind 7 reactions │
|
||||
│ • Subscribe: kind 9735 zaps │
|
||||
│ • Convert to Message[] │
|
||||
│ • Return Observable │
|
||||
│ │
|
||||
│ sendMessage(conversation, content, options) │
|
||||
│ • Build NIP-10 tags (root + reply markers) │
|
||||
│ • Add p-tags for participants │
|
||||
│ • Create kind 1 event │
|
||||
│ • Publish to conversation relays │
|
||||
└───────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ src/components/ChatViewer.tsx │
|
||||
│ │
|
||||
│ Detects: protocol === "nip-10" │
|
||||
│ │
|
||||
│ Special rendering: │
|
||||
│ • Fetch rootEventId from conversation.metadata │
|
||||
│ • Render root with KindRenderer (centered) │
|
||||
│ • Show visual separator │
|
||||
│ • Render replies as chat messages below │
|
||||
│ • Chat composer at bottom │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Differences from Other Chat Protocols
|
||||
|
||||
| Feature | NIP-29 Groups | NIP-53 Live Chat | **NIP-10 Threads (NEW)** |
|
||||
|---------|---------------|------------------|---------------------------|
|
||||
| **Event Kind** | 9 | 1311 | **1** |
|
||||
| **Reply Tag** | q-tag | q-tag | **e-tag with markers** |
|
||||
| **Root Display** | ❌ No root | ❌ No root | **✅ Root at top** |
|
||||
| **Relay Model** | Single relay | Multiple relays | **Multiple relays** |
|
||||
| **Membership** | Admin approval | Open | **Open** |
|
||||
| **Protocol** | nip-29 | nip-53 | **nip-10** |
|
||||
| **Identifier** | `relay'group` | `naddr1...` | **`nevent1...`** |
|
||||
| **Use Case** | Private groups | Live streams | **Twitter threads** |
|
||||
|
||||
## NIP-10 Tag Structure Comparison
|
||||
|
||||
### Direct Reply to Root (Kind 1)
|
||||
|
||||
```javascript
|
||||
{
|
||||
kind: 1,
|
||||
content: "Great point!",
|
||||
tags: [
|
||||
["e", "<root-id>", "<relay>", "root", "<root-author>"],
|
||||
["p", "<root-author>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Nested Reply (Kind 1)
|
||||
|
||||
```javascript
|
||||
{
|
||||
kind: 1,
|
||||
content: "I agree with Alice!",
|
||||
tags: [
|
||||
["e", "<root-id>", "<relay>", "root", "<root-author>"], // Thread root
|
||||
["e", "<alice-msg-id>", "<relay>", "reply", "<alice-pk>"], // Direct parent
|
||||
["p", "<root-author>"],
|
||||
["p", "<alice-pk>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### NIP-29 Group Message (Kind 9) - For Comparison
|
||||
|
||||
```javascript
|
||||
{
|
||||
kind: 9,
|
||||
content: "Hello group!",
|
||||
tags: [
|
||||
["h", "<group-id>"], // Group identifier
|
||||
["q", "<parent-msg-id>"] // Simple reply (if replying)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Relay Selection Strategy
|
||||
|
||||
NIP-10 threads use **merged relay sources** for maximum reach:
|
||||
|
||||
```
|
||||
1. Root Event Seen Relays (from EventStore)
|
||||
└─ Where the root was originally found
|
||||
|
||||
2. Provided Event Relay Hints (from nevent)
|
||||
└─ User-specified relays in the nevent encoding
|
||||
|
||||
3. Root Author's Outbox (NIP-65 kind 10002)
|
||||
└─ Where the root author publishes
|
||||
|
||||
4. Active User's Outbox (NIP-65 kind 10002)
|
||||
└─ Where current user publishes
|
||||
|
||||
5. Fallback Popular Relays (if < 3 found)
|
||||
└─ relay.damus.io, nos.lol, relay.nostr.band
|
||||
|
||||
Result: Top 5-7 relays (deduplicated, normalized)
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Loading a Thread
|
||||
|
||||
```
|
||||
1. User: chat nevent1qqsxyz...
|
||||
│
|
||||
▼
|
||||
2. Parse nevent → EventPointer
|
||||
│
|
||||
▼
|
||||
3. Fetch event xyz
|
||||
│
|
||||
├─ Is it kind 1? ✓
|
||||
│
|
||||
▼
|
||||
4. Parse NIP-10 references
|
||||
│
|
||||
├─ Has root marker? → Fetch root event
|
||||
└─ No root marker? → xyz IS the root
|
||||
│
|
||||
▼
|
||||
5. Determine relays
|
||||
│
|
||||
├─ Merge: seen, hints, outbox
|
||||
│
|
||||
▼
|
||||
6. Subscribe to thread events
|
||||
│
|
||||
├─ kind 1 with #e = root.id
|
||||
├─ kind 7 with #e = root.id
|
||||
└─ kind 9735 with #e = root.id
|
||||
│
|
||||
▼
|
||||
7. Convert events → Messages
|
||||
│
|
||||
├─ Parse reply hierarchy
|
||||
├─ Extract zap amounts
|
||||
└─ Sort chronologically
|
||||
│
|
||||
▼
|
||||
8. Render UI
|
||||
│
|
||||
├─ Root event (centered, feed renderer)
|
||||
├─ Visual separator
|
||||
└─ Replies (chat messages)
|
||||
```
|
||||
|
||||
### Sending a Reply
|
||||
|
||||
```
|
||||
1. User types: "Great idea!"
|
||||
│
|
||||
▼
|
||||
2. User clicks Reply on Alice's message
|
||||
│
|
||||
▼
|
||||
3. Adapter.sendMessage(conversation, content, { replyTo: alice.id })
|
||||
│
|
||||
▼
|
||||
4. Build tags:
|
||||
│
|
||||
├─ ["e", root.id, relay, "root", root.author]
|
||||
├─ ["e", alice.id, relay, "reply", alice.pk]
|
||||
├─ ["p", root.author]
|
||||
└─ ["p", alice.pk]
|
||||
│
|
||||
▼
|
||||
5. Create kind 1 event
|
||||
│
|
||||
▼
|
||||
6. Publish to conversation relays
|
||||
│
|
||||
├─ relay.damus.io
|
||||
├─ nos.lol
|
||||
└─ root.author's outbox
|
||||
│
|
||||
▼
|
||||
7. Event propagates
|
||||
│
|
||||
▼
|
||||
8. Subscription receives event
|
||||
│
|
||||
▼
|
||||
9. UI updates (new message appears)
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
### New Files (to be created)
|
||||
|
||||
```
|
||||
src/lib/chat/adapters/nip-10-adapter.ts
|
||||
└─ Full adapter implementation (~600 lines)
|
||||
|
||||
src/lib/chat/adapters/nip-10-adapter.test.ts
|
||||
└─ Unit tests for adapter
|
||||
|
||||
docs/nip10-thread-chat-design.md
|
||||
└─ Detailed design spec
|
||||
|
||||
docs/nip10-thread-chat-examples.md
|
||||
└─ Code examples
|
||||
|
||||
docs/nip10-thread-chat-summary.md
|
||||
└─ This file
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
src/types/chat.ts
|
||||
├─ Add ThreadIdentifier type
|
||||
├─ Add "nip-10" to ChatProtocol
|
||||
└─ Add rootEventId to ConversationMetadata
|
||||
|
||||
src/lib/chat-parser.ts
|
||||
├─ Import Nip10Adapter
|
||||
└─ Add to adapter priority list
|
||||
|
||||
src/components/ChatViewer.tsx
|
||||
├─ Detect isThreadChat = protocol === "nip-10"
|
||||
├─ Fetch rootEvent from metadata
|
||||
├─ Render root with KindRenderer (centered)
|
||||
└─ Show visual separator
|
||||
|
||||
src/components/chat/ReplyPreview.tsx
|
||||
└─ Show "thread root" when replying to root
|
||||
|
||||
CLAUDE.md
|
||||
└─ Document NIP-10 thread chat usage
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Opening Thread from Twitter-like Feed
|
||||
|
||||
```bash
|
||||
# User sees interesting post in feed (kind 1 note)
|
||||
# Clicks "View as Thread" button
|
||||
# App extracts event pointer
|
||||
chat nevent1qqsrz7x...
|
||||
|
||||
# Result: Opens thread chat with:
|
||||
# - Root post at top (full renderer with actions)
|
||||
# - All replies as chat messages below
|
||||
# - Reply composer at bottom
|
||||
```
|
||||
|
||||
### Example 2: Opening Thread from Deep Reply
|
||||
|
||||
```bash
|
||||
# User is reading a reply deep in a thread
|
||||
# Clicks "View Thread" context menu
|
||||
chat nevent1qqsabc... # This is a nested reply, not root
|
||||
|
||||
# Adapter logic:
|
||||
# 1. Fetch event abc
|
||||
# 2. Parse NIP-10 refs → finds root XYZ
|
||||
# 3. Fetch root event XYZ
|
||||
# 4. Resolve conversation with root XYZ
|
||||
# 5. Load all replies to XYZ
|
||||
# 6. Display full thread (not just from abc onward)
|
||||
```
|
||||
|
||||
### Example 3: Replying in Thread
|
||||
|
||||
```
|
||||
Thread Chat Interface:
|
||||
|
||||
┌──────────────────────────────────┐
|
||||
│ [Root Event by Alice] │ User can interact with root
|
||||
│ 42 ⚡ 18 ♥ 3 💬 │ (zap, like, etc.)
|
||||
├──────────────────────────────────┤
|
||||
│ │
|
||||
│ Bob: Great point! │ ← Hover shows Reply button
|
||||
│ └─ ↳ thread root │
|
||||
│ │
|
||||
│ Carol: I agree │ ← Click Reply here
|
||||
│ └─ ↳ Bob │
|
||||
│ │
|
||||
└──────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────┐
|
||||
│ [Replying to Carol] │ ← Reply preview shows
|
||||
│ Type message... [Send] │
|
||||
└──────────────────────────────────┘
|
||||
↓ User types "Me too!"
|
||||
↓ Clicks Send
|
||||
↓
|
||||
Publishes kind 1 with:
|
||||
["e", rootId, relay, "root", alice] # Thread root
|
||||
["e", carolId, relay, "reply", carol] # Direct parent
|
||||
["p", alice] # Root author
|
||||
["p", carol] # Parent author
|
||||
["p", bob] # Mentioned in parent
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Users
|
||||
- **Focused discussion**: See entire thread in one view
|
||||
- **Better context**: Root always visible at top
|
||||
- **Faster replies**: Chat-like composer instead of feed actions
|
||||
- **Real-time**: New replies appear instantly (subscription)
|
||||
- **Cross-client**: Works with any NIP-10 compliant client
|
||||
|
||||
### For Developers
|
||||
- **Reuses infrastructure**: Same ChatViewer, just different adapter
|
||||
- **Protocol-agnostic UI**: Adapter pattern abstracts differences
|
||||
- **Testable**: Unit tests for adapter, integration tests for UI
|
||||
- **Extensible**: Easy to add features (threading UI, export, etc.)
|
||||
|
||||
## Limitations & Trade-offs
|
||||
|
||||
### Limitations
|
||||
1. **Kind 1 only**: Doesn't work with other event kinds
|
||||
2. **No encryption**: All messages public (NIP-10 has no encryption)
|
||||
3. **No moderation**: Can't delete/hide replies (not in NIP-10)
|
||||
4. **Relay dependent**: Need good relay coverage to see all replies
|
||||
5. **No real-time guarantees**: Relies on relay subscriptions
|
||||
|
||||
### Trade-offs
|
||||
- **Simplicity vs Features**: Chat UI sacrifices some feed features (repost, quote, etc.)
|
||||
- **Performance vs Completeness**: Limit to 7 relays for speed (might miss some replies)
|
||||
- **UX vs Protocol**: Showing root separately breaks chronological order slightly
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Thread Tree View**: Toggle between flat chat and tree structure
|
||||
2. **Thread Statistics**: "X replies from Y participants"
|
||||
3. **Smart Relay Discovery**: Learn which relays have full thread
|
||||
4. **Missing Reply Detection**: "Some replies may be missing" indicator
|
||||
5. **Thread Export**: Save thread as markdown/JSON
|
||||
6. **Quote Highlighting**: Visual indication of quoted text
|
||||
7. **Thread Branching**: Show sub-threads that diverge
|
||||
8. **Participant Indicators**: Show who's been most active
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: What happens if I open a nevent for a kind 9 (group message)?**
|
||||
A: Nip10Adapter returns null, Nip29Adapter handles it instead.
|
||||
|
||||
**Q: Can I reply to the root event from the chat?**
|
||||
A: Yes! Click the Reply button that appears on hover on the root event display.
|
||||
|
||||
**Q: What if the root event is deleted?**
|
||||
A: Adapter throws error "Thread root not found" - cannot display thread.
|
||||
|
||||
**Q: Do reactions work?**
|
||||
A: Yes! Reactions (kind 7) are subscribed to and shown inline via MessageReactions component.
|
||||
|
||||
**Q: Can I zap messages in the thread?**
|
||||
A: Yes! Zaps (kind 9735) are shown as special chat messages with ⚡ indicator.
|
||||
|
||||
**Q: What if relays are slow/offline?**
|
||||
A: EventStore caches events. If relay is offline, cached events still display. New replies won't arrive until relay reconnects.
|
||||
|
||||
**Q: How is this different from opening the note in the feed?**
|
||||
A: Thread chat provides focused, conversation-centric view with root prominent and chat-like UI. Feed view is more action-oriented (repost, quote, etc.).
|
||||
|
||||
**Q: Can I use this for group discussions?**
|
||||
A: It works, but NIP-29 groups are better for persistent communities. NIP-10 threads are ad-hoc, thread-specific.
|
||||
|
||||
**Q: Does it support polls/forms/etc?**
|
||||
A: No, only text replies (kind 1). Other kinds ignored.
|
||||
|
||||
## References
|
||||
|
||||
- [NIP-10: Text Note References](https://github.com/nostr-protocol/nips/blob/master/10.md)
|
||||
- [NIP-19: bech32 Encoding](https://github.com/nostr-protocol/nips/blob/master/19.md)
|
||||
- [NIP-65: Relay List Metadata](https://github.com/nostr-protocol/nips/blob/master/65.md)
|
||||
- [Grimoire Chat System Docs](../CLAUDE.md#chat-system)
|
||||
Reference in New Issue
Block a user