Remove direct messaging feature and skill

Deletes the DM implementation (DMProvider, DMContext, useDMContext,
useConversationMessages, DMChatArea, DMConversationList,
DMMessagingInterface, DMStatusInfo, dmMessageStore, dmUtils,
dmConstants, the orphaned pages/Messages.tsx, and the
nostr-direct-messages skill) and removes the corresponding wrapper
from the provider tree in App.tsx.

The feature was already disabled (dmConfig.enabled = false), so this
removes no user-visible functionality -- only ~1,600 lines in
DMProvider and the associated UI/context/hooks. The nip44/nip04 signer
paths used by drafts, letters, mute lists, and encrypted settings are
unrelated and remain. Kind 1222 voice messages are a public-feed
feature and stay.

Documentation cleanup: strip the three DM mentions from AGENTS.md
(Project Structure, App.tsx provider list, Specialized Workflows skill
pointer) and the Private Messaging bullet from README.md's feature
list. Historical CHANGELOG entries are preserved.
This commit is contained in:
Alex Gleason
2026-04-26 23:21:54 -05:00
parent bd68a32708
commit e883309791
16 changed files with 4 additions and 3709 deletions
@@ -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 (
<UnheadProvider head={head}>
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey='nostr:login'>
<NostrProvider>
<NostrSync />
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<Suspense>
<AppRouter />
</Suspense>
</TooltipProvider>
</DMProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</AppProvider>
</UnheadProvider>
);
}
```
### 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 (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
);
}
```
### 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 <div>Loading conversations...</div>;
}
return (
<div className="space-y-2">
{conversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
conversation={conversation}
onClick={() => onSelectConversation(conversation.pubkey)}
/>
))}
</div>
);
}
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 (
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={avatarUrl} />
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 text-left">
<div className="font-medium">{displayName}</div>
<div className="text-sm text-muted-foreground truncate">
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
</div>
</div>
</div>
</button>
);
}
```
### 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 (
<div className="flex flex-col space-y-2">
{hasMoreMessages && (
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
Load earlier messages
</button>
)}
{messages.map((message) => {
const isFromMe = message.pubkey === user?.pubkey;
return (
<div
key={message.id}
className={cn(
"flex",
isFromMe ? "justify-end" : "justify-start"
)}
>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{message.error ? (
<span className="text-red-500">🔒 {message.error}</span>
) : (
<p className="whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
{message.isSending && (
<span className="text-xs opacity-50">Sending...</span>
)}
</div>
</div>
);
})}
</div>
);
}
```
## 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 (
<div className="container mx-auto p-4 h-screen">
<DMMessagingInterface />
</div>
);
}
```
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<File | null>(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 (
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Type a message..."
/>
<input
type="file"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/>
{selectedFile && <div>Selected: {selectedFile.name}</div>}
<button type="submit" disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Send'}
</button>
</form>
);
}
```
## 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 (
<button onClick={handleClearCache}>
Clear Message Cache
</button>
);
}
```
## 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';
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
onStatusClick={() => setShowStatus(true)} // optional
className="h-full"
/>
```
**`DMChatArea`** - Message thread and compose area
```tsx
import { DMChatArea } from '@/components/dm/DMChatArea';
<DMChatArea
pubkey={selectedPubkey}
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
className="h-full"
/>
```
**`DMStatusInfo`** - Debug/status panel
```tsx
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
```
### 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<string | null>(null);
return (
<div className="flex h-screen">
{/* Custom sidebar */}
<aside className="w-64 border-r">
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={setSelectedPubkey}
/>
</aside>
{/* Custom main area */}
<main className="flex-1">
{selectedPubkey ? (
<DMChatArea pubkey={selectedPubkey} />
) : (
<div className="flex items-center justify-center h-full">
<p>Select a conversation to start messaging</p>
</div>
)}
</main>
</div>
);
}
```
+3 -4
View File
@@ -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.
-1
View File
@@ -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
+1 -10
View File
@@ -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() {
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<InitialSyncGate>
@@ -210,8 +202,7 @@ export function App() {
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</NWCProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
File diff suppressed because it is too large Load Diff
-495
View File
@@ -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 (
<div className={cn("flex mb-4", isFromCurrentUser ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromCurrentUser
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
{message.error ? (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<p className="text-sm italic opacity-70 cursor-help">🔒 Failed to decrypt</p>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{message.error}</p>
</TooltipContent>
</Tooltip>
) : isFileAttachment ? (
// Kind 15: Use NoteContent to render files/media with imeta tags
<div className="text-sm">
<NoteContent event={messageEvent} className="whitespace-pre-wrap break-words" />
</div>
) : (
// Kind 4 (NIP-04) and Kind 14 (NIP-17 text): Display plain text
<p dir="auto" className="text-sm whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
<div className="flex items-center gap-2 mt-1">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className={cn(
"text-xs opacity-70 cursor-default",
isFromCurrentUser ? "text-primary-foreground" : "text-muted-foreground"
)}>
{formatConversationTime(message.created_at)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{formatFullDateTime(message.created_at)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className={cn(
"flex-shrink-0 opacity-50",
isFromCurrentUser ? "text-primary-foreground" : "text-muted-foreground"
)}>
{message.kind === 4 ? (
<Key className="h-3 w-3" />
) : (
<ShieldCheck className="h-3 w-3" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{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}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isNIP4Message && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<AlertTriangle className="h-3 w-3 text-yellow-600 dark:text-yellow-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Uses outdated NIP-04 encryption</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{message.isSending && (
<Loader2 className="h-3 w-3 animate-spin opacity-70" />
)}
</div>
</div>
</div>
);
});
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 (
<div className="p-4 border-b flex items-center gap-3">
{onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="md:hidden"
>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Avatar shape={avatarShape} className="h-10 w-10">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h2 className="font-semibold truncate">{displayName}</h2>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={pubkey} className="text-xs text-muted-foreground truncate block" />
)}
</div>
</div>
);
};
const EmptyState = ({ isLoading }: { isLoading: boolean }) => {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="text-center text-muted-foreground max-w-sm">
{isLoading ? (
<>
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm">Loading conversations...</p>
<p className="text-xs mt-2">
Fetching encrypted messages from relays
</p>
</>
) : (
<>
<p className="text-sm">Select a conversation to start messaging</p>
<p className="text-xs mt-2">
Your messages are encrypted and stored locally
</p>
</>
)}
</div>
</div>
);
};
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<HTMLTextAreaElement>(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<MessageProtocol>(getDefaultProtocol());
const scrollAreaRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
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 (
<Card className={cn("h-full", className)}>
<EmptyState isLoading={isLoading} />
</Card>
);
}
if (!user) {
return (
<Card className={cn("h-full flex items-center justify-center", className)}>
<div className="text-center text-muted-foreground">
<p className="text-sm">Please log in to view messages</p>
</div>
</Card>
);
}
return (
<Card className={cn("h-full flex flex-col", className)}>
<ChatHeader pubkey={pubkey} onBack={onBack} />
<ScrollArea ref={scrollAreaRef} className="flex-1 p-4">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="text-sm">No messages yet</p>
<p className="text-xs mt-1">Send a message to start the conversation</p>
</div>
</div>
) : (
<div>
{hasMoreMessages && (
<div className="flex justify-center mb-4">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore}
className="text-xs"
>
{isLoadingMore ? (
<>
<Loader2 className="h-3 w-3 animate-spin mr-2" />
Loading...
</>
) : (
'Load Earlier Messages'
)}
</Button>
</div>
)}
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isFromCurrentUser={message.pubkey === user.pubkey}
/>
))}
</div>
)}
</ScrollArea>
<div className="p-4 border-t">
<div className="flex gap-2">
<div className="flex-1 flex flex-col gap-1.5">
<div className="relative">
<Textarea
ref={textareaRef}
value={messageText}
onChange={(e) => 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}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={messageText}
onInsertEmoji={insertAtCursor}
/>
</div>
{/* Toolbar row */}
<div className="flex items-center gap-0.5">
{/* Emoji picker */}
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-1.5 rounded-full transition-colors',
emojiOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Smile className="size-[16px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<EmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
const text = selection.type === 'native' ? selection.emoji : `:${selection.shortcode}:`;
insertEmoji(text);
}}
/>
</PopoverContent>
</Popover>
{/* GIF picker */}
<Popover open={gifOpen} onOpenChange={setGifOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-1.5 rounded-full transition-colors',
gifOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<ImagePlay className="size-[16px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<GifPicker onSelect={(gif) => {
setMessageText((prev) => (prev ? prev + '\n' + gif.url : gif.url));
setGifOpen(false);
}} />
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleSend}
disabled={!messageText.trim() || isSending}
size="icon"
className="h-[44px] w-[90px]"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</Button>
<Select
value={selectedProtocol}
onValueChange={(value) => setSelectedProtocol(value as MessageProtocol)}
disabled={!allowSelection}
>
<SelectTrigger className="h-[32px] w-[90px] text-base md:text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MESSAGE_PROTOCOL.NIP17} className="text-base md:text-xs">
NIP-17
</SelectItem>
<SelectItem value={MESSAGE_PROTOCOL.NIP04} className="text-base md:text-xs">
NIP-04
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</Card>
);
};
-268
View File
@@ -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 (
<button
onClick={onClick}
className={cn(
"w-full text-left p-3 rounded-lg transition-colors hover:bg-accent block overflow-hidden",
isSelected && "bg-accent"
)}
>
<div className="flex items-start gap-3 max-w-full">
{isLoadingProfile ? (
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
) : (
<Avatar shape={avatarShape} className="h-10 w-10 flex-shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{isLoadingProfile ? (
<Skeleton className="h-[1.25rem] w-24" />
) : (
<span className="font-medium text-sm truncate">{displayName}</span>
)}
{hasNIP4Messages && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-500" />
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs max-w-[200px]">Some messages use outdated NIP-04 encryption</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground whitespace-nowrap flex-shrink-0 cursor-default">
{formatConversationTime(lastActivity)}
</span>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs">{formatFullDateTime(lastActivity)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-sm text-muted-foreground truncate">
{lastMessagePreview}
</p>
</div>
</div>
</button>
);
};
const ConversationItem = memo(ConversationItemComponent);
ConversationItem.displayName = 'ConversationItem';
const ConversationListSkeleton = () => {
return (
<div className="space-y-2 p-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-3 p-3">
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
);
};
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 (
<Card className={cn("h-full flex flex-col overflow-hidden", className)}>
{/* Header - always visible */}
<div className="p-4 border-b flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-lg">Messages</h2>
{(loadingPhase === LOADING_PHASES.CACHE ||
loadingPhase === LOADING_PHASES.RELAYS ||
loadingPhase === LOADING_PHASES.SUBSCRIPTIONS) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{loadingPhase === LOADING_PHASES.CACHE && 'Loading from cache...'}
{loadingPhase === LOADING_PHASES.RELAYS && 'Querying relays for new messages...'}
{loadingPhase === LOADING_PHASES.SUBSCRIPTIONS && 'Setting up subscriptions...'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{onStatusClick && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onStatusClick}
aria-label="View messaging status"
>
<Info className="h-4 w-4" />
</Button>
)}
</div>
{/* Tab buttons - always visible */}
<div className="px-2 pt-2 flex-shrink-0">
<div className="grid grid-cols-2 gap-1 bg-muted p-1 rounded-lg">
<button
onClick={() => 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})`}
</button>
<button
onClick={() => 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})`}
</button>
</div>
</div>
{/* Content area - show skeleton during initial load, otherwise show conversations */}
<div className="flex-1 min-h-0 mt-2 overflow-hidden">
{(isLoading || isInitialLoad) ? (
<ConversationListSkeleton />
) : conversations.length === 0 ? (
<div className="flex items-center justify-center h-full text-center text-muted-foreground px-4">
<div>
<p className="text-sm">No conversations yet</p>
<p className="text-xs mt-1">Start a new conversation to get started</p>
</div>
</div>
) : currentConversations.length === 0 ? (
<div className="flex items-center justify-center h-32 text-center text-muted-foreground px-4">
<p className="text-sm">No {activeTab} conversations</p>
</div>
) : (
<ScrollArea className="h-full block">
<div className="block w-full px-2 py-2 space-y-1">
{currentConversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
pubkey={conversation.pubkey}
isSelected={selectedPubkey === conversation.pubkey}
onClick={() => onSelectConversation(conversation.pubkey)}
lastMessage={conversation.lastMessage}
lastActivity={conversation.lastActivity}
hasNIP4Messages={conversation.hasNIP4Messages}
/>
))}
</div>
</ScrollArea>
)}
</div>
</Card>
);
};
@@ -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<string | null>(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 */}
<Dialog open={statusModalOpen} onOpenChange={setStatusModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Messaging Status</DialogTitle>
<DialogDescription>
View loading status, cache info, and connection details
</DialogDescription>
</DialogHeader>
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
</DialogContent>
</Dialog>
<div className={cn("flex gap-4 overflow-hidden", className)}>
{/* Conversation List - Left Sidebar */}
<div className={cn(
"md:w-80 md:flex-shrink-0",
isMobile && !showConversationList && "hidden",
isMobile && showConversationList && "w-full"
)}>
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={handleSelectConversation}
className="h-full"
onStatusClick={() => setStatusModalOpen(true)}
/>
</div>
{/* Chat Area - Right Panel */}
<div className={cn(
"flex-1 md:min-w-0",
isMobile && !showChatArea && "hidden",
isMobile && showChatArea && "w-full"
)}>
<DMChatArea
pubkey={selectedPubkey}
onBack={isMobile ? handleBack : undefined}
className="h-full"
/>
</div>
</div>
</>
);
};
-214
View File
@@ -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<void>;
}
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 (
<div className="space-y-4">
{/* Loading Phase */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<PhaseIcon className={`h-5 w-5 ${phaseInfo.color} ${loadingPhase !== LOADING_PHASES.READY ? 'animate-pulse' : ''}`} />
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{phaseInfo.label}</p>
{isDoingInitialLoad && (
<Badge variant="secondary" className="text-xs">
Initial Load
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{phaseInfo.description}</p>
</div>
</div>
</CardContent>
</Card>
{/* Scan Progress */}
{(scanProgress.nip4 !== null || scanProgress.nip17 !== null) && (
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Scanning Messages</p>
{scanProgress.nip4 !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">NIP-4 (Legacy)</span>
<span className="text-muted-foreground">{scanProgress.nip4.current} events</span>
</div>
<p className="text-xs text-muted-foreground">{scanProgress.nip4.status}</p>
</div>
)}
{scanProgress.nip17 !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">NIP-17 (Private)</span>
<span className="text-muted-foreground">{scanProgress.nip17.current} events</span>
</div>
<p className="text-xs text-muted-foreground">{scanProgress.nip17.status}</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Subscriptions */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Real-time Subscriptions</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">NIP-4 (Legacy DMs)</span>
<Badge variant={subscriptions.isNIP4Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP4Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">NIP-17 (Private DMs)</span>
<Badge variant={subscriptions.isNIP17Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP17Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Cache Info */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Cache Information</p>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Conversations</span>
<span className="font-medium">{conversations.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last NIP-4 sync</span>
<span className="font-medium">{formatTimestamp(lastSync.nip4)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last NIP-17 sync</span>
<span className="font-medium">{formatTimestamp(lastSync.nip17)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
{clearCacheAndRefetch && (
<>
<Separator />
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">Cache Management</p>
<p className="text-xs text-muted-foreground">
Clear all cached messages and refetch from relays. This will force a fresh sync.
</p>
</div>
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="outline"
className="w-full"
>
{isClearing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Clearing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Clear Cache & Refetch
</>
)}
</Button>
</div>
</>
)}
</div>
);
};
-138
View File
@@ -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<string, ParticipantData>;
interface LastSyncData {
nip4: number | null;
nip17: number | null;
}
interface SubscriptionStatus {
isNIP4Connected: boolean;
isNIP17Connected: boolean;
}
interface ScanProgress {
current: number;
status: string;
}
interface ScanProgressState {
nip4: ScanProgress | null;
nip17: ScanProgress | null;
}
interface ConversationSummary {
id: string;
pubkey: string;
lastMessage: DecryptedMessage | null;
lastActivity: number;
hasNIP4Messages: boolean;
hasNIP17Messages: boolean;
isKnown: boolean;
isRequest: boolean;
lastMessageFromUser: boolean;
}
interface 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<void>;
protocolMode: ProtocolMode;
scanProgress: ScanProgressState;
clearCacheAndRefetch: () => Promise<void>;
}
export const DMContext = createContext<DMContextType | null>(null);
-87
View File
@@ -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 (
* <div>
* {hasMoreMessages && (
* <button onClick={loadEarlierMessages}>
* Load Earlier ({totalCount - messages.length} more)
* </button>
* )}
* {messages.map(msg => (
* <div key={msg.id}>{msg.decryptedContent}</div>
* ))}
* </div>
* );
* }
* ```
*
* @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,
};
}
-45
View File
@@ -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 (
* <div>
* {isLoading ? 'Loading...' : conversations.map(c => (
* <div key={c.pubkey}>{c.lastMessage?.decryptedContent}</div>
* ))}
* </div>
* );
* }
* ```
*
* @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;
}
-86
View File
@@ -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;
}
-94
View File
@@ -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<string, StoredParticipant>;
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<void> {
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<MessageStore | undefined> {
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<void> {
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<void> {
try {
const db = await openDatabase();
if (!db) return;
await db.clear(STORE.MESSAGES);
} catch {
// Non-critical.
}
}
-98
View File
@@ -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'
});
}
-27
View File
@@ -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 (
<div className="bg-background">
<div className="container mx-auto p-4 h-screen flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Messages</h1>
</div>
<DMMessagingInterface className="flex-1" />
</div>
</div>
);
};
export default Messages;