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:
@@ -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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user