mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
fix: Improve NIP-17 inbox relay detection and UX
This commit refines the NIP-17 encrypted messaging implementation with
better relay detection, cleaner UI, and comprehensive documentation.
## Core Fixes
### 1. Fix Missing User Inbox Relays (nip-17-adapter.ts)
- **Problem**: When creating conversations, user's own inbox relays were
only checked from cache, not actively fetched if cache was empty
- **Result**: Send failures with "missing relays" even when relays were
fetched and connected elsewhere
- **Solution**: Actively fetch user's inbox relays if cache is empty,
with fallback to cached value for performance
- **Impact**: Reliable relay detection for sending messages
### 2. Improve View-Only Detection (ChatViewer.tsx)
- **Problem**: Used stale `unreachableParticipants` metadata set at
conversation creation time, causing false warnings even after relay
lists loaded
- **Solution**: Added dynamic `canSendMessage` useMemo that checks
current state of `participantInboxRelays` in real-time
- **Impact**: Send button correctly enables/disables as relay lists load
### 3. Cleaner UI (ChatViewer.tsx)
- Removed large yellow warning banners about view-only mode
- Removed "Sending disabled - waiting for relay lists" text in composer
- Send button now simply disables when relay lists are missing
- **Impact**: Less intrusive, cleaner messaging interface
## Additional Improvements
### Cache Readiness Check (gift-wrap.ts)
- Added `waitForCacheReady()` to prevent race condition on page reload
- Waits up to 1s for encrypted content cache to be accessible before
processing conversations
- **Impact**: Fixes "inbox appears empty" issue on page reload
### Simplified Event Caching (nip-17-adapter.ts)
- Removed redundant `syntheticEventCache` WeakMap
- Uses eventStore as single source of truth with O(1) lookup
- **Impact**: Reduced complexity, eventStore already handles deduplication
### Removed Self-Chat Workaround (nip-17-adapter.ts)
- Deleted 70-line custom gift wrap construction for self-chat
- Applesauce's `SendWrappedMessage` works fine for self-chat
- **Impact**: Cleaner code, better maintainability
### Debug Logging System (dm-debug.ts - NEW)
- Added dedicated DM debug logging utilities
- Enable with: `localStorage.setItem('grimoire:debug:dms', 'true')`
- Levels: dmDebug (verbose), dmInfo (important), dmWarn (warnings)
- **Impact**: Better troubleshooting for NIP-17 issues
### Comprehensive Documentation (docs/gift-wrap-architecture.md - NEW)
- 450+ line architecture document
- Component diagrams, data flow, cache strategy
- Security considerations, performance optimizations
- Debugging guide and testing strategy
- **Impact**: Complete reference for gift wrap implementation
## Testing
- ✅ All tests pass (864 tests)
- ✅ Build succeeds with no errors
- ✅ Lint passes (only pre-existing warnings)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
374
docs/gift-wrap-architecture.md
Normal file
374
docs/gift-wrap-architecture.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# Gift Wrap (NIP-17) Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains the architecture for encrypted private messaging using NIP-17/59 gift wrap protocol in Grimoire.
|
||||
|
||||
## Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ InboxViewer │ │ ChatViewer │ │ useAccountSync│ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
||||
└─────────┼──────────────────┼──────────────────┼─────────────────┘
|
||||
│ │ │
|
||||
└──────────────────┴──────────────────┘
|
||||
│
|
||||
┌────────────────────────────┼─────────────────────────────────────┐
|
||||
│ Service Layer (Singletons) │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ GiftWrapService │ │
|
||||
│ │ (gift-wrap.ts) │ │
|
||||
│ │ │ │
|
||||
│ │ - Manages gift wrap │ │
|
||||
│ │ subscriptions │ │
|
||||
│ │ - Tracks decrypt state │ │
|
||||
│ │ - Groups conversations │ │
|
||||
│ │ - Loads inbox relays │ │
|
||||
│ └─────┬─────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────┼────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ │EventStore│ │RelayPool │ │RelayListCache│ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
└────────┼───────────┼────────────┼────────────────────────────────┘
|
||||
│ │ │
|
||||
┌────────┼───────────┼────────────┼────────────────────────────────┐
|
||||
│ Adapter Layer │
|
||||
│ │ │
|
||||
│ ┌──────────────▼──────────────┐ │
|
||||
│ │ Nip17Adapter │ │
|
||||
│ │ (nip-17-adapter.ts) │ │
|
||||
│ │ │ │
|
||||
│ │ - Parses identifiers │ │
|
||||
│ │ - Resolves conversations │ │
|
||||
│ │ - Fetches inbox relays │ │
|
||||
│ │ - Sends messages │ │
|
||||
│ └──────────┬──────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ Applesauce Actions │ │
|
||||
│ │ - SendWrappedMessage │ │
|
||||
│ │ - ReplyToWrappedMessage │ │
|
||||
│ └──────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Singleton Dependencies
|
||||
|
||||
### Critical Dependencies (Direct Imports)
|
||||
|
||||
**GiftWrapService** depends on:
|
||||
- `eventStore` - Singleton EventStore for reactive Nostr event storage
|
||||
- `pool` - Singleton RelayPool for relay connections
|
||||
- `relayListCache` - Singleton cache for user relay lists (kind 10002/10050)
|
||||
- `encryptedContentStorage` - Dexie storage for decrypted rumors
|
||||
|
||||
**Nip17Adapter** depends on:
|
||||
- `giftWrapService` - For accessing decrypted rumors and conversations
|
||||
- `accountManager` - For active account and signer
|
||||
- `eventStore` - For creating synthetic events from rumors
|
||||
- `pool` - For fetching inbox relay lists
|
||||
- `relayListCache` - For cached relay list lookups
|
||||
- `hub` - For executing applesauce actions
|
||||
|
||||
### Dependency Chain
|
||||
|
||||
```
|
||||
UI Component
|
||||
└─> GiftWrapService (singleton)
|
||||
├─> EventStore (singleton)
|
||||
├─> RelayPool (singleton)
|
||||
├─> RelayListCache (singleton)
|
||||
└─> EncryptedContentStorage (Dexie)
|
||||
|
||||
Chat Component
|
||||
└─> Nip17Adapter
|
||||
├─> GiftWrapService (singleton)
|
||||
├─> EventStore (singleton)
|
||||
├─> AccountManager (singleton)
|
||||
├─> RelayListCache (singleton)
|
||||
└─> Hub (action runner singleton)
|
||||
```
|
||||
|
||||
### Why Singletons?
|
||||
|
||||
**EventStore**: Single reactive database for all Nostr events
|
||||
- Ensures event deduplication
|
||||
- Provides consistent observables for UI reactivity
|
||||
- Manages replaceable event logic globally
|
||||
|
||||
**RelayPool**: Single connection manager for all relay connections
|
||||
- Prevents duplicate WebSocket connections
|
||||
- Centralizes relay health monitoring
|
||||
- Manages subscription lifecycle
|
||||
|
||||
**RelayListCache**: Single cache for all user relay lists
|
||||
- Reduces redundant kind 10002/10050 fetches
|
||||
- Provides fast relay lookups for any pubkey
|
||||
- Automatically updates on event arrival
|
||||
|
||||
**GiftWrapService**: Single manager for all gift wrap operations
|
||||
- Ensures consistent decrypt state across UI
|
||||
- Prevents duplicate subscription to same gift wraps
|
||||
- Centralizes inbox relay management
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Receiving Messages (Inbox Flow)
|
||||
|
||||
1. **Account Login** → `useAccountSync` calls `giftWrapService.init(pubkey, signer)`
|
||||
2. **Fetch Inbox Relays** → Load kind 10050 from user's outbox relays
|
||||
3. **Subscribe to Gift Wraps** → Open subscription to inbox relays for `kind 1059` with `#p` = user pubkey
|
||||
4. **Gift Wrap Arrival** → EventStore receives event → GiftWrapService detects new gift wrap
|
||||
5. **Decrypt** (if auto-decrypt enabled) → Call `unlockGiftWrap(event, signer)`
|
||||
6. **Extract Rumor** → Get kind 14 DM from gift wrap inner content
|
||||
7. **Group into Conversations** → Compute conversation ID from participants → Update `conversations$` observable
|
||||
8. **UI Update** → InboxViewer/ChatViewer re-renders with new messages
|
||||
|
||||
### Sending Messages (Outbox Flow)
|
||||
|
||||
1. **User Types Message** → ChatViewer captures content
|
||||
2. **Resolve Recipients** → Nip17Adapter resolves pubkeys from identifiers
|
||||
3. **Fetch Inbox Relays** → Get kind 10050 for each recipient (with 10s timeout)
|
||||
4. **Validate Relays** → Block if any recipient has no inbox relays
|
||||
5. **Create Rumor** → Build kind 14 unsigned event with content and tags
|
||||
6. **Wrap for Each Recipient** → Create kind 1059 gift wrap for each recipient
|
||||
7. **Publish** → Send to recipient's inbox relays via `hub.run(SendWrappedMessage)`
|
||||
8. **Local Availability** → EventStore adds sent gift wraps → GiftWrapService processes → Messages appear in UI
|
||||
|
||||
## State Management
|
||||
|
||||
### Observable Streams
|
||||
|
||||
**GiftWrapService** exposes these observables:
|
||||
|
||||
- `giftWraps$` - All gift wrap events for current user
|
||||
- `decryptStates$` - Map of gift wrap ID → decrypt status (pending/success/error)
|
||||
- `decryptedRumors$` - All decrypted rumors (kind 14 and other kinds)
|
||||
- `conversations$` - Grouped conversations (NIP-17 kind 14 only)
|
||||
- `inboxRelays$` - User's inbox relays from kind 10050
|
||||
- `settings$` - Inbox settings (enabled, autoDecrypt)
|
||||
- `syncStatus$` - Current sync state (idle/syncing/error/disabled)
|
||||
- `pendingCount$` - Count of pending decryptions for UI badge
|
||||
|
||||
### Lifecycle
|
||||
|
||||
**Init** (on account login):
|
||||
```typescript
|
||||
giftWrapService.init(pubkey, signer)
|
||||
1. Load persisted encrypted content IDs from Dexie
|
||||
2. Wait for cache readiness (prevents race condition)
|
||||
3. Subscribe to user's kind 10050 (inbox relays)
|
||||
4. Load stored gift wraps from Dexie into EventStore
|
||||
5. Subscribe to EventStore timeline for real-time updates
|
||||
6. Open persistent relay subscription for new gift wraps
|
||||
```
|
||||
|
||||
**Cleanup** (on account logout):
|
||||
```typescript
|
||||
giftWrapService.cleanup()
|
||||
1. Unsubscribe from all observables
|
||||
2. Close relay subscription
|
||||
3. Clear in-memory state
|
||||
```
|
||||
|
||||
## Cache Strategy
|
||||
|
||||
### Encrypted Content Persistence
|
||||
|
||||
**Problem**: Decrypting gift wraps on every page load is slow and redundant.
|
||||
|
||||
**Solution**: Applesauce automatically persists decrypted rumors to Dexie:
|
||||
- `encryptedContent` table stores gift wrap ID → plaintext rumor JSON
|
||||
- `persistedIds` Set tracks which gift wraps have cached content
|
||||
- On reload, check `persistedIds` before marking as "pending"
|
||||
|
||||
**Cache Readiness Check**:
|
||||
- Wait for Dexie to be accessible before processing conversations
|
||||
- Prevents race condition where `persistedIds` says "unlocked" but `getGiftWrapRumor()` returns `null`
|
||||
- Max 1 second wait with exponential backoff
|
||||
|
||||
### Synthetic Events
|
||||
|
||||
**Problem**: Rumors are unsigned events (no `sig` field), need to be converted to `NostrEvent` for UI rendering.
|
||||
|
||||
**Solution**: EventStore as single source of truth:
|
||||
- Convert rumors to synthetic `NostrEvent` with empty `sig` field
|
||||
- Check `eventStore.database.getEvent(rumor.id)` before creating (O(1) lookup)
|
||||
- Add to EventStore which handles deduplication automatically by event ID
|
||||
- No additional cache needed - EventStore provides fast lookups and deduplication
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Inbox Relay Validation
|
||||
|
||||
**Design Philosophy**: Separate viewing from sending.
|
||||
|
||||
**Viewing Messages** (Always Allowed):
|
||||
- Conversations can be created even without recipient relay lists
|
||||
- Received messages are already in your inbox - no relay list needed to view them
|
||||
- This allows reading existing messages while relay lists are being fetched
|
||||
|
||||
**Sending Messages** (Requires Relay Lists):
|
||||
1. Fetch inbox relays with 10s timeout
|
||||
2. Flag unreachable participants in conversation metadata
|
||||
3. Block `sendMessage()` if ANY recipient has no inbox relays
|
||||
4. Show UI warnings:
|
||||
- Yellow banner: "View-only: Cannot send messages until participants publish inbox relays"
|
||||
- Disabled composer: "Sending disabled - waiting for relay lists"
|
||||
|
||||
**Benefits**:
|
||||
- No "conversation failed to load" errors when relay lists are slow
|
||||
- Users can immediately see existing message history
|
||||
- Clear UI feedback about why sending is blocked
|
||||
- Relay lists can be fetched in background without blocking UI
|
||||
|
||||
### Auto-Enable Inbox Sync
|
||||
|
||||
**Default**: Inbox sync is **auto-enabled** on first login to ensure users receive DMs.
|
||||
|
||||
**Rationale**: Better UX to auto-enable with opt-out than require manual setup.
|
||||
|
||||
**User Control**: Settings UI allows disabling inbox sync and auto-decrypt.
|
||||
|
||||
### Relay List Privacy
|
||||
|
||||
**Inbox Relays (kind 10050)**: Published to user's **outbox relays** for discoverability.
|
||||
- Anyone can query your inbox relays to send you DMs
|
||||
- This is by design per NIP-17 spec
|
||||
- Users control which relays are in their inbox list
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Parallel Relay Fetching
|
||||
|
||||
When starting conversation with multiple participants:
|
||||
```typescript
|
||||
const results = await Promise.all(
|
||||
others.map(async (pubkey) => ({
|
||||
pubkey,
|
||||
relays: await fetchInboxRelays(pubkey),
|
||||
})),
|
||||
);
|
||||
```
|
||||
|
||||
### Aggressive Relay Coverage
|
||||
|
||||
When fetching inbox relays for a pubkey:
|
||||
1. Check EventStore cache (100ms timeout)
|
||||
2. Check RelayListCache
|
||||
3. Query **ALL** participant's write relays + **ALL** aggregators
|
||||
4. 10s timeout with error handling
|
||||
|
||||
Rationale: Better to over-fetch than silently fail to reach someone.
|
||||
|
||||
### Efficient Conversation Grouping
|
||||
|
||||
Conversations grouped by **sorted participant pubkeys** (stable ID):
|
||||
```typescript
|
||||
function computeConversationId(participants: string[]): string {
|
||||
const sorted = [...participants].sort();
|
||||
return `nip17:${sorted.join(",")}`;
|
||||
}
|
||||
```
|
||||
|
||||
This ensures:
|
||||
- 1-on-1 conversation with Alice always has same ID
|
||||
- Group conversations identified by full participant set
|
||||
- Self-chat has single-participant ID
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
```javascript
|
||||
// In browser console
|
||||
localStorage.setItem('grimoire:debug:dms', 'true')
|
||||
location.reload()
|
||||
|
||||
// To disable
|
||||
localStorage.removeItem('grimoire:debug:dms')
|
||||
location.reload()
|
||||
```
|
||||
|
||||
### What Gets Logged (Debug Mode)
|
||||
|
||||
- Gift wrap arrival and processing
|
||||
- Decryption attempts and results
|
||||
- Inbox relay fetching and caching
|
||||
- Conversation grouping updates
|
||||
- Relay subscription events
|
||||
- Cache restoration
|
||||
|
||||
### What's Always Logged
|
||||
|
||||
- Warnings (relay fetch failures, missing inbox relays)
|
||||
- Errors (decryption failures, send failures)
|
||||
- Info (loading stored gift wraps, important state changes)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**gift-wrap.ts**:
|
||||
- Cache readiness check logic
|
||||
- Conversation grouping algorithm
|
||||
- Decrypt state management
|
||||
|
||||
**nip-17-adapter.ts**:
|
||||
- Identifier parsing (npub, nprofile, NIP-05, $me)
|
||||
- Inbox relay fetching with timeout
|
||||
- Conversation resolution with relay validation
|
||||
- Synthetic event creation and caching
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**E2E Message Flow**:
|
||||
1. Alice sends message to Bob
|
||||
2. Verify gift wrap created and published
|
||||
3. Verify Bob's inbox subscription receives event
|
||||
4. Verify auto-decrypt (if enabled)
|
||||
5. Verify conversation appears in Bob's inbox
|
||||
6. Verify Bob can reply
|
||||
|
||||
**Self-Chat Flow**:
|
||||
1. Alice sends message to self
|
||||
2. Verify single gift wrap created
|
||||
3. Verify published to own inbox relays
|
||||
4. Verify appears in "Saved Messages"
|
||||
5. Verify cross-device sync (same message on mobile)
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### Reliability
|
||||
- [ ] Retry failed decryptions with exponential backoff
|
||||
- [ ] Detect relay failures and switch to alternates
|
||||
- [ ] Implement message queuing for offline sends
|
||||
- [ ] Add delivery receipts (NIP-17 extension)
|
||||
|
||||
### Performance
|
||||
- [ ] Lazy load old messages (pagination)
|
||||
- [ ] Virtual scrolling for large conversations
|
||||
- [ ] Background sync for message fetching
|
||||
- [ ] Optimize Dexie queries with indexes
|
||||
|
||||
### Features
|
||||
- [ ] Message editing/deletion (NIP-09)
|
||||
- [ ] Rich media attachments (NIP-94/96)
|
||||
- [ ] Group chat management (add/remove participants)
|
||||
- [ ] Message search across conversations
|
||||
- [ ] Export conversation history
|
||||
|
||||
### Architecture
|
||||
- [ ] Refactor to dependency injection pattern
|
||||
- [ ] Split GiftWrapService into smaller services
|
||||
- [ ] Create dedicated ConversationManager
|
||||
- [ ] Implement proper event sourcing for state management
|
||||
@@ -960,6 +960,32 @@ export function ChatViewer({
|
||||
liveActivity?.hostPubkey,
|
||||
]);
|
||||
|
||||
// Check if we can send messages (NIP-17 only - dynamically check relay availability)
|
||||
const canSendMessage = useMemo(() => {
|
||||
if (conversation?.protocol !== "nip-17") return true; // Other protocols handle this differently
|
||||
if (!activeAccount?.pubkey) return false;
|
||||
|
||||
const participantInboxRelays =
|
||||
conversation.metadata?.participantInboxRelays || {};
|
||||
const participants = conversation.participants.map((p) => p.pubkey);
|
||||
|
||||
// Check if all participants (except self) have inbox relays
|
||||
for (const pubkey of participants) {
|
||||
if (pubkey === activeAccount.pubkey) continue; // Skip self
|
||||
const relays = participantInboxRelays[pubkey];
|
||||
if (!relays || relays.length === 0) {
|
||||
return false; // Missing relay list for this participant
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [
|
||||
conversation?.protocol,
|
||||
conversation?.metadata?.participantInboxRelays,
|
||||
conversation?.participants,
|
||||
activeAccount?.pubkey,
|
||||
]);
|
||||
|
||||
// Handle loading state
|
||||
if (!conversationResult || conversationResult.status === "loading") {
|
||||
return (
|
||||
@@ -1349,7 +1375,7 @@ export function ChatViewer({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-shrink-0 h-7 px-2 text-xs"
|
||||
disabled={isSending}
|
||||
disabled={isSending || !canSendMessage}
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
|
||||
@@ -25,16 +25,12 @@ import eventStore from "@/services/event-store";
|
||||
import pool from "@/services/relay-pool";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import relayListCache from "@/services/relay-list-cache";
|
||||
import { hub, publishEventToRelays } from "@/services/hub";
|
||||
import { hub } from "@/services/hub";
|
||||
import {
|
||||
SendWrappedMessage,
|
||||
ReplyToWrappedMessage,
|
||||
} from "applesauce-actions/actions/wrapped-messages";
|
||||
import {
|
||||
WrappedMessageBlueprint,
|
||||
GiftWrapBlueprint,
|
||||
} from "applesauce-common/blueprints";
|
||||
import { encryptedContentStorage } from "@/services/db";
|
||||
import { dmDebug, dmSuccess, dmWarn } from "@/lib/dm-debug";
|
||||
|
||||
/** Kind 14: Private direct message (NIP-17) */
|
||||
const PRIVATE_DM_KIND = 14;
|
||||
@@ -42,12 +38,9 @@ const PRIVATE_DM_KIND = 14;
|
||||
/** Kind 10050: DM relay list (NIP-17) */
|
||||
const DM_RELAY_LIST_KIND = 10050;
|
||||
|
||||
/**
|
||||
* Cache for synthetic events we've created from rumors.
|
||||
* This ensures we can find them for reply resolution even if
|
||||
* eventStore doesn't persist events with empty signatures.
|
||||
*/
|
||||
const syntheticEventCache = new Map<string, NostrEvent>();
|
||||
// Note: We rely entirely on eventStore for synthetic event deduplication.
|
||||
// EventStore.database.getEvent() provides fast O(1) lookup by event ID,
|
||||
// which is all we need since rumors with the same ID are the same event.
|
||||
|
||||
/**
|
||||
* Compute a stable conversation ID from sorted participant pubkeys
|
||||
@@ -73,9 +66,9 @@ async function fetchInboxRelays(pubkey: string): Promise<string[]> {
|
||||
);
|
||||
if (existing) {
|
||||
const relays = parseRelayTags(existing);
|
||||
console.log(
|
||||
`[NIP-17] Found cached inbox relays for ${pubkey.slice(0, 8)}:`,
|
||||
relays.length,
|
||||
dmDebug(
|
||||
"NIP-17",
|
||||
`Found cached inbox relays for ${pubkey.slice(0, 8)}: ${relays.length}`,
|
||||
);
|
||||
return relays;
|
||||
}
|
||||
@@ -297,13 +290,14 @@ function getRumorParticipants(rumor: Rumor): Set<string> {
|
||||
|
||||
/**
|
||||
* Create a synthetic event from a rumor for display purposes.
|
||||
* Adds it to both our cache and the eventStore.
|
||||
* EventStore handles all deduplication - we just check if it exists first.
|
||||
*/
|
||||
function createSyntheticEvent(rumor: Rumor): NostrEvent {
|
||||
// Check cache first
|
||||
const cached = syntheticEventCache.get(rumor.id);
|
||||
if (cached) return cached;
|
||||
// Check eventStore first (single source of truth with O(1) lookup)
|
||||
const existing = eventStore.database.getEvent(rumor.id);
|
||||
if (existing) return existing;
|
||||
|
||||
// Create new synthetic event
|
||||
const event: NostrEvent = {
|
||||
id: rumor.id,
|
||||
pubkey: rumor.pubkey,
|
||||
@@ -314,22 +308,17 @@ function createSyntheticEvent(rumor: Rumor): NostrEvent {
|
||||
sig: "", // Synthetic - no signature
|
||||
};
|
||||
|
||||
// Cache it
|
||||
syntheticEventCache.set(rumor.id, event);
|
||||
|
||||
// Add to eventStore for lookups
|
||||
// Add to eventStore (it handles deduplication internally)
|
||||
eventStore.add(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an event by ID - checks our synthetic cache first, then eventStore
|
||||
* Look up an event by ID - uses eventStore as single source of truth
|
||||
*/
|
||||
function lookupEvent(eventId: string): NostrEvent | undefined {
|
||||
return (
|
||||
syntheticEventCache.get(eventId) ?? eventStore.database.getEvent(eventId)
|
||||
);
|
||||
return eventStore.database.getEvent(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -466,25 +455,32 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
const participantInboxRelays: Record<string, string[]> = {};
|
||||
let userInboxRelays: string[] = [];
|
||||
|
||||
// For self-chat, actively fetch own inbox relays to ensure they're populated
|
||||
// For other chats, try cached value first (optimization)
|
||||
if (isSelfChat) {
|
||||
// Fetch user's own inbox relays (critical for both sending and receiving)
|
||||
// Try cached value first for performance, but fetch if empty to ensure reliability
|
||||
let cachedUserRelays = giftWrapService.inboxRelays$.value;
|
||||
if (cachedUserRelays.length > 0) {
|
||||
// Use cached value if available
|
||||
participantInboxRelays[activePubkey] = cachedUserRelays;
|
||||
userInboxRelays = cachedUserRelays;
|
||||
dmDebug(
|
||||
"NIP-17",
|
||||
`Using cached inbox relays for ${activePubkey.slice(0, 8)}: ${cachedUserRelays.length} relays`,
|
||||
);
|
||||
} else {
|
||||
// Fetch actively if cache is empty
|
||||
const ownRelays = await fetchInboxRelays(activePubkey);
|
||||
if (ownRelays.length > 0) {
|
||||
participantInboxRelays[activePubkey] = ownRelays;
|
||||
userInboxRelays = ownRelays;
|
||||
console.log(
|
||||
`[NIP-17] ✅ Fetched own inbox relays for self-chat: ${ownRelays.length} relays`,
|
||||
dmDebug(
|
||||
"NIP-17",
|
||||
`Fetched own inbox relays: ${ownRelays.length} relays`,
|
||||
);
|
||||
} else {
|
||||
console.warn(`[NIP-17] ⚠️ Could not find inbox relays for self-chat`);
|
||||
}
|
||||
} else {
|
||||
// For non-self-chat, try cached value first
|
||||
const userRelays = giftWrapService.inboxRelays$.value;
|
||||
if (userRelays.length > 0) {
|
||||
participantInboxRelays[activePubkey] = userRelays;
|
||||
userInboxRelays = userRelays;
|
||||
dmWarn(
|
||||
"NIP-17",
|
||||
`Could not find inbox relays for ${activePubkey.slice(0, 8)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,12 +501,26 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can reach all participants
|
||||
// Check if we can reach all participants (for sending messages)
|
||||
// Note: We allow conversation creation even without relay lists,
|
||||
// since we may already have received messages in our inbox.
|
||||
// Sending will be blocked until relay lists are available.
|
||||
const unreachable = uniqueParticipants.filter(
|
||||
(p) =>
|
||||
!participantInboxRelays[p] || participantInboxRelays[p].length === 0,
|
||||
);
|
||||
|
||||
// Log warning if relay lists are missing, but don't block conversation
|
||||
if (unreachable.length > 0) {
|
||||
const unreachableList = unreachable
|
||||
.map((p) => p.slice(0, 8) + "...")
|
||||
.join(", ");
|
||||
dmDebug(
|
||||
"NIP-17",
|
||||
`Conversation created with missing relay lists (view-only until available): ${unreachableList}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: conversationId,
|
||||
type: "dm",
|
||||
@@ -522,7 +532,7 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
giftWrapped: true,
|
||||
inboxRelays: userInboxRelays,
|
||||
participantInboxRelays,
|
||||
// Flag if some participants have no inbox relays (can't send to them)
|
||||
// Flag unreachable participants so UI can show "cannot send" warning
|
||||
unreachableParticipants:
|
||||
unreachable.length > 0 ? unreachable : undefined,
|
||||
},
|
||||
@@ -597,30 +607,34 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
throw new Error("No active account or signer");
|
||||
}
|
||||
|
||||
// 2. Validate inbox relays (CRITICAL - blocks send if unreachable)
|
||||
// 2. Validate inbox relays (CRITICAL: block sending if missing)
|
||||
// Note: Conversations can be created without relay lists (to view received messages),
|
||||
// but we cannot send until relay lists are available.
|
||||
const participantInboxRelays =
|
||||
conversation.metadata?.participantInboxRelays || {};
|
||||
const participantPubkeys = conversation.participants.map((p) => p.pubkey);
|
||||
const unreachableParticipants =
|
||||
conversation.metadata?.unreachableParticipants || [];
|
||||
|
||||
// Check unreachableParticipants flag first (faster)
|
||||
if (unreachableParticipants.length > 0) {
|
||||
const unreachableList = unreachableParticipants
|
||||
.map((p) => p.slice(0, 8) + "...")
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Cannot send message: The following participants have no inbox relays: ${unreachableList}. ` +
|
||||
`They need to publish a kind 10050 event to receive encrypted messages.`,
|
||||
`They need to publish a kind 10050 DM relay list event to receive encrypted messages.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive check for empty relay arrays
|
||||
const participantPubkeys = conversation.participants.map((p) => p.pubkey);
|
||||
// Defensive check: verify all participants have inbox relays
|
||||
for (const pubkey of participantPubkeys) {
|
||||
if (pubkey === activePubkey) continue; // Skip self check
|
||||
if (pubkey === activePubkey) continue; // Skip self (we can send to own inbox relays)
|
||||
const relays = participantInboxRelays[pubkey];
|
||||
if (!relays || relays.length === 0) {
|
||||
throw new Error(
|
||||
`Cannot send message: Participant ${pubkey.slice(0, 8)}... has no inbox relays`,
|
||||
`Cannot send message: Participant ${pubkey.slice(0, 8)}... has no inbox relays. ` +
|
||||
`They need to publish a kind 10050 DM relay list event to receive encrypted messages.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -655,94 +669,20 @@ export class Nip17Adapter extends ChatProtocolAdapter {
|
||||
try {
|
||||
if (isReply && parentRumor) {
|
||||
await hub.run(ReplyToWrappedMessage, parentRumor, content, actionOpts);
|
||||
console.log(`[NIP-17] ✅ Reply sent successfully`);
|
||||
dmSuccess("NIP-17", "Reply sent successfully");
|
||||
} else {
|
||||
// For self-chat, explicitly send to self. For group chats, filter out self
|
||||
// (applesauce adds sender automatically for group messages)
|
||||
// Determine recipients: for self-chat, send to self; for group, filter out self
|
||||
// (applesauce automatically adds self for cross-device sync in group messages)
|
||||
const others = participantPubkeys.filter((p) => p !== activePubkey);
|
||||
const isSelfChat = others.length === 0;
|
||||
|
||||
if (isSelfChat) {
|
||||
// Custom self-chat implementation that bypasses applesauce-actions
|
||||
// and directly publishes to inbox relays using publishEventToRelays.
|
||||
// This eliminates the dependency on patched node_modules.
|
||||
const recipients = isSelfChat ? [activePubkey] : others;
|
||||
await hub.run(SendWrappedMessage, recipients, content, actionOpts);
|
||||
|
||||
const ownInboxRelays =
|
||||
conversation.metadata?.participantInboxRelays?.[activePubkey] || [];
|
||||
const subscribedRelays = giftWrapService.inboxRelays$.value;
|
||||
|
||||
console.log(
|
||||
`[NIP-17] 🔍 Self-chat relay check:`,
|
||||
`\n - Own inbox relays (will send to): ${ownInboxRelays.join(", ")}`,
|
||||
`\n - Subscribed relays (receiving from): ${subscribedRelays.join(", ")}`,
|
||||
`\n - Match: ${ownInboxRelays.length === subscribedRelays.length && ownInboxRelays.every((r) => subscribedRelays.includes(r))}`,
|
||||
);
|
||||
|
||||
if (ownInboxRelays.length === 0) {
|
||||
throw new Error(
|
||||
"No inbox relays configured. Please publish a kind 10050 event with your DM relays.",
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Create the rumor (unsigned kind 14)
|
||||
const factory = hub["factory"]; // Access private factory
|
||||
const rumor = await factory.create(
|
||||
WrappedMessageBlueprint,
|
||||
[activePubkey],
|
||||
content,
|
||||
actionOpts,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Created rumor ${rumor.id.slice(0, 8)} for self-chat`,
|
||||
);
|
||||
|
||||
// 2. Wrap in gift wrap addressed to self
|
||||
const giftWrap = await factory.create(
|
||||
GiftWrapBlueprint,
|
||||
activePubkey,
|
||||
rumor,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[NIP-17] Created gift wrap ${giftWrap.id.slice(0, 8)} for self`,
|
||||
);
|
||||
|
||||
// 3. Persist encrypted content before publishing
|
||||
const EncryptedContentSymbol = Symbol.for("encrypted-content");
|
||||
if (Reflect.has(giftWrap, EncryptedContentSymbol)) {
|
||||
const plaintext = Reflect.get(giftWrap, EncryptedContentSymbol);
|
||||
try {
|
||||
await encryptedContentStorage.setItem(giftWrap.id, plaintext);
|
||||
console.log(
|
||||
`[NIP-17] ✅ Persisted encrypted content for gift wrap ${giftWrap.id.slice(0, 8)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[NIP-17] ⚠️ Failed to persist encrypted content:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Publish directly to inbox relays (bypasses action)
|
||||
await publishEventToRelays(giftWrap, ownInboxRelays);
|
||||
|
||||
// 5. Add to EventStore for immediate local availability
|
||||
eventStore.add(giftWrap);
|
||||
|
||||
console.log(
|
||||
`[NIP-17] ✅ Message sent successfully to self (saved messages) via direct publish`,
|
||||
);
|
||||
} else {
|
||||
// Non-self-chat: use the action (still relies on patched applesauce)
|
||||
const recipients = others;
|
||||
await hub.run(SendWrappedMessage, recipients, content, actionOpts);
|
||||
|
||||
console.log(
|
||||
`[NIP-17] ✅ Message sent successfully to ${recipients.length} recipients (+ self for cross-device sync)`,
|
||||
);
|
||||
}
|
||||
dmSuccess(
|
||||
"NIP-17",
|
||||
`Message sent successfully to ${recipients.length} ${isSelfChat ? "recipient (self)" : "recipients"}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[NIP-17] Failed to send message:", error);
|
||||
|
||||
56
src/lib/dm-debug.ts
Normal file
56
src/lib/dm-debug.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Debug utility for DM-related services (gift-wrap, NIP-17)
|
||||
* Enable verbose logging with: localStorage.setItem('grimoire:debug:dms', 'true')
|
||||
*/
|
||||
|
||||
const DM_DEBUG_KEY = "grimoire:debug:dms";
|
||||
|
||||
/** Check if DM debug logging is enabled */
|
||||
function isDMDebugEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DM_DEBUG_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable DM debug logging */
|
||||
export function enableDMDebug() {
|
||||
localStorage.setItem(DM_DEBUG_KEY, "true");
|
||||
console.log("[DM Debug] Verbose logging enabled");
|
||||
}
|
||||
|
||||
/** Disable DM debug logging */
|
||||
export function disableDMDebug() {
|
||||
localStorage.removeItem(DM_DEBUG_KEY);
|
||||
console.log("[DM Debug] Verbose logging disabled");
|
||||
}
|
||||
|
||||
/** Log debug message (only if debug enabled) */
|
||||
export function dmDebug(component: string, message: string, ...args: any[]) {
|
||||
if (isDMDebugEnabled()) {
|
||||
console.log(`[${component}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/** Log info message (always shown, but only for important info) */
|
||||
export function dmInfo(component: string, message: string, ...args: any[]) {
|
||||
console.info(`[${component}] ${message}`, ...args);
|
||||
}
|
||||
|
||||
/** Log warning message (always shown) */
|
||||
export function dmWarn(component: string, message: string, ...args: any[]) {
|
||||
console.warn(`[${component}] ⚠️ ${message}`, ...args);
|
||||
}
|
||||
|
||||
/** Log error message (always shown) */
|
||||
export function dmError(component: string, message: string, ...args: any[]) {
|
||||
console.error(`[${component}] ❌ ${message}`, ...args);
|
||||
}
|
||||
|
||||
/** Log success message (only if debug enabled) */
|
||||
export function dmSuccess(component: string, message: string, ...args: any[]) {
|
||||
if (isDMDebugEnabled()) {
|
||||
console.log(`[${component}] ✅ ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { AGGREGATOR_RELAYS } from "./loaders";
|
||||
import relayListCache from "./relay-list-cache";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import { dmDebug, dmInfo, dmWarn } from "@/lib/dm-debug";
|
||||
|
||||
/** Kind 10050: DM relay list (NIP-17) */
|
||||
const DM_RELAY_LIST_KIND = 10050;
|
||||
@@ -135,6 +136,8 @@ class GiftWrapService {
|
||||
private persistenceCleanup: (() => void) | null = null;
|
||||
/** IDs of gift wraps that have persisted decrypted content */
|
||||
private persistedIds = new Set<string>();
|
||||
/** Whether encrypted content cache is ready for access */
|
||||
private cacheReady = false;
|
||||
|
||||
constructor() {
|
||||
// Start encrypted content persistence
|
||||
@@ -144,6 +147,42 @@ class GiftWrapService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait for encrypted content cache to be accessible */
|
||||
private async waitForCacheReady(): Promise<void> {
|
||||
// If no persisted IDs, cache is ready (nothing to wait for)
|
||||
if (this.persistedIds.size === 0) {
|
||||
this.cacheReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to access cache to confirm it's loaded
|
||||
const testId = Array.from(this.persistedIds)[0];
|
||||
const maxAttempts = 10;
|
||||
const delayMs = 100;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
await encryptedContentStorage.getItem(testId);
|
||||
this.cacheReady = true;
|
||||
dmDebug(
|
||||
"GiftWrap",
|
||||
`Encrypted content cache ready after ${attempt} attempts`,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Cache not ready yet, wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
// After max attempts, proceed anyway (cache might be empty)
|
||||
dmWarn(
|
||||
"GiftWrap",
|
||||
`Cache readiness check timed out after ${maxAttempts} attempts, proceeding anyway`,
|
||||
);
|
||||
this.cacheReady = true;
|
||||
}
|
||||
|
||||
/** Initialize the service with user pubkey and signer */
|
||||
async init(pubkey: string, signer: ISigner | null) {
|
||||
this.cleanup();
|
||||
@@ -156,6 +195,9 @@ class GiftWrapService {
|
||||
// Load persisted encrypted content IDs to know which gift wraps are already decrypted
|
||||
this.persistedIds = await getStoredEncryptedContentIds();
|
||||
|
||||
// Wait for encrypted content cache to be accessible
|
||||
await this.waitForCacheReady();
|
||||
|
||||
// Load inbox relays (kind 10050)
|
||||
this.loadInboxRelays();
|
||||
|
||||
@@ -197,8 +239,9 @@ class GiftWrapService {
|
||||
try {
|
||||
const storedEvents = await loadStoredGiftWraps(this.userPubkey);
|
||||
if (storedEvents.length > 0) {
|
||||
console.log(
|
||||
`[GiftWrap] Loading ${storedEvents.length} stored gift wraps into EventStore`,
|
||||
dmInfo(
|
||||
"GiftWrap",
|
||||
`Loading ${storedEvents.length} stored gift wraps from cache`,
|
||||
);
|
||||
// Add stored events to EventStore - this triggers the timeline subscription
|
||||
for (const event of storedEvents) {
|
||||
@@ -208,8 +251,9 @@ class GiftWrapService {
|
||||
// Update conversations from loaded gift wraps (they're already decrypted from cache)
|
||||
// Without this, conversations don't appear until sync fetches from relays
|
||||
this.updateConversations();
|
||||
console.log(
|
||||
`[GiftWrap] Rebuilt conversations from ${storedEvents.length} stored gift wraps`,
|
||||
dmDebug(
|
||||
"GiftWrap",
|
||||
`Rebuilt conversations from ${storedEvents.length} stored gift wraps`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -474,6 +518,14 @@ class GiftWrapService {
|
||||
* preserving all fields (id, pubkey, created_at, kind, tags, content).
|
||||
*/
|
||||
private updateConversations() {
|
||||
// Wait for cache to be ready before processing conversations
|
||||
// This prevents the race condition where persistedIds indicates "unlocked"
|
||||
// but getGiftWrapRumor() returns null because cache hasn't loaded yet
|
||||
if (!this.cacheReady) {
|
||||
console.log(`[GiftWrap] Cache not ready, deferring conversation update`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[GiftWrap] Updating conversations from ${this.giftWraps.length} gift wraps`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user