delete legacy mkstack dms
This commit is contained in:
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 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'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user