Files
eranos/src/components/DMProvider.tsx
T
shakespeare.diy 98ff1e09e0 New project created with Shakespeare
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-02-16 16:54:01 -06:00

1525 lines
53 KiB
TypeScript

import { useEffect, useState, ReactNode, useCallback, useMemo, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostr } from '@nostrify/react';
import { useAppContext } from '@/hooks/useAppContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { validateDMEvent } from '@/lib/dmUtils';
import { LOADING_PHASES, type LoadingPhase, PROTOCOL_MODE, type ProtocolMode } from '@/lib/dmConstants';
import { NSecSigner, type NostrEvent } from '@nostrify/nostrify';
import { generateSecretKey } from 'nostr-tools';
import type { MessageProtocol } from '@/lib/dmConstants';
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
import { DMContext, DMContextType, FileAttachment } from '@/contexts/DMContext';
// ============================================================================
// DM Types and Constants
// ============================================================================
interface ParticipantData {
messages: DecryptedMessage[];
lastActivity: number;
lastMessage: DecryptedMessage | null;
hasNIP4: boolean;
hasNIP17: boolean;
}
type MessagesState = Map<string, ParticipantData>;
interface LastSyncData {
nip4: number | null;
nip17: number | null;
}
interface SubscriptionStatus {
isNIP4Connected: boolean;
isNIP17Connected: boolean;
}
interface ScanProgress {
current: number;
status: string;
}
interface ScanProgressState {
nip4: ScanProgress | null;
nip17: ScanProgress | null;
}
interface ConversationSummary {
id: string;
pubkey: string;
lastMessage: DecryptedMessage | null;
lastActivity: number;
hasNIP4Messages: boolean;
hasNIP17Messages: boolean;
isKnown: boolean;
isRequest: boolean;
lastMessageFromUser: boolean;
}
interface MessageProcessingResult {
lastMessageTimestamp?: number;
messageCount: number;
}
interface DecryptionResult {
decryptedContent: string;
error?: string;
}
interface DecryptedMessage extends NostrEvent {
decryptedContent?: string;
error?: string;
isSending?: boolean;
clientFirstSeen?: number;
decryptedEvent?: NostrEvent; // For NIP-17: the inner kind 14/15 event
originalGiftWrapId?: string; // Store gift wrap ID for NIP-17 deduplication
}
interface NIP17ProcessingResult {
processedMessage: DecryptedMessage;
conversationPartner: string;
sealEvent: NostrEvent; // Return the seal so we can cache it
}
const DM_CONSTANTS = {
DEBOUNCED_WRITE_DELAY: 15000,
RECENT_MESSAGE_THRESHOLD: 5000,
SUBSCRIPTION_OVERLAP_SECONDS: 10, // Overlap for subscriptions to catch race conditions
SCAN_TOTAL_LIMIT: 20000,
SCAN_BATCH_SIZE: 1000,
NIP4_QUERY_TIMEOUT: 15000,
NIP17_QUERY_TIMEOUT: 30000,
ERROR_LOG_DEBOUNCE_DELAY: 2000,
} as const;
const SCAN_STATUS_MESSAGES = {
NIP4_STARTING: 'Starting NIP-4 scan...',
NIP17_STARTING: 'Starting NIP-17 scan...',
} as const;
const createErrorLogger = (name: string) => {
let count = 0;
let timeout: NodeJS.Timeout | null = null;
return (_error: Error) => {
count++;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
if (count > 0) {
console.error(`[DM] ${name} processing complete with ${count} errors`);
count = 0;
}
}, DM_CONSTANTS.ERROR_LOG_DEBOUNCE_DELAY);
};
};
const nip17ErrorLogger = createErrorLogger('NIP-17');
export interface DMConfig {
enabled?: boolean;
protocolMode?: ProtocolMode;
}
interface DMProviderProps {
children: ReactNode;
config?: DMConfig;
}
// ============================================================================
// Message Sending Types and Helpers (Internal)
// ============================================================================
/**
* Prepare message content with file URLs appended
*/
function prepareMessageContent(content: string, attachments: FileAttachment[] = []): string {
if (attachments.length === 0) return content;
const fileUrls = attachments.map(file => file.url).join('\n');
return content ? `${content}\n\n${fileUrls}` : fileUrls;
}
/**
* Create imeta tags for file attachments (NIP-92)
*/
function createImetaTags(attachments: FileAttachment[] = []): string[][] {
return attachments.map(file => {
const imetaTag = ['imeta'];
imetaTag.push(`url ${file.url}`);
if (file.mimeType) imetaTag.push(`m ${file.mimeType}`);
if (file.size) imetaTag.push(`size ${file.size}`);
if (file.name) imetaTag.push(`alt ${file.name}`);
// Add hash tags from file.tags
file.tags.forEach(tag => {
if (tag[0] === 'x') imetaTag.push(`x ${tag[1]}`);
if (tag[0] === 'ox') imetaTag.push(`ox ${tag[1]}`);
});
return imetaTag;
});
}
// ============================================================================
// DMProvider Component
// ============================================================================
export function DMProvider({ children, config }: DMProviderProps) {
const { enabled = false, protocolMode = PROTOCOL_MODE.NIP17_ONLY } = config || {};
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: createEvent } = useNostrPublish();
const { toast } = useToast();
const { config: appConfig } = useAppContext();
const userPubkey = useMemo(() => user?.pubkey, [user?.pubkey]);
// Track relay metadata to detect changes
const previousRelayMetadata = useRef(appConfig.relayMetadata);
// Determine if NIP-17 is enabled based on protocol mode
const enableNIP17 = protocolMode !== PROTOCOL_MODE.NIP04_ONLY;
const [messages, setMessages] = useState<MessagesState>(new Map());
const [lastSync, setLastSync] = useState<LastSyncData>({
nip4: null,
nip17: null
});
const [isLoading, setIsLoading] = useState(false);
const [loadingPhase, setLoadingPhase] = useState<LoadingPhase>(LOADING_PHASES.IDLE);
const [subscriptions, setSubscriptions] = useState<SubscriptionStatus>({
isNIP4Connected: false,
isNIP17Connected: false
});
const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false);
const [shouldSaveImmediately, setShouldSaveImmediately] = useState(false);
const [scanProgress, setScanProgress] = useState<ScanProgressState>({
nip4: null,
nip17: null
});
const nip4SubscriptionRef = useRef<{ close: () => void } | null>(null);
const nip17SubscriptionRef = useRef<{ close: () => void } | null>(null);
const debouncedWriteRef = useRef<NodeJS.Timeout | null>(null);
// ============================================================================
// Internal Message Sending Mutations
// ============================================================================
// Send NIP-04 Message (internal)
const sendNIP4Message = useMutation<NostrEvent, Error, {
recipientPubkey: string;
content: string;
attachments?: FileAttachment[];
}>({
mutationFn: async ({ recipientPubkey, content, attachments = [] }) => {
if (!user) {
throw new Error('User is not logged in');
}
if (!user.signer.nip04) {
throw new Error('NIP-04 encryption not available');
}
// Prepare content with file URLs
const messageContent = prepareMessageContent(content, attachments);
// Encrypt the content
const encryptedContent = await user.signer.nip04.encrypt(recipientPubkey, messageContent);
// Build tags with imeta tags for attachments
const tags: string[][] = [
['p', recipientPubkey],
...createImetaTags(attachments)
];
// Create and publish the event
return await createEvent({
kind: 4,
content: encryptedContent,
tags,
});
},
onError: (error) => {
console.error('[DM] Failed to send NIP-04 message:', error);
toast({
title: 'Failed to send message',
description: error.message,
variant: 'destructive',
});
},
});
// Send NIP-17 Message (internal)
const sendNIP17Message = useMutation<NostrEvent, Error, {
recipientPubkey: string;
content: string;
attachments?: FileAttachment[];
}>({
mutationFn: async ({ recipientPubkey, content, attachments = [] }) => {
if (!user) {
throw new Error('User is not logged in');
}
if (!user.signer.nip44) {
throw new Error('NIP-44 encryption not available');
}
// Step 1: Create the inner Kind 14 Private Direct Message
const now = Math.floor(Date.now() / 1000);
// Generate randomized timestamps for gift wraps (NIP-59 metadata privacy)
// Randomize within ±2 days in the PAST only (relays reject future timestamps > +30min)
const randomizeTimestamp = (baseTime: number) => {
const twoDaysInSeconds = 2 * 24 * 60 * 60;
// Random offset between -2 days and 0 (never future)
const randomOffset = -Math.floor(Math.random() * twoDaysInSeconds);
return baseTime + randomOffset;
};
// Prepare content with file URLs
const messageContent = prepareMessageContent(content, attachments);
// Build tags with imeta tags for attachments
const tags: string[][] = [
['p', recipientPubkey],
...createImetaTags(attachments)
];
// Use kind 15 for messages with file attachments, kind 14 for text-only
const messageKind = (attachments && attachments.length > 0) ? 15 : 14;
const privateMessage: Omit<NostrEvent, 'id' | 'sig'> = {
kind: messageKind,
pubkey: user.pubkey,
created_at: now,
tags,
content: messageContent,
};
// Step 2: Create TWO Kind 13 Seal events (one for recipient, one for myself)
const recipientSeal: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 13,
pubkey: user.pubkey,
created_at: now,
tags: [],
content: await user.signer.nip44.encrypt(recipientPubkey, JSON.stringify(privateMessage)),
};
const senderSeal: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 13,
pubkey: user.pubkey,
created_at: now,
tags: [],
content: await user.signer.nip44.encrypt(user.pubkey, JSON.stringify(privateMessage)),
};
// Step 3: Create TWO Kind 1059 Gift Wrap events
// Per NIP-17/NIP-59: Gift wraps MUST be signed with random, ephemeral keys
// to hide the sender's identity and provide - some - metadata privacy
// Generate random secret keys for each gift wrap
const recipientRandomKey = generateSecretKey();
const senderRandomKey = generateSecretKey();
// Create signers with the random keys
const recipientRandomSigner = new NSecSigner(recipientRandomKey);
const senderRandomSigner = new NSecSigner(senderRandomKey);
// Encrypt the seals using the RANDOM signers (so recipient can decrypt with the random pubkey)
// The recipient will decrypt using the gift wrap's pubkey (the random ephemeral key)
const recipientGiftWrapContent = await recipientRandomSigner.nip44!.encrypt(recipientPubkey, JSON.stringify(recipientSeal));
const senderGiftWrapContent = await senderRandomSigner.nip44!.encrypt(user.pubkey, JSON.stringify(senderSeal));
// Sign both gift wraps with random keys and randomized timestamps
// Random keys hide the sender's identity; encryption to recipient allows decryption
const [recipientGiftWrap, senderGiftWrap] = await Promise.all([
recipientRandomSigner.signEvent({
kind: 1059,
created_at: randomizeTimestamp(now), // Randomized to hide real send time
tags: [['p', recipientPubkey]],
content: recipientGiftWrapContent,
}),
senderRandomSigner.signEvent({
kind: 1059,
created_at: randomizeTimestamp(now), // Randomized to hide real send time
tags: [['p', user.pubkey]],
content: senderGiftWrapContent,
}),
]);
// Publish both to relays
try {
const results = await Promise.allSettled([
nostr.event(recipientGiftWrap),
nostr.event(senderGiftWrap),
]);
// Check for failures and log detailed errors
const recipientResult = results[0];
const senderResult = results[1];
if (recipientResult.status === 'rejected') {
console.error('[DM] Failed to publish recipient gift wrap');
console.error('[DM] Recipient gift wrap event:', recipientGiftWrap);
// Try to extract detailed errors from AggregateError
const error = recipientResult.reason;
if (error && typeof error === 'object' && 'errors' in error) {
console.error('[DM] Recipient individual relay errors:', error.errors);
} else {
console.error('[DM] Recipient error:', error);
}
}
if (senderResult.status === 'rejected') {
console.error('[DM] Failed to publish sender gift wrap');
console.error('[DM] Sender gift wrap event:', senderGiftWrap);
// Try to extract detailed errors from AggregateError
const error = senderResult.reason;
if (error && typeof error === 'object' && 'errors' in error) {
console.error('[DM] Sender individual relay errors:', error.errors);
} else {
console.error('[DM] Sender error:', error);
}
}
// If both failed, throw error
if (recipientResult.status === 'rejected' && senderResult.status === 'rejected') {
throw new Error(`Both gift wraps rejected. Recipient: ${recipientResult.reason}, Sender: ${senderResult.reason}`);
}
} catch (publishError) {
console.error('[DM] Publish error:', publishError);
throw publishError;
}
return recipientGiftWrap;
},
onError: (error) => {
console.error('[DM] Failed to send NIP-17 message:', error);
toast({
title: 'Failed to send message',
description: error.message,
variant: 'destructive',
});
},
});
// ============================================================================
// Message Loading and Processing
// ============================================================================
// Load past NIP-4 messages
const loadPastNIP4Messages = useCallback(async (sinceTimestamp?: number) => {
if (!user?.pubkey) return;
let allMessages: NostrEvent[] = [];
let processedMessages = 0;
let currentSince = sinceTimestamp || 0;
setScanProgress(prev => ({ ...prev, nip4: { current: 0, status: SCAN_STATUS_MESSAGES.NIP4_STARTING } }));
while (processedMessages < DM_CONSTANTS.SCAN_TOTAL_LIMIT) {
const batchLimit = Math.min(DM_CONSTANTS.SCAN_BATCH_SIZE, DM_CONSTANTS.SCAN_TOTAL_LIMIT - processedMessages);
const filters = [
{ kinds: [4], '#p': [user.pubkey], limit: batchLimit, since: currentSince },
{ kinds: [4], authors: [user.pubkey], limit: batchLimit, since: currentSince }
];
try {
const batchDMs = await nostr.query(filters, { signal: AbortSignal.timeout(DM_CONSTANTS.NIP4_QUERY_TIMEOUT) });
const validBatchDMs = batchDMs.filter(validateDMEvent);
if (validBatchDMs.length === 0) break;
allMessages = [...allMessages, ...validBatchDMs];
processedMessages += validBatchDMs.length;
setScanProgress(prev => ({
...prev,
nip4: {
current: allMessages.length,
status: `Batch ${Math.floor(processedMessages / DM_CONSTANTS.SCAN_BATCH_SIZE) + 1} complete: ${validBatchDMs.length} messages`
}
}));
const oldestToMe = validBatchDMs.filter(m => m.pubkey !== user.pubkey).length > 0
? Math.min(...validBatchDMs.filter(m => m.pubkey !== user.pubkey).map(m => m.created_at))
: Infinity;
const oldestFromMe = validBatchDMs.filter(m => m.pubkey === user.pubkey).length > 0
? Math.min(...validBatchDMs.filter(m => m.pubkey === user.pubkey).map(m => m.created_at))
: Infinity;
const oldestInBatch = Math.min(oldestToMe, oldestFromMe);
if (oldestInBatch !== Infinity) {
currentSince = oldestInBatch;
}
if (validBatchDMs.length < batchLimit * 2) break;
} catch (error) {
console.error('[DM] NIP-4 Error in batch query:', error);
break;
}
}
setScanProgress(prev => ({ ...prev, nip4: null }));
return allMessages;
}, [user, nostr]);
// Load past NIP-17 messages
const loadPastNIP17Messages = useCallback(async (sinceTimestamp?: number) => {
if (!user?.pubkey) return;
let allNIP17Events: NostrEvent[] = [];
let processedMessages = 0;
// Adjust since timestamp to account for NIP-17 timestamp fuzzing (±2 days)
// We need to query from (lastSync - 2 days) to catch messages with randomized past timestamps
// This may fetch duplicates, but they're filtered by message ID in addMessageToState
const TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60;
let currentSince = sinceTimestamp ? sinceTimestamp - TWO_DAYS_IN_SECONDS : 0;
setScanProgress(prev => ({ ...prev, nip17: { current: 0, status: SCAN_STATUS_MESSAGES.NIP17_STARTING } }));
while (processedMessages < DM_CONSTANTS.SCAN_TOTAL_LIMIT) {
const batchLimit = Math.min(DM_CONSTANTS.SCAN_BATCH_SIZE, DM_CONSTANTS.SCAN_TOTAL_LIMIT - processedMessages);
const filters = [
{ kinds: [1059], '#p': [user.pubkey], limit: batchLimit, since: currentSince }
];
try {
const batchEvents = await nostr.query(filters, { signal: AbortSignal.timeout(DM_CONSTANTS.NIP17_QUERY_TIMEOUT) });
if (batchEvents.length === 0) break;
allNIP17Events = [...allNIP17Events, ...batchEvents];
processedMessages += batchEvents.length;
setScanProgress(prev => ({
...prev,
nip17: {
current: allNIP17Events.length,
status: `Batch ${Math.floor(processedMessages / DM_CONSTANTS.SCAN_BATCH_SIZE) + 1} complete: ${batchEvents.length} messages`
}
}));
if (batchEvents.length > 0) {
const oldestInBatch = Math.min(...batchEvents.map(m => m.created_at));
currentSince = oldestInBatch;
}
if (batchEvents.length < batchLimit) break;
} catch (error) {
console.error('[DM] NIP-17 Error in batch query:', error);
break;
}
}
setScanProgress(prev => ({ ...prev, nip17: null }));
return allNIP17Events;
}, [user, nostr]);
// Query relays for messages
const queryRelaysForMessagesSince = useCallback(async (protocol: MessageProtocol, sinceTimestamp?: number): Promise<MessageProcessingResult> => {
if (protocol === MESSAGE_PROTOCOL.NIP17 && !enableNIP17) {
return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 };
}
if (!userPubkey) {
return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 };
}
if (protocol === MESSAGE_PROTOCOL.NIP04) {
const messages = await loadPastNIP4Messages(sinceTimestamp);
if (messages && messages.length > 0) {
const newState = new Map();
for (const message of messages) {
const isFromUser = message.pubkey === user?.pubkey;
const recipientPTag = message.tags?.find(([name]) => name === 'p')?.[1];
const otherPubkey = isFromUser ? recipientPTag : message.pubkey;
if (!otherPubkey || otherPubkey === user?.pubkey) continue;
const { decryptedContent, error } = await decryptNIP4Message(message, otherPubkey);
const decryptedMessage: DecryptedMessage = {
...message,
content: message.content,
decryptedContent: decryptedContent,
error: error,
};
const messageAge = Date.now() - (message.created_at * 1000);
if (messageAge < 5000) {
decryptedMessage.clientFirstSeen = Date.now();
}
if (!newState.has(otherPubkey)) {
newState.set(otherPubkey, createEmptyParticipant());
}
const participant = newState.get(otherPubkey)!;
participant.messages.push(decryptedMessage);
participant.hasNIP4 = true;
}
newState.forEach(participant => {
sortAndUpdateParticipantState(participant);
});
mergeMessagesIntoState(newState);
const currentTime = Math.floor(Date.now() / 1000);
setLastSync(prev => ({ ...prev, nip4: currentTime }));
const newestMessage = messages.reduce((newest, msg) =>
msg.created_at > newest.created_at ? msg : newest
);
return { lastMessageTimestamp: newestMessage.created_at, messageCount: messages.length };
} else {
// No new messages, but we still successfully queried relays - update lastSync
const currentTime = Math.floor(Date.now() / 1000);
setLastSync(prev => ({ ...prev, nip4: currentTime }));
return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 };
}
} else if (protocol === MESSAGE_PROTOCOL.NIP17) {
const messages = await loadPastNIP17Messages(sinceTimestamp);
if (messages && messages.length > 0) {
const newState = new Map();
for (const giftWrap of messages) {
try {
const { processedMessage, conversationPartner, sealEvent } = await processNIP17GiftWrap(giftWrap);
// Skip messages with decryption errors
if (processedMessage.error) {
continue;
}
// Store the seal (kind 13) as-is + add decryptedEvent for inner message access
const messageWithAnimation: DecryptedMessage = {
...sealEvent, // Seal fields (kind 13, seal pubkey, encrypted content, etc.)
created_at: processedMessage.created_at, // Use real timestamp from inner message
decryptedEvent: {
...processedMessage,
content: processedMessage.decryptedContent,
} as NostrEvent,
decryptedContent: processedMessage.decryptedContent,
originalGiftWrapId: giftWrap.id, // Store gift wrap ID for deduplication
};
// Use real message timestamp for recency check
const messageAge = Date.now() - (processedMessage.created_at * 1000);
if (messageAge < 5000) {
messageWithAnimation.clientFirstSeen = Date.now();
}
if (!newState.has(conversationPartner)) {
newState.set(conversationPartner, createEmptyParticipant());
}
newState.get(conversationPartner)!.messages.push(messageWithAnimation);
newState.get(conversationPartner)!.hasNIP17 = true;
} catch (error) {
console.error('[DM] Error processing gift wrap from relay:', error);
}
}
newState.forEach(participant => {
sortAndUpdateParticipantState(participant);
});
mergeMessagesIntoState(newState);
const currentTime = Math.floor(Date.now() / 1000);
setLastSync(prev => ({ ...prev, nip17: currentTime }));
const newestMessage = messages.reduce((newest, msg) =>
msg.created_at > newest.created_at ? msg : newest
);
return { lastMessageTimestamp: newestMessage.created_at, messageCount: messages.length };
} else {
// No new messages, but we still successfully queried relays - update lastSync
const currentTime = Math.floor(Date.now() / 1000);
setLastSync(prev => ({ ...prev, nip17: currentTime }));
return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 };
}
}
return { lastMessageTimestamp: sinceTimestamp, messageCount: 0 };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableNIP17, userPubkey, loadPastNIP4Messages, loadPastNIP17Messages, user]);
// Decrypt NIP-4 message
const decryptNIP4Message = useCallback(async (event: NostrEvent, otherPubkey: string): Promise<DecryptionResult> => {
try {
if (user?.signer?.nip04) {
const decryptedContent = await user.signer.nip04.decrypt(otherPubkey, event.content);
return { decryptedContent };
} else {
return {
decryptedContent: '',
error: 'No NIP-04 decryption available'
};
}
} catch (error) {
console.error(`[DM] Failed to decrypt NIP-4 message ${event.id}:`, error);
return {
decryptedContent: '',
error: 'Decryption failed'
};
}
}, [user]);
// Create empty participant
const createEmptyParticipant = useCallback(() => ({
messages: [],
lastActivity: 0,
lastMessage: null,
hasNIP4: false,
hasNIP17: false,
}), []);
// Sort and update participant state
const sortAndUpdateParticipantState = useCallback((participant: { messages: DecryptedMessage[]; lastActivity: number; lastMessage: DecryptedMessage | null }) => {
participant.messages.sort((a, b) => a.created_at - b.created_at);
if (participant.messages.length > 0) {
participant.lastActivity = participant.messages[participant.messages.length - 1].created_at;
participant.lastMessage = participant.messages[participant.messages.length - 1];
}
}, []);
// Merge messages into state
const mergeMessagesIntoState = useCallback((newState: MessagesState) => {
setMessages(prev => {
const finalMap = new Map(prev);
newState.forEach((value, key) => {
const existing = finalMap.get(key);
if (existing) {
// For NIP-17 messages with originalGiftWrapId, dedupe by gift wrap ID
// For NIP-04 and cached NIP-17 messages, dedupe by message ID
const existingMessageIds = new Set(
existing.messages.map(msg => msg.originalGiftWrapId || msg.id)
);
const newMessages = value.messages.filter(msg =>
!existingMessageIds.has(msg.originalGiftWrapId || msg.id)
);
const mergedMessages = [...existing.messages, ...newMessages];
mergedMessages.sort((a, b) => a.created_at - b.created_at);
// Recalculate lastActivity and lastMessage after merging
const lastMessage = mergedMessages.length > 0 ? mergedMessages[mergedMessages.length - 1] : null;
const lastActivity = lastMessage ? lastMessage.created_at : existing.lastActivity;
finalMap.set(key, {
...existing,
messages: mergedMessages,
lastActivity,
lastMessage,
hasNIP4: existing.hasNIP4 || value.hasNIP4,
hasNIP17: existing.hasNIP17 || value.hasNIP17,
});
} else {
finalMap.set(key, value);
}
});
return finalMap;
});
}, []);
// Add message to state
const addMessageToState = useCallback((message: DecryptedMessage, conversationPartner: string, protocol: MessageProtocol) => {
setMessages(prev => {
const newMap = new Map(prev);
const existing = newMap.get(conversationPartner);
if (existing) {
// For NIP-17 messages with originalGiftWrapId, dedupe by gift wrap ID
// For NIP-04 and cached NIP-17 messages, dedupe by message ID
const messageId = message.originalGiftWrapId || message.id;
if (existing.messages.some(msg => (msg.originalGiftWrapId || msg.id) === messageId)) {
return prev;
}
const optimisticIndex = existing.messages.findIndex(msg =>
msg.isSending &&
msg.pubkey === message.pubkey &&
msg.decryptedContent === message.decryptedContent &&
Math.abs(msg.created_at - message.created_at) <= 30
);
let updatedMessages: DecryptedMessage[];
if (optimisticIndex !== -1) {
const existingMessage = existing.messages[optimisticIndex];
updatedMessages = [...existing.messages];
updatedMessages[optimisticIndex] = {
...message,
created_at: existingMessage.created_at,
clientFirstSeen: existingMessage.clientFirstSeen
};
} else {
updatedMessages = [...existing.messages, message];
}
updatedMessages.sort((a, b) => a.created_at - b.created_at);
const actualLastMessage = updatedMessages[updatedMessages.length - 1];
newMap.set(conversationPartner, {
...existing,
messages: updatedMessages,
lastActivity: actualLastMessage.created_at,
lastMessage: actualLastMessage,
hasNIP4: protocol === MESSAGE_PROTOCOL.NIP04 ? true : existing.hasNIP4,
hasNIP17: protocol === MESSAGE_PROTOCOL.NIP17 ? true : existing.hasNIP17,
});
} else {
const newConversation = {
messages: [message],
lastActivity: message.created_at,
lastMessage: message,
hasNIP4: protocol === MESSAGE_PROTOCOL.NIP04,
hasNIP17: protocol === MESSAGE_PROTOCOL.NIP17,
};
newMap.set(conversationPartner, newConversation);
}
return newMap;
});
}, []);
// Process incoming NIP-4 message
const processIncomingNIP4Message = useCallback(async (event: NostrEvent) => {
if (!user?.pubkey) return;
if (!validateDMEvent(event)) return;
const isFromUser = event.pubkey === user.pubkey;
const recipientPTag = event.tags?.find(([name]) => name === 'p')?.[1];
const otherPubkey = isFromUser ? recipientPTag : event.pubkey;
if (!otherPubkey || otherPubkey === user.pubkey) return;
const { decryptedContent, error } = await decryptNIP4Message(event, otherPubkey);
const decryptedMessage: DecryptedMessage = {
...event,
content: event.content,
decryptedContent: decryptedContent,
error: error,
};
const messageAge = Date.now() - (event.created_at * 1000);
if (messageAge < 5000) {
decryptedMessage.clientFirstSeen = Date.now();
}
addMessageToState(decryptedMessage, otherPubkey, MESSAGE_PROTOCOL.NIP04);
}, [user, decryptNIP4Message, addMessageToState]);
// Process NIP-17 Gift Wrap
const processNIP17GiftWrap = useCallback(async (event: NostrEvent): Promise<NIP17ProcessingResult> => {
if (!user?.signer?.nip44) {
return {
processedMessage: {
...event,
content: '',
decryptedContent: '',
error: 'No NIP-44 decryption available',
},
conversationPartner: event.pubkey,
sealEvent: event, // Return the event itself as fallback
};
}
try {
// Decrypt using the ephemeral sender's pubkey (event.pubkey)
const sealContent = await user.signer.nip44.decrypt(event.pubkey, event.content);
const sealEvent = JSON.parse(sealContent) as NostrEvent;
if (sealEvent.kind !== 13) {
console.log(`[DM] ⚠️ NIP-17 INVALID SEAL - expected kind 13, got ${sealEvent.kind}`, {
giftWrapId: event.id,
sealKind: sealEvent.kind,
});
return {
processedMessage: {
...event,
content: '',
decryptedContent: '',
error: `Invalid Seal format - expected kind 13, got ${sealEvent.kind}`,
},
conversationPartner: event.pubkey,
sealEvent: event, // Return the gift wrap as fallback
};
}
const messageContent = await user.signer.nip44.decrypt(sealEvent.pubkey, sealEvent.content);
const messageEvent = JSON.parse(messageContent) as NostrEvent;
// Accept both kind 14 (text) and kind 15 (files/attachments)
if (messageEvent.kind !== 14 && messageEvent.kind !== 15) {
console.log(`[DM] ⚠️ NIP-17 MESSAGE WITH UNSUPPORTED INNER EVENT KIND:`, {
giftWrapId: event.id,
innerKind: messageEvent.kind,
expectedKinds: [14, 15],
sealPubkey: sealEvent.pubkey,
messageEvent: messageEvent,
});
return {
processedMessage: {
...event,
content: '',
decryptedContent: '',
error: `Invalid message format - expected kind 14 or 15, got ${messageEvent.kind}`,
},
conversationPartner: event.pubkey,
sealEvent, // Return the seal
};
}
let conversationPartner: string;
if (sealEvent.pubkey === user.pubkey) {
const recipient = messageEvent.tags.find(([name]) => name === 'p')?.[1];
if (!recipient || recipient === user.pubkey) {
return {
processedMessage: {
...event,
content: '',
decryptedContent: '',
error: 'Invalid recipient - malformed p tag',
},
conversationPartner: event.pubkey,
sealEvent, // Return the seal
};
} else {
conversationPartner = recipient;
}
} else {
conversationPartner = sealEvent.pubkey;
}
return {
processedMessage: {
...messageEvent,
id: messageEvent.id || `missing-nip17-inner-${messageEvent.created_at}-${messageEvent.pubkey.substring(0, 8)}-${messageEvent.content.substring(0, 16)}`,
decryptedContent: messageEvent.content, // Plaintext from inner message
},
conversationPartner,
sealEvent, // Return the seal (kind 13) for storage
};
} catch (error) {
console.error('[DM] Failed to process NIP-17 gift wrap:', {
giftWrapId: event.id,
error: error instanceof Error ? error.message : String(error),
});
nip17ErrorLogger(error as Error);
return {
processedMessage: {
...event,
content: '',
decryptedContent: '',
error: error instanceof Error ? error.message : 'Failed to decrypt or parse NIP-17 message',
},
conversationPartner: event.pubkey,
sealEvent: event, // Return the gift wrap as fallback
};
}
}, [user]);
// Process incoming NIP-17 message
const processIncomingNIP17Message = useCallback(async (event: NostrEvent) => {
if (!user?.pubkey) return;
if (event.kind !== 1059) return;
try {
const { processedMessage, conversationPartner, sealEvent } = await processNIP17GiftWrap(event);
// Check if decryption failed
if (processedMessage.error) {
console.error('[DM] NIP-17 message decryption failed:', {
giftWrapId: event.id,
error: processedMessage.error,
});
nip17ErrorLogger(new Error(processedMessage.error));
return;
}
// Store the seal (kind 13) as-is + add decryptedEvent for inner message access
const messageWithAnimation: DecryptedMessage = {
...sealEvent, // Seal fields (kind 13, seal pubkey, encrypted content, etc.)
created_at: processedMessage.created_at, // Use real timestamp from inner message
decryptedEvent: {
...processedMessage,
content: processedMessage.decryptedContent,
} as NostrEvent,
decryptedContent: processedMessage.decryptedContent,
originalGiftWrapId: event.id, // Store gift wrap ID for deduplication
};
// Use real message timestamp for recency check
const messageAge = Date.now() - (processedMessage.created_at * 1000);
if (messageAge < 5000) {
messageWithAnimation.clientFirstSeen = Date.now();
}
addMessageToState(messageWithAnimation, conversationPartner, MESSAGE_PROTOCOL.NIP17);
} catch (error) {
console.error('[DM] Exception in processIncomingNIP17Message:', {
giftWrapId: event.id,
error: error instanceof Error ? error.message : String(error),
});
nip17ErrorLogger(error as Error);
}
}, [user, processNIP17GiftWrap, addMessageToState]);
// Start NIP-4 subscription
const startNIP4Subscription = useCallback(async (sinceTimestamp?: number) => {
if (!user?.pubkey || !nostr) return;
if (nip4SubscriptionRef.current) {
nip4SubscriptionRef.current.close();
}
try {
let subscriptionSince = sinceTimestamp || Math.floor(Date.now() / 1000);
if (!sinceTimestamp && lastSync.nip4) {
subscriptionSince = lastSync.nip4 - DM_CONSTANTS.SUBSCRIPTION_OVERLAP_SECONDS;
}
const filters = [
{ kinds: [4], '#p': [user.pubkey], since: subscriptionSince },
{ kinds: [4], authors: [user.pubkey], since: subscriptionSince }
];
const subscription = nostr.req(filters);
let isActive = true;
(async () => {
try {
for await (const msg of subscription) {
if (!isActive) break;
if (msg[0] === 'EVENT') {
await processIncomingNIP4Message(msg[2]);
}
}
} catch (error) {
if (isActive) {
console.error('[DM] NIP-4 subscription error:', error);
}
}
})();
nip4SubscriptionRef.current = {
close: () => {
isActive = false;
}
};
setSubscriptions(prev => ({ ...prev, isNIP4Connected: true }));
} catch (error) {
console.error('[DM] Failed to start NIP-4 subscription:', error);
setSubscriptions(prev => ({ ...prev, isNIP4Connected: false }));
}
}, [user, nostr, lastSync.nip4, processIncomingNIP4Message]);
// Start NIP-17 subscription
const startNIP17Subscription = useCallback(async (sinceTimestamp?: number) => {
if (!user?.pubkey || !nostr || !enableNIP17) return;
if (nip17SubscriptionRef.current) {
nip17SubscriptionRef.current.close();
}
try {
let subscriptionSince = sinceTimestamp || Math.floor(Date.now() / 1000);
if (!sinceTimestamp && lastSync.nip17) {
subscriptionSince = lastSync.nip17 - DM_CONSTANTS.SUBSCRIPTION_OVERLAP_SECONDS;
}
// Adjust for NIP-17 timestamp fuzzing (±2 days)
// Subscribe from (lastSync - 2 days) to catch messages with randomized past timestamps
const TWO_DAYS_IN_SECONDS = 2 * 24 * 60 * 60;
subscriptionSince = subscriptionSince - TWO_DAYS_IN_SECONDS;
const filters = [{
kinds: [1059],
'#p': [user.pubkey],
since: subscriptionSince,
}];
const subscription = nostr.req(filters);
let isActive = true;
(async () => {
try {
for await (const msg of subscription) {
if (!isActive) break;
if (msg[0] === 'EVENT') {
await processIncomingNIP17Message(msg[2]);
}
}
} catch (error) {
if (isActive) {
console.error('[DM] NIP-17 subscription error:', error);
}
}
})();
nip17SubscriptionRef.current = {
close: () => {
isActive = false;
}
};
setSubscriptions(prev => ({ ...prev, isNIP17Connected: true }));
} catch (error) {
console.error('[DM] Failed to start NIP-17 subscription:', error);
setSubscriptions(prev => ({ ...prev, isNIP17Connected: false }));
}
}, [user, nostr, lastSync.nip17, enableNIP17, processIncomingNIP17Message]);
// Load all cached messages at once (both protocols)
const loadAllCachedMessages = useCallback(async (): Promise<{ nip4Since?: number; nip17Since?: number }> => {
if (!userPubkey) return {};
try {
const { readMessagesFromDB } = await import('@/lib/dmMessageStore');
const cachedStore = await readMessagesFromDB(userPubkey);
if (!cachedStore || Object.keys(cachedStore.participants).length === 0) {
return {};
}
const filteredParticipants = enableNIP17
? cachedStore.participants
: Object.fromEntries(
Object.entries(cachedStore.participants).filter(([_, participant]) => !participant.hasNIP17)
);
const newState = new Map();
// Decrypt each message individually (they're stored in original encrypted form)
for (const [participantPubkey, participant] of Object.entries(filteredParticipants)) {
const processedMessages = await Promise.all(participant.messages.map(async (msg) => {
// Decrypt based on message kind
let decryptedContent: string | undefined;
let error: string | undefined;
if (msg.kind === 4) {
// NIP-04 message
const otherPubkey = msg.pubkey === user?.pubkey
? msg.tags.find(([name]) => name === 'p')?.[1]
: msg.pubkey;
if (otherPubkey && user?.signer?.nip04) {
try {
decryptedContent = await user.signer.nip04.decrypt(otherPubkey, msg.content);
} catch {
error = 'Decryption failed';
}
}
} else if (msg.kind === 13) {
// NIP-17 seal - decrypt to get the inner kind 14/15 event
if (user?.signer?.nip44) {
try {
const sealContent = await user.signer.nip44.decrypt(msg.pubkey, msg.content);
const decryptedEvent = JSON.parse(sealContent) as NostrEvent;
// Keep seal structure but add decryptedEvent for access to inner fields
return {
...msg,
decryptedEvent, // Full inner event (kind 14/15)
decryptedContent: decryptedEvent.content, // Plaintext message
} as NostrEvent & { decryptedEvent?: NostrEvent; decryptedContent?: string; error?: string };
} catch {
error = 'Decryption failed';
}
}
}
return {
...msg,
id: msg.id || `missing-${msg.kind}-${msg.created_at}-${msg.pubkey.substring(0, 8)}-${msg.content?.substring(0, 16) || 'nocontent'}`,
decryptedContent,
error,
} as NostrEvent & { decryptedContent?: string; error?: string };
}));
newState.set(participantPubkey, {
messages: processedMessages,
lastActivity: participant.lastActivity,
lastMessage: processedMessages.length > 0 ? processedMessages[processedMessages.length - 1] : null,
hasNIP4: participant.hasNIP4,
hasNIP17: participant.hasNIP17,
});
}
setMessages(newState);
if (cachedStore.lastSync) {
setLastSync(cachedStore.lastSync);
}
return {
nip4Since: cachedStore.lastSync?.nip4 || undefined,
nip17Since: cachedStore.lastSync?.nip17 || undefined,
};
} catch (error) {
console.error('[DM] Error loading cached messages:', error);
return {};
}
}, [userPubkey, enableNIP17, user]);
// Start message loading
const startMessageLoading = useCallback(async () => {
if (isLoading) return;
setIsLoading(true);
setLoadingPhase(LOADING_PHASES.CACHE);
try {
// ===== PHASE 1: Load cache and show immediately =====
const { nip4Since, nip17Since } = await loadAllCachedMessages();
// Mark as completed BEFORE releasing isLoading to prevent re-trigger
setHasInitialLoadCompleted(true);
// Show cached messages immediately! Don't wait for relays
setLoadingPhase(LOADING_PHASES.READY);
setIsLoading(false);
// ===== PHASE 2: Query relays in background (non-blocking, parallel) =====
setLoadingPhase(LOADING_PHASES.RELAYS);
// Run NIP-04 and NIP-17 queries IN PARALLEL
const [nip4Result, nip17Result] = await Promise.all([
queryRelaysForMessagesSince(MESSAGE_PROTOCOL.NIP04, nip4Since),
enableNIP17 ? queryRelaysForMessagesSince(MESSAGE_PROTOCOL.NIP17, nip17Since) : Promise.resolve({ lastMessageTimestamp: undefined, messageCount: 0 })
]);
const totalNewMessages = nip4Result.messageCount + (nip17Result?.messageCount || 0);
if (totalNewMessages > 0) {
setShouldSaveImmediately(true);
}
// ===== PHASE 3: Setup subscriptions =====
setLoadingPhase(LOADING_PHASES.SUBSCRIPTIONS);
await Promise.all([
startNIP4Subscription(nip4Result.lastMessageTimestamp),
enableNIP17 ? startNIP17Subscription(nip17Result?.lastMessageTimestamp) : Promise.resolve()
]);
setLoadingPhase(LOADING_PHASES.READY);
} catch (error) {
console.error('[DM] Error in message loading:', error);
setHasInitialLoadCompleted(true);
setLoadingPhase(LOADING_PHASES.READY);
setIsLoading(false);
}
}, [loadAllCachedMessages, queryRelaysForMessagesSince, startNIP4Subscription, startNIP17Subscription, enableNIP17, isLoading]);
// Clear cache and refetch from relays
const clearCacheAndRefetch = useCallback(async () => {
if (!enabled || !userPubkey) return;
try {
// Close existing subscriptions
if (nip4SubscriptionRef.current) {
nip4SubscriptionRef.current.close();
nip4SubscriptionRef.current = null;
}
if (nip17SubscriptionRef.current) {
nip17SubscriptionRef.current.close();
nip17SubscriptionRef.current = null;
}
// Clear IndexedDB cache
const { deleteMessagesFromDB } = await import('@/lib/dmMessageStore');
await deleteMessagesFromDB(userPubkey);
// Reset all state
setMessages(new Map());
setLastSync({ nip4: null, nip17: null });
setSubscriptions({ isNIP4Connected: false, isNIP17Connected: false });
setScanProgress({ nip4: null, nip17: null });
setLoadingPhase(LOADING_PHASES.IDLE);
// Trigger reload by setting hasInitialLoadCompleted to false
setHasInitialLoadCompleted(false);
} catch (error) {
console.error('[DM] Error clearing cache:', error);
throw error;
}
}, [enabled, userPubkey]);
// Main effect to load messages
useEffect(() => {
if (!enabled || !userPubkey || hasInitialLoadCompleted || isLoading) return;
startMessageLoading();
}, [enabled, userPubkey, hasInitialLoadCompleted, isLoading, startMessageLoading]);
// Cleanup effect
useEffect(() => {
if (!enabled) return;
return () => {
if (nip4SubscriptionRef.current) {
nip4SubscriptionRef.current.close();
nip4SubscriptionRef.current = null;
}
if (nip17SubscriptionRef.current) {
nip17SubscriptionRef.current.close();
nip17SubscriptionRef.current = null;
}
};
}, [enabled, userPubkey]);
// Cleanup subscriptions
useEffect(() => {
if (!enabled) return;
return () => {
if (nip4SubscriptionRef.current) {
nip4SubscriptionRef.current.close();
}
if (nip17SubscriptionRef.current) {
nip17SubscriptionRef.current.close();
}
if (debouncedWriteRef.current) {
clearTimeout(debouncedWriteRef.current);
}
setSubscriptions({ isNIP4Connected: false, isNIP17Connected: false });
};
}, [enabled]);
// Detect relay changes and reload messages
useEffect(() => {
const relayChanged = JSON.stringify(previousRelayMetadata.current) !== JSON.stringify(appConfig.relayMetadata);
previousRelayMetadata.current = appConfig.relayMetadata;
if (relayChanged && enabled && userPubkey && hasInitialLoadCompleted) {
clearCacheAndRefetch();
}
}, [enabled, userPubkey, appConfig.relayMetadata, hasInitialLoadCompleted, clearCacheAndRefetch]);
// Detect hard refresh shortcut (Ctrl+Shift+R / Cmd+Shift+R) to clear cache
useEffect(() => {
if (!enabled || !userPubkey) return;
const handleHardRefresh = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'R' || e.key === 'r')) {
try {
sessionStorage.setItem('dm-clear-cache-on-load', 'true');
} catch (error) {
console.warn('[DM] SessionStorage unavailable, cache won\'t clear on hard refresh:', error);
}
}
};
window.addEventListener('keydown', handleHardRefresh);
return () => window.removeEventListener('keydown', handleHardRefresh);
}, [enabled, userPubkey]);
// Clear cache after hard refresh
useEffect(() => {
if (!enabled || !userPubkey) return;
try {
const shouldClearCache = sessionStorage.getItem('dm-clear-cache-on-load');
if (shouldClearCache) {
sessionStorage.removeItem('dm-clear-cache-on-load');
clearCacheAndRefetch();
}
} catch (error) {
console.warn('[DM] Could not check sessionStorage for cache clear flag:', error);
}
}, [enabled, userPubkey, clearCacheAndRefetch]);
// Conversations summary
const conversations = useMemo(() => {
const conversationsList: ConversationSummary[] = [];
messages.forEach((participant, participantPubkey) => {
if (!participant.messages.length) return;
const userHasSentMessage = participant.messages.some(msg => msg.pubkey === user?.pubkey);
const isKnown = userHasSentMessage;
const isRequest = !userHasSentMessage;
const lastMessage = participant.messages[participant.messages.length - 1];
const isFromUser = lastMessage.pubkey === user?.pubkey;
conversationsList.push({
id: participantPubkey,
pubkey: participantPubkey,
lastMessage: participant.lastMessage,
lastActivity: participant.lastActivity,
hasNIP4Messages: participant.hasNIP4,
hasNIP17Messages: participant.hasNIP17,
isKnown: isKnown,
isRequest: isRequest,
lastMessageFromUser: isFromUser,
});
});
return conversationsList.sort((a, b) => b.lastActivity - a.lastActivity);
}, [messages, user?.pubkey]);
// Write to store
const writeAllMessagesToStore = useCallback(async () => {
if (!userPubkey) return;
try {
const { writeMessagesToDB } = await import('@/lib/dmMessageStore');
const messageStore = {
participants: {} as Record<string, {
messages: NostrEvent[];
lastActivity: number;
hasNIP4: boolean;
hasNIP17: boolean;
}>,
lastSync: {
nip4: lastSync.nip4,
nip17: lastSync.nip17,
}
};
messages.forEach((participant, participantPubkey) => {
messageStore.participants[participantPubkey] = {
messages: participant.messages.map(msg => ({
// Store messages in their ORIGINAL ENCRYPTED form
// Just strip the decrypted fields (decryptedContent, decryptedEvent)
// Keep originalGiftWrapId for NIP-17 deduplication on cache load
id: msg.id,
pubkey: msg.pubkey,
content: msg.content, // Encrypted content (NIP-04 or seal)
created_at: msg.created_at,
kind: msg.kind, // 4 for NIP-04, 13 for NIP-17
tags: msg.tags,
sig: msg.sig,
...(msg.originalGiftWrapId && { originalGiftWrapId: msg.originalGiftWrapId }),
} as NostrEvent)),
lastActivity: participant.lastActivity,
hasNIP4: participant.hasNIP4,
hasNIP17: participant.hasNIP17,
};
});
await writeMessagesToDB(userPubkey, messageStore);
const currentTime = Math.floor(Date.now() / 1000);
setLastSync(prev => ({
nip4: prev.nip4 || currentTime,
nip17: prev.nip17 || currentTime
}));
} catch (error) {
console.error('[DM] Error writing messages to IndexedDB:', error);
}
}, [messages, userPubkey, lastSync]);
// Trigger debounced write
const triggerDebouncedWrite = useCallback(() => {
if (debouncedWriteRef.current) {
clearTimeout(debouncedWriteRef.current);
}
debouncedWriteRef.current = setTimeout(() => {
writeAllMessagesToStore();
debouncedWriteRef.current = null;
}, DM_CONSTANTS.DEBOUNCED_WRITE_DELAY);
}, [writeAllMessagesToStore]);
// Watch messages and save
useEffect(() => {
if (!enabled || messages.size === 0) return;
if (shouldSaveImmediately) {
setShouldSaveImmediately(false);
writeAllMessagesToStore();
} else {
triggerDebouncedWrite();
}
}, [enabled, messages, shouldSaveImmediately, writeAllMessagesToStore, triggerDebouncedWrite]);
// Send message
const sendMessage = useCallback(async (params: {
recipientPubkey: string;
content: string;
protocol?: MessageProtocol;
attachments?: FileAttachment[];
}) => {
if (!enabled) return;
const { recipientPubkey, content, protocol = MESSAGE_PROTOCOL.NIP04, attachments } = params;
if (!userPubkey) return;
const optimisticId = `optimistic-${Date.now()}-${Math.random()}`;
const optimisticMessage: DecryptedMessage = {
id: optimisticId,
kind: protocol === MESSAGE_PROTOCOL.NIP04 ? 4 : 14, // Use kind 14 for NIP-17 (the real message kind)
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000), // Real timestamp
tags: [['p', recipientPubkey]],
content: '',
decryptedContent: content,
sig: '',
isSending: true,
clientFirstSeen: Date.now(),
};
addMessageToState(optimisticMessage, recipientPubkey, protocol === MESSAGE_PROTOCOL.NIP04 ? MESSAGE_PROTOCOL.NIP04 : MESSAGE_PROTOCOL.NIP17);
try {
if (protocol === MESSAGE_PROTOCOL.NIP04) {
await sendNIP4Message.mutateAsync({ recipientPubkey, content, attachments });
} else if (protocol === MESSAGE_PROTOCOL.NIP17) {
await sendNIP17Message.mutateAsync({ recipientPubkey, content, attachments });
}
} catch (error) {
console.error(`[DM] Failed to send ${protocol} message:`, error);
}
}, [enabled, userPubkey, addMessageToState, sendNIP4Message, sendNIP17Message]);
const isDoingInitialLoad = isLoading && (loadingPhase === LOADING_PHASES.CACHE || loadingPhase === LOADING_PHASES.RELAYS);
const contextValue: DMContextType = {
messages,
isLoading,
loadingPhase,
isDoingInitialLoad,
lastSync,
conversations,
sendMessage,
protocolMode,
scanProgress,
subscriptions,
clearCacheAndRefetch,
};
return (
<DMContext.Provider value={contextValue}>
{children}
</DMContext.Provider>
);
}