diff --git a/.agents/skills/nostr-direct-messages/SKILL.md b/.agents/skills/nostr-direct-messages/SKILL.md
deleted file mode 100644
index 26f3ef56..00000000
--- a/.agents/skills/nostr-direct-messages/SKILL.md
+++ /dev/null
@@ -1,478 +0,0 @@
----
-name: nostr-direct-messages
-description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
----
-
-# Direct Messaging on Nostr
-
-This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
-
-**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
-
-## Setup Instructions
-
-### 1. Add DMProvider to Your App
-
-First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
-
-```tsx
-// Add these imports at the top of src/App.tsx
-import { DMProvider, type DMConfig } from '@/components/DMProvider';
-import { PROTOCOL_MODE } from '@/lib/dmConstants';
-
-// Add this configuration before your App component
-const dmConfig: DMConfig = {
- // Enable or disable DMs entirely
- enabled: true, // Set to true to enable messaging functionality
-
- // Choose one protocol mode:
- // PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
- // PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
- // PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
- protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
-};
-
-// Then wrap your app components with DMProvider:
-export function App() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-```
-
-### 2. Configure DM Settings
-
-The `DMConfig` object supports the following options:
-
-- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
-- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
- - `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
- - `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
- - `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
-
-**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
-
-## Quick Start
-
-### 1. Send Messages
-
-```tsx
-import { useDMContext } from '@/hooks/useDMContext';
-import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
-
-function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
- const { sendMessage } = useDMContext();
- const [content, setContent] = useState('');
-
- const handleSend = async () => {
- await sendMessage({
- recipientPubkey,
- content,
- protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
- });
- setContent('');
- };
-
- return (
-
- );
-}
-```
-
-### 2. Display Conversations
-
-```tsx
-import { useDMContext } from '@/hooks/useDMContext';
-import { useAuthor } from '@/hooks/useAuthor';
-import { genUserName } from '@/lib/genUserName';
-
-function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
- const { conversations, isLoading } = useDMContext();
-
- if (isLoading) {
- return Loading conversations...
;
- }
-
- return (
-
- {conversations.map((conversation) => (
- onSelectConversation(conversation.pubkey)}
- />
- ))}
-
- );
-}
-
-function ConversationItem({ conversation, onClick }: {
- conversation: ConversationSummary;
- onClick: () => void;
-}) {
- const author = useAuthor(conversation.pubkey);
- const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
- const avatarUrl = author.data?.metadata?.picture;
-
- return (
-
-
-
-
- {displayName.slice(0, 2).toUpperCase()}
-
-
-
{displayName}
-
- {conversation.lastMessage?.decryptedContent || 'No messages yet'}
-
-
-
-
- );
-}
-```
-
-### 3. Display Messages in a Conversation
-
-```tsx
-import { useConversationMessages } from '@/hooks/useConversationMessages';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-
-function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
- const { user } = useCurrentUser();
- const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
-
- return (
-
- {hasMoreMessages && (
-
- Load earlier messages
-
- )}
-
- {messages.map((message) => {
- const isFromMe = message.pubkey === user?.pubkey;
-
- return (
-
-
- {message.error ? (
-
🔒 {message.error}
- ) : (
-
- {message.decryptedContent}
-
- )}
- {message.isSending && (
-
Sending...
- )}
-
-
- );
- })}
-
- );
-}
-```
-
-## Using the Complete Messaging Interface
-
-For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
-
-```tsx
-import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
-
-function MessagesPage() {
- return (
-
-
-
- );
-}
-```
-
-The `DMMessagingInterface` component provides a complete messaging UI with:
-- Conversation list with Active/Requests tabs
-- Message thread view with pagination
-- Compose area with file upload support
-- Real-time message updates
-- Mobile-responsive layout (shows one panel at a time on mobile)
-
-It requires no props and works automatically when wrapped in `DMProvider`.
-
-**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
-
-## Sending Files with Messages
-
-```tsx
-import { useDMContext } from '@/hooks/useDMContext';
-import { useUploadFile } from '@/hooks/useUploadFile';
-import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
-import type { FileAttachment } from '@/contexts/DMContext';
-
-function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
- const { sendMessage } = useDMContext();
- const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
- const [content, setContent] = useState('');
- const [selectedFile, setSelectedFile] = useState(null);
-
- const handleSend = async () => {
- let attachments: FileAttachment[] | undefined;
-
- // Upload file if one is selected
- if (selectedFile) {
- const tags = await uploadFile(selectedFile);
-
- attachments = [{
- url: tags[0][1], // URL from first tag
- mimeType: selectedFile.type,
- size: selectedFile.size,
- name: selectedFile.name,
- tags: tags
- }];
- }
-
- await sendMessage({
- recipientPubkey,
- content,
- protocol: MESSAGE_PROTOCOL.NIP17,
- attachments,
- });
-
- setContent('');
- setSelectedFile(null);
- };
-
- return (
- { e.preventDefault(); handleSend(); }}>
- setContent(e.target.value)}
- placeholder="Type a message..."
- />
-
- setSelectedFile(e.target.files?.[0] || null)}
- />
-
- {selectedFile && Selected: {selectedFile.name}
}
-
-
- {isUploading ? 'Uploading...' : 'Send'}
-
-
- );
-}
-```
-
-## Protocol Comparison
-
-### NIP-04 (Legacy)
-- **Encryption**: NIP-04 (simpler, older)
-- **Metadata**: Sender and recipient visible to relays
-- **Event Kind**: Kind 4
-- **Use When**: Compatibility with older clients
-
-### NIP-17 (Modern & Private)
-- **Encryption**: NIP-44 (stronger)
-- **Metadata**: Hidden via gift wrapping (NIP-59)
-- **Event Kinds**: Kind 14 (text), Kind 15 (files)
-- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
-- **Use When**: Maximum privacy (recommended)
-
-**Key Privacy Features of NIP-17:**
-- Sender identity hidden (uses random ephemeral keys)
-- Timestamps randomized (±2 days) to hide send time
-- Dual gift wraps (recipient + sender) for message history
-
-## Advanced Features
-
-### Conversation Categorization
-
-The system automatically categorizes conversations:
-
-```tsx
-const { conversations } = useDMContext();
-
-// Filter by category
-const knownConversations = conversations.filter(c => c.isKnown);
-const requestConversations = conversations.filter(c => c.isRequest);
-
-// isKnown = true if user has sent at least one message
-// isRequest = true if only received messages, never replied
-```
-
-### Loading States
-
-```tsx
-const { isLoading, loadingPhase, scanProgress } = useDMContext();
-
-// Check overall loading state
-if (isLoading) {
- console.log('Current phase:', loadingPhase);
- // LOADING_PHASES.CACHE - Loading from local cache
- // LOADING_PHASES.RELAYS - Querying relays
- // LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
- // LOADING_PHASES.READY - Fully loaded
-}
-
-// Display scan progress for large message histories
-if (scanProgress.nip17) {
- console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
-}
-```
-
-### Clear Cache and Refresh
-
-```tsx
-import { useDMContext } from '@/hooks/useDMContext';
-
-function SettingsButton() {
- const { clearCacheAndRefetch } = useDMContext();
-
- const handleClearCache = async () => {
- await clearCacheAndRefetch();
- // Clears IndexedDB cache and reloads all messages from relays
- };
-
- return (
-
- Clear Message Cache
-
- );
-}
-```
-
-## Architecture Notes
-
-### Data Flow
-1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
-2. **Background Sync**: New messages fetched from relays in parallel
-3. **Real-time Updates**: WebSocket subscriptions for live messages
-4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
-
-### Storage
-- **IndexedDB**: All messages stored locally with NIP-44 encryption
-- **Per-User Storage**: Separate encrypted store for each logged-in user
-- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
-
-### Performance
-- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
-- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
-- **Pagination**: Conversation messages paginated (25/page)
-- **Deduplication**: Automatic filtering of duplicate messages by ID
-
-### Security
-- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
-- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
-- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
-- **No Plaintext**: Decrypted content never persisted unencrypted
-- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
-
-## Building Custom Messaging UIs
-
-For advanced use cases, you can use the individual DM components to build custom layouts:
-
-### Available Components
-
-**`DMConversationList`** - Conversation sidebar with tabs
-```tsx
-import { DMConversationList } from '@/components/dm/DMConversationList';
-
- setSelectedPubkey(pubkey)}
- onStatusClick={() => setShowStatus(true)} // optional
- className="h-full"
-/>
-```
-
-**`DMChatArea`** - Message thread and compose area
-```tsx
-import { DMChatArea } from '@/components/dm/DMChatArea';
-
- setSelectedPubkey(null)} // optional, for mobile back button
- className="h-full"
-/>
-```
-
-**`DMStatusInfo`** - Debug/status panel
-```tsx
-import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
-
-
-```
-
-### Custom Layout Example
-
-```tsx
-import { useState } from 'react';
-import { DMConversationList } from '@/components/dm/DMConversationList';
-import { DMChatArea } from '@/components/dm/DMChatArea';
-
-function CustomMessagingLayout() {
- const [selectedPubkey, setSelectedPubkey] = useState(null);
-
- return (
-
- {/* Custom sidebar */}
-
-
- {/* Custom main area */}
-
- {selectedPubkey ? (
-
- ) : (
-
-
Select a conversation to start messaging
-
- )}
-
-
- );
-}
-```
-
diff --git a/AGENTS.md b/AGENTS.md
index af1858cb..ece65588 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,14 +16,14 @@ Ditto is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui,
## Project Structure
-- `/src/components/` — UI components. `ui/` holds shadcn primitives; `auth/` holds login components; `dm/` holds direct-messaging UI (built on `DMContext`).
+- `/src/components/` — UI components. `ui/` holds shadcn primitives; `auth/` holds login components.
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`, `useZaps`, `useWallet`, `useNWC`, `useShakespeare`.
- `/src/pages/` — page components wired into `AppRouter.tsx`. The catch-all `/:nip19` route is handled by `NIP19Page.tsx` (see the `nip19-routing` skill).
- `/src/lib/` — utility functions and shared logic.
-- `/src/contexts/` — React context providers (`AppContext`, `NWCContext`, `DMContext`).
+- `/src/contexts/` — React context providers (`AppContext`, `NWCContext`).
- `/src/test/` — testing utilities including the `TestApp` wrapper.
- `/public/` — static assets.
-- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`, `NWCContext`, `DMContext`. Read before editing; changes are rarely needed.
+- `App.tsx` — **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider`, `AppProvider`, `NostrLoginProvider`, `NWCContext`. Read before editing; changes are rarely needed.
- `AppRouter.tsx` — React Router configuration.
- `NIP.md` — custom kinds documented by this project (see the `nostr-kinds` skill).
@@ -214,7 +214,6 @@ Load the matching skill when the feature requires it:
- **`nostr-encryption`** — NIP-44 / NIP-04 via the user's signer (DMs, gift wraps, private content).
- **`nostr-relay-pools`** — `nostr.relay(url)` / `nostr.group([urls])` for targeted queries.
- **`nostr-comments`** — Ditto's threaded comments (NIP-10 for kind 1, NIP-22 for everything else).
-- **`nostr-direct-messages`** — DM implementation via `DMContext` (NIP-04 + NIP-17).
- **`nostr-infinite-scroll`** — feed pagination patterns.
- **`nip85-stats`** — NIP-85 trusted-assertion stats (followers, zap totals, etc.).
- **`ai-chat`** — Shakespeare AI streaming chat interfaces.
diff --git a/README.md b/README.md
index 53b49c74..2d818946 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,6 @@ Made by [Soapbox](https://soapbox.pub).
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
-- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
- **Self-Hosting** -- Builds to static HTML/JS/CSS. Deploy anywhere -- GitHub Pages, Netlify, Vercel, a VPS, or a Raspberry Pi
- **Mobile** -- Android native app via Capacitor, responsive design for all screen sizes
diff --git a/src/App.tsx b/src/App.tsx
index d7f2bab1..ec8c5a39 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
import { createHead, UnheadProvider } from "@unhead/react/client";
import { AppProvider } from "@/components/AppProvider";
-import { DMProvider, type DMConfig } from "@/components/DMProvider";
import { InitialSyncGate } from "@/components/InitialSyncGate";
import { NativeNotifications } from "@/components/NativeNotifications";
import NostrProvider from "@/components/NostrProvider";
@@ -19,17 +18,11 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
-import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
-const dmConfig: DMConfig = {
- enabled: false,
- protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
-};
-
const head = createHead({
plugins: [InferSeoMetaPlugin()],
});
@@ -202,7 +195,6 @@ export function App() {
-
@@ -210,8 +202,7 @@ export function App() {
-
-
+
diff --git a/src/components/DMProvider.tsx b/src/components/DMProvider.tsx
deleted file mode 100644
index 1c271d43..00000000
--- a/src/components/DMProvider.tsx
+++ /dev/null
@@ -1,1580 +0,0 @@
-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;
-
-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(new Map());
- const [lastSync, setLastSync] = useState({
- nip4: null,
- nip17: null
- });
- const [isLoading, setIsLoading] = useState(false);
- const [loadingPhase, setLoadingPhase] = useState(LOADING_PHASES.IDLE);
- const [subscriptions, setSubscriptions] = useState({
- isNIP4Connected: false,
- isNIP17Connected: false
- });
- const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false);
- const [shouldSaveImmediately, setShouldSaveImmediately] = useState(false);
- const [scanProgress, setScanProgress] = useState({
- nip4: null,
- nip17: null
- });
-
- const nip4SubscriptionRef = useRef<{ close: () => void } | null>(null);
- const nip17SubscriptionRef = useRef<{ close: () => void } | null>(null);
- const debouncedWriteRef = useRef(null);
-
- // Reset all DM state when the user account changes. Without this, switching
- // accounts leaves the previous user's decrypted conversations visible and
- // prevents new subscriptions from starting (hasInitialLoadCompleted stays true).
- const prevDMPubkey = useRef(undefined);
- useEffect(() => {
- const pubkey = userPubkey;
- if (prevDMPubkey.current !== undefined && pubkey !== prevDMPubkey.current) {
- // Close existing subscriptions
- if (nip4SubscriptionRef.current) {
- nip4SubscriptionRef.current.close();
- nip4SubscriptionRef.current = null;
- }
- if (nip17SubscriptionRef.current) {
- nip17SubscriptionRef.current.close();
- nip17SubscriptionRef.current = null;
- }
- if (debouncedWriteRef.current) {
- clearTimeout(debouncedWriteRef.current);
- debouncedWriteRef.current = null;
- }
-
- // Reset all state so the main loading effect re-triggers for the new user
- setMessages(new Map());
- setLastSync({ nip4: null, nip17: null });
- setIsLoading(false);
- setLoadingPhase(LOADING_PHASES.IDLE);
- setSubscriptions({ isNIP4Connected: false, isNIP17Connected: false });
- setHasInitialLoadCompleted(false);
- setScanProgress({ nip4: null, nip17: null });
- }
- prevDMPubkey.current = pubkey;
- }, [userPubkey]);
-
- // ============================================================================
- // Internal Message Sending Mutations
- // ============================================================================
-
- // Send NIP-04 Message (internal)
- const sendNIP4Message = useMutation({
- 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({
- 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 = {
- 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 = {
- kind: 13,
- pubkey: user.pubkey,
- created_at: now,
- tags: [],
- content: await user.signer.nip44.encrypt(recipientPubkey, JSON.stringify(privateMessage)),
- };
-
- const senderSeal: Omit = {
- 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 => {
- 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 => {
- 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 => {
- 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;
-
- // NIP-17: clients MUST verify that the inner rumor's pubkey matches the
- // seal's pubkey. Without this check, anyone can gift-wrap a rumor whose
- // `pubkey` field claims to be someone else and impersonate that user.
- // The seal signature authenticates only the seal author, not whatever
- // pubkey appears inside the (unsigned) rumor.
- if (messageEvent.pubkey !== sealEvent.pubkey) {
- console.log(`[DM] ⚠️ NIP-17 IMPERSONATION ATTEMPT - inner pubkey does not match seal pubkey`, {
- giftWrapId: event.id,
- sealPubkey: sealEvent.pubkey,
- innerPubkey: messageEvent.pubkey,
- });
- return {
- processedMessage: {
- ...event,
- content: '',
- decryptedContent: '',
- error: 'Inner event pubkey does not match seal pubkey (possible impersonation)',
- },
- conversationPartner: event.pubkey,
- sealEvent,
- };
- }
-
- // 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,
- 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 (
-
- {children}
-
- );
-}
-
diff --git a/src/components/dm/DMChatArea.tsx b/src/components/dm/DMChatArea.tsx
deleted file mode 100644
index 6ff826ee..00000000
--- a/src/components/dm/DMChatArea.tsx
+++ /dev/null
@@ -1,495 +0,0 @@
-import { useState, useRef, useEffect, useCallback, memo } from 'react';
-import { useConversationMessages } from '@/hooks/useConversationMessages';
-import { useDMContext } from '@/hooks/useDMContext';
-import { useCurrentUser } from '@/hooks/useCurrentUser';
-import { useAuthor } from '@/hooks/useAuthor';
-import { VerifiedNip05Text } from '@/components/Nip05Badge';
-import { genUserName } from '@/lib/genUserName';
-import { MESSAGE_PROTOCOL, PROTOCOL_MODE, type MessageProtocol } from '@/lib/dmConstants';
-import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { getAvatarShape } from '@/lib/avatarShape';
-import { Button } from '@/components/ui/button';
-import { Card } from '@/components/ui/card';
-import { Textarea } from '@/components/ui/textarea';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
-import { ArrowLeft, Send, Loader2, AlertTriangle, Key, ShieldCheck, ImagePlay, Smile } from 'lucide-react';
-import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
-import { cn } from '@/lib/utils';
-import { NoteContent } from '@/components/NoteContent';
-import { GifPicker } from '@/components/GifPicker';
-import { EmojiPicker } from '@/components/EmojiPicker';
-import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
-import { useCustomEmojis } from '@/hooks/useCustomEmojis';
-import { useFeedSettings } from '@/hooks/useFeedSettings';
-import { useInsertText } from '@/hooks/useInsertText';
-import type { NostrEvent } from '@nostrify/nostrify';
-
-interface DMChatAreaProps {
- pubkey: string | null;
- onBack?: () => void;
- className?: string;
-}
-
-const MessageBubble = memo(({
- message,
- isFromCurrentUser
-}: {
- message: {
- id: string;
- pubkey: string;
- kind: number;
- tags: string[][];
- decryptedContent?: string;
- decryptedEvent?: NostrEvent;
- error?: string;
- created_at: number;
- isSending?: boolean;
- };
- isFromCurrentUser: boolean;
-}) => {
- // For NIP-17, use inner message kind (14/15); for NIP-04, use message kind (4)
- const actualKind = message.decryptedEvent?.kind || message.kind;
- const isNIP4Message = message.kind === 4;
- const isFileAttachment = actualKind === 15; // Kind 15 = files/attachments
-
- // Create a NostrEvent object for NoteContent (only used for kind 15)
- // For NIP-17 file attachments, use the decryptedEvent which has the actual tags
- const messageEvent: NostrEvent = message.decryptedEvent || {
- id: message.id,
- pubkey: message.pubkey,
- created_at: message.created_at,
- kind: message.kind,
- tags: message.tags,
- content: message.decryptedContent || '',
- sig: '', // Not needed for display
- };
-
- return (
-
-
- {message.error ? (
-
-
- 🔒 Failed to decrypt
-
-
- {message.error}
-
-
- ) : isFileAttachment ? (
- // Kind 15: Use NoteContent to render files/media with imeta tags
-
-
-
- ) : (
- // Kind 4 (NIP-04) and Kind 14 (NIP-17 text): Display plain text
-
- {message.decryptedContent}
-
- )}
-
-
-
-
-
- {formatConversationTime(message.created_at)}
-
-
-
- {formatFullDateTime(message.created_at)}
-
-
-
-
-
-
-
-
- {message.kind === 4 ? (
-
- ) : (
-
- )}
-
-
-
-
- {message.kind === 4 && "NIP-04 Kind 4 (Legacy DM)"}
- {message.kind === 14 && "NIP-17 Kind 14 (Private Message)"}
- {message.kind === 15 && "NIP-17 Kind 15 (Media)"}
- {message.kind !== 4 && message.kind !== 14 && message.kind !== 15 && `Kind ${message.kind}`}
-
-
-
-
- {isNIP4Message && (
-
-
-
-
-
-
- Uses outdated NIP-04 encryption
-
-
-
- )}
- {message.isSending && (
-
- )}
-
-
-
- );
-});
-
-MessageBubble.displayName = 'MessageBubble';
-
-const ChatHeader = ({ pubkey, onBack }: { pubkey: string; onBack?: () => void }) => {
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const avatarShape = getAvatarShape(metadata);
-
- const displayName = metadata?.name || genUserName(pubkey);
- const avatarUrl = metadata?.picture;
- const initials = displayName.slice(0, 2).toUpperCase();
-
- return (
-
- {onBack && (
-
-
-
- )}
-
-
-
- {initials}
-
-
-
-
{displayName}
- {metadata?.nip05 && (
-
- )}
-
-
- );
-};
-
-const EmptyState = ({ isLoading }: { isLoading: boolean }) => {
- return (
-
-
- {isLoading ? (
- <>
-
-
Loading conversations...
-
- Fetching encrypted messages from relays
-
- >
- ) : (
- <>
-
Select a conversation to start messaging
-
- Your messages are encrypted and stored locally
-
- >
- )}
-
-
- );
-};
-
-export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
- const { user } = useCurrentUser();
- const { sendMessage, protocolMode, isLoading } = useDMContext();
- const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(pubkey || '');
-
- const [messageText, setMessageText] = useState('');
- const [isSending, setIsSending] = useState(false);
- const [isLoadingMore, setIsLoadingMore] = useState(false);
- const [gifOpen, setGifOpen] = useState(false);
- const [emojiOpen, setEmojiOpen] = useState(false);
- const { feedSettings } = useFeedSettings();
- const { emojis: allCustomEmojis } = useCustomEmojis();
- const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
- const textareaRef = useRef(null);
- const { insertAtCursor, insertEmoji } = useInsertText(textareaRef, messageText, setMessageText);
-
- // Determine default protocol based on mode
- const getDefaultProtocol = () => {
- if (protocolMode === PROTOCOL_MODE.NIP04_ONLY) return MESSAGE_PROTOCOL.NIP04;
- if (protocolMode === PROTOCOL_MODE.NIP17_ONLY) return MESSAGE_PROTOCOL.NIP17;
- if (protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17) return MESSAGE_PROTOCOL.NIP17;
- // Fallback to NIP-17 for any unexpected mode
- return MESSAGE_PROTOCOL.NIP17;
- };
-
- const [selectedProtocol, setSelectedProtocol] = useState(getDefaultProtocol());
- const scrollAreaRef = useRef(null);
-
- // Determine if selection is allowed
- const allowSelection = protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17;
-
- // Auto-scroll to bottom when new messages arrive
- useEffect(() => {
- if (scrollAreaRef.current) {
- const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
- if (scrollContainer) {
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
- }
- }
- }, [messages.length]);
-
- const handleSend = useCallback(async () => {
- if (!messageText.trim() || !pubkey || !user) return;
-
- setIsSending(true);
- try {
- await sendMessage({
- recipientPubkey: pubkey,
- content: messageText.trim(),
- protocol: selectedProtocol,
- });
- setMessageText('');
- } catch (error) {
- console.error('Failed to send message:', error);
- } finally {
- setIsSending(false);
- }
- }, [messageText, pubkey, user, sendMessage, selectedProtocol]);
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- }, [handleSend]);
-
- const handleLoadMore = useCallback(async () => {
- if (!scrollAreaRef.current || isLoadingMore) return;
-
- const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
- if (!scrollContainer) return;
-
- // Store current scroll position and height
- const previousScrollHeight = scrollContainer.scrollHeight;
- const previousScrollTop = scrollContainer.scrollTop;
-
- setIsLoadingMore(true);
-
- // Load more messages
- loadEarlierMessages();
-
- // Wait for DOM to update, then restore relative scroll position
- setTimeout(() => {
- if (scrollContainer) {
- const newScrollHeight = scrollContainer.scrollHeight;
- const heightDifference = newScrollHeight - previousScrollHeight;
- scrollContainer.scrollTop = previousScrollTop + heightDifference;
- }
- setIsLoadingMore(false);
- }, 0);
- }, [loadEarlierMessages, isLoadingMore]);
-
- if (!pubkey) {
- return (
-
-
-
- );
- }
-
- if (!user) {
- return (
-
-
-
Please log in to view messages
-
-
- );
- }
-
- return (
-
-
-
-
- {messages.length === 0 ? (
-
-
-
No messages yet
-
Send a message to start the conversation
-
-
- ) : (
-
- {hasMoreMessages && (
-
-
- {isLoadingMore ? (
- <>
-
- Loading...
- >
- ) : (
- 'Load Earlier Messages'
- )}
-
-
- )}
- {messages.map((message) => (
-
- ))}
-
- )}
-
-
-
-
-
-
- setMessageText(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
- className="min-h-[80px] resize-none"
- disabled={isSending}
- />
-
-
- {/* Toolbar row */}
-
- {/* Emoji picker */}
-
-
-
-
-
-
-
- {
- const text = selection.type === 'native' ? selection.emoji : `:${selection.shortcode}:`;
- insertEmoji(text);
- }}
- />
-
-
-
- {/* GIF picker */}
-
-
-
-
-
-
-
- {
- setMessageText((prev) => (prev ? prev + '\n' + gif.url : gif.url));
- setGifOpen(false);
- }} />
-
-
-
-
-
-
- {isSending ? (
-
- ) : (
-
- )}
-
- setSelectedProtocol(value as MessageProtocol)}
- disabled={!allowSelection}
- >
-
-
-
-
-
- NIP-17
-
-
- NIP-04
-
-
-
-
-
-
-
- );
-};
diff --git a/src/components/dm/DMConversationList.tsx b/src/components/dm/DMConversationList.tsx
deleted file mode 100644
index f628a832..00000000
--- a/src/components/dm/DMConversationList.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-import { useMemo, useState, memo } from 'react';
-import { AlertTriangle, Info, Loader2 } from 'lucide-react';
-import { useDMContext } from '@/hooks/useDMContext';
-import { useAuthor } from '@/hooks/useAuthor';
-import { genUserName } from '@/lib/genUserName';
-import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { getAvatarShape } from '@/lib/avatarShape';
-import { Button } from '@/components/ui/button';
-import { Card } from '@/components/ui/card';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
-import { cn } from '@/lib/utils';
-import { LOADING_PHASES } from '@/lib/dmConstants';
-
-interface DMConversationListProps {
- selectedPubkey: string | null;
- onSelectConversation: (pubkey: string) => void;
- className?: string;
- onStatusClick?: () => void;
-}
-
-interface ConversationItemProps {
- pubkey: string;
- isSelected: boolean;
- onClick: () => void;
- lastMessage: { decryptedContent?: string; error?: string } | null;
- lastActivity: number;
- hasNIP4Messages: boolean;
-}
-
-const ConversationItemComponent = ({
- pubkey,
- isSelected,
- onClick,
- lastMessage,
- lastActivity,
- hasNIP4Messages
-}: ConversationItemProps) => {
- const author = useAuthor(pubkey);
- const metadata = author.data?.metadata;
- const avatarShape = getAvatarShape(metadata);
-
- const displayName = metadata?.name || genUserName(pubkey);
- const avatarUrl = metadata?.picture;
- const initials = displayName.slice(0, 2).toUpperCase();
-
- const lastMessagePreview = lastMessage?.error
- ? '🔒 Encrypted message'
- : lastMessage?.decryptedContent || 'No messages yet';
-
- // Show skeleton only for name/avatar while loading (we already have message data)
- const isLoadingProfile = author.isLoading && !metadata;
-
- return (
-
-
- {isLoadingProfile ? (
-
- ) : (
-
-
- {initials}
-
- )}
-
-
-
-
- {isLoadingProfile ? (
-
- ) : (
-
{displayName}
- )}
- {hasNIP4Messages && (
-
-
-
-
-
-
- Some messages use outdated NIP-04 encryption
-
-
-
- )}
-
-
-
-
-
- {formatConversationTime(lastActivity)}
-
-
-
- {formatFullDateTime(lastActivity)}
-
-
-
-
-
-
- {lastMessagePreview}
-
-
-
-
- );
-};
-
-const ConversationItem = memo(ConversationItemComponent);
-ConversationItem.displayName = 'ConversationItem';
-
-const ConversationListSkeleton = () => {
- return (
-
- {[1, 2, 3, 4, 5].map((i) => (
-
- ))}
-
- );
-};
-
-export const DMConversationList = ({
- selectedPubkey,
- onSelectConversation,
- className,
- onStatusClick
-}: DMConversationListProps) => {
- const { conversations, isLoading, loadingPhase } = useDMContext();
- const [activeTab, setActiveTab] = useState<'known' | 'requests'>('known');
-
- // Filter conversations by type
- const { knownConversations, requestConversations } = useMemo(() => {
- return {
- knownConversations: conversations.filter(c => c.isKnown),
- requestConversations: conversations.filter(c => c.isRequest),
- };
- }, [conversations]);
-
- // Get the current list based on active tab
- const currentConversations = activeTab === 'known' ? knownConversations : requestConversations;
-
- // Show skeleton during initial load (cache + relays) if we have no conversations yet
- const isInitialLoad = (loadingPhase === LOADING_PHASES.CACHE || loadingPhase === LOADING_PHASES.RELAYS) && conversations.length === 0;
-
- return (
-
- {/* Header - always visible */}
-
-
-
Messages
- {(loadingPhase === LOADING_PHASES.CACHE ||
- loadingPhase === LOADING_PHASES.RELAYS ||
- loadingPhase === LOADING_PHASES.SUBSCRIPTIONS) && (
-
-
-
-
-
-
-
-
-
- {loadingPhase === LOADING_PHASES.CACHE && 'Loading from cache...'}
- {loadingPhase === LOADING_PHASES.RELAYS && 'Querying relays for new messages...'}
- {loadingPhase === LOADING_PHASES.SUBSCRIPTIONS && 'Setting up subscriptions...'}
-
-
-
-
- )}
-
- {onStatusClick && (
-
-
-
- )}
-
-
- {/* Tab buttons - always visible */}
-
-
- setActiveTab('known')}
- className={cn(
- "text-xs py-2 px-3 rounded-md transition-colors",
- activeTab === 'known'
- ? "bg-background shadow-sm font-medium"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Active {knownConversations.length > 0 && `(${knownConversations.length})`}
-
- setActiveTab('requests')}
- className={cn(
- "text-xs py-2 px-3 rounded-md transition-colors",
- activeTab === 'requests'
- ? "bg-background shadow-sm font-medium"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Requests {requestConversations.length > 0 && `(${requestConversations.length})`}
-
-
-
-
- {/* Content area - show skeleton during initial load, otherwise show conversations */}
-
- {(isLoading || isInitialLoad) ? (
-
- ) : conversations.length === 0 ? (
-
-
-
No conversations yet
-
Start a new conversation to get started
-
-
- ) : currentConversations.length === 0 ? (
-
-
No {activeTab} conversations
-
- ) : (
-
-
- {currentConversations.map((conversation) => (
- onSelectConversation(conversation.pubkey)}
- lastMessage={conversation.lastMessage}
- lastActivity={conversation.lastActivity}
- hasNIP4Messages={conversation.hasNIP4Messages}
- />
- ))}
-
-
- )}
-
-
- );
-};
diff --git a/src/components/dm/DMMessagingInterface.tsx b/src/components/dm/DMMessagingInterface.tsx
deleted file mode 100644
index 200ed242..00000000
--- a/src/components/dm/DMMessagingInterface.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useState, useCallback } from 'react';
-import { DMConversationList } from '@/components/dm/DMConversationList';
-import { DMChatArea } from '@/components/dm/DMChatArea';
-import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
-import { useDMContext } from '@/hooks/useDMContext';
-import { useIsMobile } from '@/hooks/useIsMobile';
-import { cn } from '@/lib/utils';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-
-interface DMMessagingInterfaceProps {
- className?: string;
-}
-
-export const DMMessagingInterface = ({ className }: DMMessagingInterfaceProps) => {
- const [selectedPubkey, setSelectedPubkey] = useState(null);
- const [statusModalOpen, setStatusModalOpen] = useState(false);
- const isMobile = useIsMobile();
- const { clearCacheAndRefetch } = useDMContext();
-
- // On mobile, show only one panel at a time
- const showConversationList = !isMobile || !selectedPubkey;
- const showChatArea = !isMobile || selectedPubkey;
-
- const handleSelectConversation = useCallback((pubkey: string) => {
- setSelectedPubkey(pubkey);
- }, []);
-
- const handleBack = useCallback(() => {
- setSelectedPubkey(null);
- }, []);
-
- return (
- <>
- {/* Status Modal */}
-
-
-
- Messaging Status
-
- View loading status, cache info, and connection details
-
-
-
-
-
-
-
- {/* Conversation List - Left Sidebar */}
-
- setStatusModalOpen(true)}
- />
-
-
- {/* Chat Area - Right Panel */}
-
-
-
-
- >
- );
-};
-
diff --git a/src/components/dm/DMStatusInfo.tsx b/src/components/dm/DMStatusInfo.tsx
deleted file mode 100644
index c44dfd49..00000000
--- a/src/components/dm/DMStatusInfo.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import { useState } from 'react';
-import { RefreshCw, Database, Wifi, CheckCircle2, Loader2 } from 'lucide-react';
-import { useDMContext } from '@/hooks/useDMContext';
-import { LOADING_PHASES } from '@/lib/dmConstants';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { Separator } from '@/components/ui/separator';
-import { useToast } from '@/hooks/useToast';
-
-interface DMStatusInfoProps {
- clearCacheAndRefetch?: () => Promise;
-}
-
-export const DMStatusInfo = ({ clearCacheAndRefetch }: DMStatusInfoProps) => {
- const [isClearing, setIsClearing] = useState(false);
- const { toast } = useToast();
- const {
- loadingPhase,
- subscriptions,
- scanProgress,
- isDoingInitialLoad,
- lastSync,
- conversations,
- } = useDMContext();
-
- const handleClearCache = async () => {
- if (!clearCacheAndRefetch) return;
-
- setIsClearing(true);
- try {
- await clearCacheAndRefetch();
- toast({
- title: 'Cache cleared',
- description: 'Refetching messages from relays...',
- });
- setIsClearing(false);
- } catch (error) {
- console.error('Error clearing cache:', error);
- toast({
- title: 'Error',
- description: 'Failed to clear cache. Please try again.',
- variant: 'destructive',
- });
- setIsClearing(false);
- }
- };
-
- const getLoadingPhaseInfo = () => {
- switch (loadingPhase) {
- case LOADING_PHASES.IDLE:
- return { label: 'Idle', description: 'Not yet initialized', icon: Loader2, color: 'text-muted-foreground' };
- case LOADING_PHASES.CACHE:
- return { label: 'Loading from cache', description: 'Reading cached messages...', icon: Database, color: 'text-blue-500' };
- case LOADING_PHASES.RELAYS:
- return { label: 'Loading from relays', description: 'Fetching messages from Nostr relays...', icon: Wifi, color: 'text-yellow-500' };
- case LOADING_PHASES.SUBSCRIPTIONS:
- return { label: 'Connecting subscriptions', description: 'Setting up real-time message sync...', icon: RefreshCw, color: 'text-orange-500' };
- case LOADING_PHASES.READY:
- return { label: 'Ready', description: 'All systems operational', icon: CheckCircle2, color: 'text-green-500' };
- default:
- return { label: 'Unknown', description: 'Status unknown', icon: Loader2, color: 'text-muted-foreground' };
- }
- };
-
- const phaseInfo = getLoadingPhaseInfo();
- const PhaseIcon = phaseInfo.icon;
-
- const formatTimestamp = (timestamp: number | null) => {
- if (!timestamp) return 'Never';
- const date = new Date(timestamp * 1000);
- const now = new Date();
- const diffMs = now.getTime() - date.getTime();
- const diffMins = Math.floor(diffMs / 60000);
-
- if (diffMins < 1) return 'Just now';
- if (diffMins < 60) return `${diffMins}m ago`;
- if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
- return date.toLocaleDateString();
- };
-
- return (
-
- {/* Loading Phase */}
-
-
-
-
-
-
-
{phaseInfo.label}
- {isDoingInitialLoad && (
-
- Initial Load
-
- )}
-
-
{phaseInfo.description}
-
-
-
-
-
- {/* Scan Progress */}
- {(scanProgress.nip4 !== null || scanProgress.nip17 !== null) && (
-
-
-
-
Scanning Messages
- {scanProgress.nip4 !== null && (
-
-
- NIP-4 (Legacy)
- {scanProgress.nip4.current} events
-
-
{scanProgress.nip4.status}
-
- )}
- {scanProgress.nip17 !== null && (
-
-
- NIP-17 (Private)
- {scanProgress.nip17.current} events
-
-
{scanProgress.nip17.status}
-
- )}
-
-
-
- )}
-
- {/* Subscriptions */}
-
-
-
-
Real-time Subscriptions
-
-
- NIP-4 (Legacy DMs)
-
- {subscriptions.isNIP4Connected ? 'Connected' : 'Disconnected'}
-
-
-
- NIP-17 (Private DMs)
-
- {subscriptions.isNIP17Connected ? 'Connected' : 'Disconnected'}
-
-
-
-
-
-
-
- {/* Cache Info */}
-
-
-
-
Cache Information
-
-
- Conversations
- {conversations.length}
-
-
- Last NIP-4 sync
- {formatTimestamp(lastSync.nip4)}
-
-
- Last NIP-17 sync
- {formatTimestamp(lastSync.nip17)}
-
-
-
-
-
-
- {/* Actions */}
- {clearCacheAndRefetch && (
- <>
-
-
-
-
Cache Management
-
- Clear all cached messages and refetch from relays. This will force a fresh sync.
-
-
-
- {isClearing ? (
- <>
-
- Clearing...
- >
- ) : (
- <>
-
- Clear Cache & Refetch
- >
- )}
-
-
- >
- )}
-
- );
-};
-
diff --git a/src/contexts/DMContext.ts b/src/contexts/DMContext.ts
deleted file mode 100644
index ce59eedb..00000000
--- a/src/contexts/DMContext.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { createContext } from 'react';
-import { type LoadingPhase, type ProtocolMode } from '@/lib/dmConstants';
-import { type NostrEvent } from '@nostrify/nostrify';
-import type { MessageProtocol } from '@/lib/dmConstants';
-
-// ============================================================================
-// DM Types and Constants
-// ============================================================================
-
-interface ParticipantData {
- messages: DecryptedMessage[];
- lastActivity: number;
- lastMessage: DecryptedMessage | null;
- hasNIP4: boolean;
- hasNIP17: boolean;
-}
-
-type MessagesState = Map;
-
-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 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
-}
-
-/**
- * File attachment for direct messages (NIP-92 compatible).
- *
- * All fields are required. Use with `useUploadFile` hook to upload files
- * and generate the proper tags format.
- *
- * @example
- * ```tsx
- * import { useUploadFile } from '@/hooks/useUploadFile';
- * import type { FileAttachment } from '@/contexts/DMContext';
- *
- * const { mutateAsync: uploadFile } = useUploadFile();
- *
- * const tags = await uploadFile(file);
- * const attachment: FileAttachment = {
- * url: tags[0][1],
- * mimeType: file.type,
- * size: file.size,
- * name: file.name,
- * tags: tags
- * };
- *
- * await sendMessage({
- * recipientPubkey: 'hex-pubkey',
- * content: 'Check out this file!',
- * attachments: [attachment]
- * });
- * ```
- *
- * @property url - Blossom server URL where file is hosted
- * @property mimeType - MIME type of the file (e.g., 'image/png')
- * @property size - File size in bytes
- * @property name - Original filename
- * @property tags - NIP-94 file metadata tags (includes hashes)
- */
-export interface FileAttachment {
- url: string;
- mimeType: string;
- size: number;
- name: string;
- tags: string[][];
-}
-
-/**
- * Direct Messaging context interface providing access to all DM functionality.
- *
- * @property messages - Raw message state (Map of pubkey -> participant data)
- * @property isLoading - True during initial load phases
- * @property loadingPhase - Current loading phase (CACHE, RELAYS, SUBSCRIPTIONS, READY, IDLE)
- * @property isDoingInitialLoad - True only during cache/relay loading (not subscriptions)
- * @property lastSync - Unix timestamps of last successful sync for each protocol
- * @property subscriptions - Connection status for real-time message subscriptions
- * @property conversations - Array of conversation summaries sorted by last activity
- * @property sendMessage - Send an encrypted direct message (NIP-04 or NIP-17)
- * @property protocolMode - Current protocol mode (NIP04_ONLY, NIP17_ONLY, or BOTH)
- * @property scanProgress - Progress info for large message history scans
- * @property clearCacheAndRefetch - Clear IndexedDB cache and reload all messages from relays
- */
-export interface DMContextType {
- messages: MessagesState;
- isLoading: boolean;
- loadingPhase: LoadingPhase;
- isDoingInitialLoad: boolean;
- lastSync: LastSyncData;
- subscriptions: SubscriptionStatus;
- conversations: ConversationSummary[];
- sendMessage: (params: {
- recipientPubkey: string;
- content: string;
- protocol?: MessageProtocol;
- attachments?: FileAttachment[];
- }) => Promise;
- protocolMode: ProtocolMode;
- scanProgress: ScanProgressState;
- clearCacheAndRefetch: () => Promise;
-}
-
-export const DMContext = createContext(null);
\ No newline at end of file
diff --git a/src/hooks/useConversationMessages.ts b/src/hooks/useConversationMessages.ts
deleted file mode 100644
index 2fbf9e16..00000000
--- a/src/hooks/useConversationMessages.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { useDMContext } from "@/hooks/useDMContext";
-
-const MESSAGES_PER_PAGE = 25;
-
-/**
- * Hook to access paginated messages for a specific conversation.
- *
- * Returns the most recent messages (default 25) with the ability to load earlier messages.
- * Automatically resets to default page size when switching conversations.
- *
- * @example
- * ```tsx
- * import { useConversationMessages } from '@/contexts/DMContext';
- *
- * function MessageThread({ recipientPubkey }: { recipientPubkey: string }) {
- * const {
- * messages,
- * hasMoreMessages,
- * loadEarlierMessages,
- * totalCount
- * } = useConversationMessages(recipientPubkey);
- *
- * return (
- *
- * {hasMoreMessages && (
- *
- * Load Earlier ({totalCount - messages.length} more)
- *
- * )}
- * {messages.map(msg => (
- *
{msg.decryptedContent}
- * ))}
- *
- * );
- * }
- * ```
- *
- * @param conversationId - The pubkey of the conversation participant
- * @returns Paginated message data with loading function
- */
-export function useConversationMessages(conversationId: string) {
- const { messages: allMessages } = useDMContext();
- const [visibleCount, setVisibleCount] = useState(MESSAGES_PER_PAGE);
-
- const result = useMemo(() => {
- const conversationData = allMessages.get(conversationId);
-
- if (!conversationData) {
- return {
- messages: [],
- hasMoreMessages: false,
- totalCount: 0,
- lastMessage: null,
- lastActivity: 0,
- };
- }
-
- const totalMessages = conversationData.messages.length;
- const hasMore = totalMessages > visibleCount;
-
- // Return the most recent N messages (slice from the end)
- const visibleMessages = conversationData.messages.slice(-visibleCount);
-
- return {
- messages: visibleMessages,
- hasMoreMessages: hasMore,
- totalCount: totalMessages,
- lastMessage: conversationData.lastMessage,
- lastActivity: conversationData.lastActivity,
- };
- }, [allMessages, conversationId, visibleCount]);
-
- const loadEarlierMessages = useCallback(() => {
- setVisibleCount(prev => prev + MESSAGES_PER_PAGE);
- }, []);
-
- // Reset visible count when conversation changes
- useEffect(() => {
- setVisibleCount(MESSAGES_PER_PAGE);
- }, [conversationId]);
-
- return {
- ...result,
- loadEarlierMessages,
- };
-}
\ No newline at end of file
diff --git a/src/hooks/useDMContext.ts b/src/hooks/useDMContext.ts
deleted file mode 100644
index a29a47f0..00000000
--- a/src/hooks/useDMContext.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useContext } from "react";
-import { DMContext, DMContextType } from "@/contexts/DMContext";
-
-/**
- * Hook to access the direct messaging system.
- *
- * Provides access to conversations, message sending, loading states, and cache management.
- * Must be used within a DMProvider.
- *
- * @example
- * ```tsx
- * import { useDMContext } from '@/hooks/useDMContext';
- * import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
- *
- * function MyComponent() {
- * const { conversations, sendMessage, isLoading } = useDMContext();
- *
- * // Send a message
- * await sendMessage({
- * recipientPubkey: 'hex-pubkey',
- * content: 'Hello!',
- * protocol: MESSAGE_PROTOCOL.NIP17
- * });
- *
- * // Display conversations
- * return (
- *
- * {isLoading ? 'Loading...' : conversations.map(c => (
- *
{c.lastMessage?.decryptedContent}
- * ))}
- *
- * );
- * }
- * ```
- *
- * @returns DMContextType - The direct messaging context
- * @throws Error if used outside DMProvider
- */
-export function useDMContext(): DMContextType {
- const context = useContext(DMContext);
- if (!context) {
- throw new Error('useDMContext must be used within DMProvider');
- }
- return context;
-}
\ No newline at end of file
diff --git a/src/lib/dmConstants.ts b/src/lib/dmConstants.ts
deleted file mode 100644
index 17945799..00000000
--- a/src/lib/dmConstants.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import type { NostrEvent } from '@nostrify/nostrify';
-
-// ============================================================================
-// Message Protocol Types
-// ============================================================================
-
-export const MESSAGE_PROTOCOL = {
- NIP04: 'nip04',
- NIP17: 'nip17',
- UNKNOWN: 'unknown',
-} as const;
-
-export type MessageProtocol = typeof MESSAGE_PROTOCOL[keyof typeof MESSAGE_PROTOCOL];
-
-// ============================================================================
-// Protocol Mode (for user selection)
-// ============================================================================
-
-export const PROTOCOL_MODE = {
- NIP04_ONLY: 'nip04_only',
- NIP17_ONLY: 'nip17_only',
- NIP04_OR_NIP17: 'nip04_or_nip17',
-} as const;
-
-export type ProtocolMode = typeof PROTOCOL_MODE[keyof typeof PROTOCOL_MODE];
-
-// ============================================================================
-// Loading Phases
-// ============================================================================
-
-export const LOADING_PHASES = {
- IDLE: 'idle',
- CACHE: 'cache',
- RELAYS: 'relays',
- SUBSCRIPTIONS: 'subscriptions',
- READY: 'ready',
-} as const;
-
-export type LoadingPhase = typeof LOADING_PHASES[keyof typeof LOADING_PHASES];
-
-// ============================================================================
-// Protocol Configuration
-// ============================================================================
-
-export const PROTOCOL_CONFIG = {
- [MESSAGE_PROTOCOL.NIP04]: {
- label: 'NIP-04',
- description: 'Legacy DMs',
- kind: 4,
- },
- [MESSAGE_PROTOCOL.NIP17]: {
- label: 'NIP-17',
- description: 'Private DMs',
- kind: 1059,
- },
- [MESSAGE_PROTOCOL.UNKNOWN]: {
- label: 'Unknown',
- description: 'Unknown protocol',
- kind: 0,
- },
-} as const;
-
-// ============================================================================
-// Helper Functions
-// ============================================================================
-
-/**
- * Get the message protocol from an event kind
- */
-export function getMessageProtocol(event: NostrEvent): MessageProtocol {
- switch (event.kind) {
- case 4:
- return MESSAGE_PROTOCOL.NIP04;
- case 1059:
- return MESSAGE_PROTOCOL.NIP17;
- default:
- return MESSAGE_PROTOCOL.UNKNOWN;
- }
-}
-
-/**
- * Check if a protocol is valid for sending messages
- */
-export function isValidSendProtocol(protocol: MessageProtocol): boolean {
- return protocol === MESSAGE_PROTOCOL.NIP04 || protocol === MESSAGE_PROTOCOL.NIP17;
-}
diff --git a/src/lib/dmMessageStore.ts b/src/lib/dmMessageStore.ts
deleted file mode 100644
index 41cccdaa..00000000
--- a/src/lib/dmMessageStore.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import type { NostrEvent } from '@nostrify/nostrify';
-
-import { openDatabase, STORE } from '@/lib/db';
-
-// ============================================================================
-// DM Message IndexedDB Store
-// ============================================================================
-
-interface StoredParticipant {
- messages: NostrEvent[];
- lastActivity: number;
- hasNIP4: boolean;
- hasNIP17: boolean;
-}
-
-export interface MessageStore {
- participants: Record;
- lastSync: {
- nip4: number | null;
- nip17: number | null;
- };
-}
-
-// ============================================================================
-// Database Operations
-// ============================================================================
-
-/**
- * Write messages to IndexedDB for a specific user.
- * Messages are stored in their original encrypted form (kind 4 or kind 13).
- * Silently skipped when IndexedDB is unavailable.
- */
-export async function writeMessagesToDB(
- userPubkey: string,
- messageStore: MessageStore
-): Promise {
- try {
- const db = await openDatabase();
- if (!db) return; // IndexedDB unavailable — skip persistence.
- // Store messages in their original encrypted form (no NIP-44 wrapper needed)
- // Each message content is already encrypted by the sender
- await db.put(STORE.MESSAGES, messageStore, userPubkey);
- } catch {
- // Write failure is non-critical — DMs still work in-memory.
- }
-}
-
-/**
- * Read messages from IndexedDB for a specific user.
- * Messages are stored in their original encrypted form (kind 4 or kind 13).
- * Returns `undefined` when IndexedDB is unavailable.
- */
-export async function readMessagesFromDB(
- userPubkey: string
-): Promise {
- try {
- const db = await openDatabase();
- if (!db) return undefined; // IndexedDB unavailable.
- const data = await db.get(STORE.MESSAGES, userPubkey);
- if (!data) return undefined;
- return data as MessageStore;
- } catch {
- // Read failure — return undefined so the caller proceeds without cache.
- return undefined;
- }
-}
-
-/**
- * Delete messages from IndexedDB for a specific user.
- * Silently skipped when IndexedDB is unavailable.
- */
-export async function deleteMessagesFromDB(userPubkey: string): Promise {
- try {
- const db = await openDatabase();
- if (!db) return;
- await db.delete(STORE.MESSAGES, userPubkey);
- } catch {
- // Non-critical.
- }
-}
-
-/**
- * Clear all messages from IndexedDB.
- * Silently skipped when IndexedDB is unavailable.
- */
-export async function clearAllMessages(): Promise {
- try {
- const db = await openDatabase();
- if (!db) return;
- await db.clear(STORE.MESSAGES);
- } catch {
- // Non-critical.
- }
-}
diff --git a/src/lib/dmUtils.ts b/src/lib/dmUtils.ts
deleted file mode 100644
index 8b5ba927..00000000
--- a/src/lib/dmUtils.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { NostrEvent } from '@nostrify/nostrify';
-
-/**
- * Validate that an event is a proper DM event
- */
-export function validateDMEvent(event: NostrEvent): boolean {
- // Must be kind 4 (NIP-04 DM)
- if (event.kind !== 4) return false;
-
- // Must have a 'p' tag
- const hasRecipient = event.tags?.some(([name]) => name === 'p');
- if (!hasRecipient) return false;
-
- // Must have content (even if encrypted)
- if (!event.content) return false;
-
- return true;
-}
-
-/**
- * Get the recipient pubkey from a DM event
- */
-export function getRecipientPubkey(event: NostrEvent): string | undefined {
- return event.tags?.find(([name]) => name === 'p')?.[1];
-}
-
-/**
- * Get the conversation partner pubkey from a DM event
- * (the other person in the conversation, not the current user)
- */
-export function getConversationPartner(event: NostrEvent, userPubkey: string): string | undefined {
- const isFromUser = event.pubkey === userPubkey;
-
- if (isFromUser) {
- // If we sent it, the partner is the recipient
- return getRecipientPubkey(event);
- } else {
- // If they sent it, the partner is the author
- return event.pubkey;
- }
-}
-
-/**
- * Format timestamp for display (matches Signal/WhatsApp/Telegram pattern)
- * Today: Show time (e.g., "2:45 PM")
- * Yesterday: "Yesterday"
- * This week: Day name (e.g., "Mon")
- * This year: Month and day (e.g., "Jan 15")
- * Older: Full date (e.g., "Jan 15, 2024")
- */
-export function formatConversationTime(timestamp: number): string {
- const date = new Date(timestamp * 1000);
- const now = new Date();
-
- // Start of today (midnight)
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
-
- // Start of yesterday
- const yesterdayStart = new Date(todayStart);
- yesterdayStart.setDate(yesterdayStart.getDate() - 1);
-
- // Start of this week (assuming week starts on Sunday, adjust if needed)
- const weekStart = new Date(todayStart);
- weekStart.setDate(weekStart.getDate() - weekStart.getDay());
-
- if (date >= todayStart) {
- // Today: Show time (e.g., "2:45 PM")
- return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
- } else if (date >= yesterdayStart) {
- // Yesterday
- return 'Yesterday';
- } else if (date >= weekStart) {
- // This week: Show day name (e.g., "Monday")
- return date.toLocaleDateString(undefined, { weekday: 'short' });
- } else if (date.getFullYear() === now.getFullYear()) {
- // This year: Show month and day (e.g., "Jan 15")
- return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
- } else {
- // Older: Show full date (e.g., "Jan 15, 2024")
- return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
- }
-}
-
-/**
- * Format timestamp as full date and time for tooltips
- * e.g., "Mon, Jan 15, 2024, 2:45 PM"
- */
-export function formatFullDateTime(timestamp: number): string {
- const date = new Date(timestamp * 1000);
- return date.toLocaleString(undefined, {
- weekday: 'short',
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: 'numeric',
- minute: '2-digit'
- });
-}
diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx
deleted file mode 100644
index 00b4c680..00000000
--- a/src/pages/Messages.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useSeoMeta } from '@unhead/react';
-import { DMMessagingInterface } from '@/components/dm/DMMessagingInterface';
-import { useLayoutOptions } from '@/contexts/LayoutContext';
-
-const Messages = () => {
- useSeoMeta({
- title: 'Messages',
- description: 'Private encrypted messaging on Nostr',
- });
-
- useLayoutOptions({ noOverscroll: true, rightSidebar: null, noMaxWidth: true });
-
- return (
-
-
- {/* Header */}
-
-
Messages
-
-
-
-
-
- );
-};
-
-export default Messages;