delete legacy mkstack dms

This commit is contained in:
sam
2026-04-20 18:10:23 +05:45
parent 85b8e68f52
commit 865a472ef1
11 changed files with 0 additions and 3189 deletions
File diff suppressed because it is too large Load Diff
-495
View File
@@ -1,495 +0,0 @@
import { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useConversationMessages } from '@/hooks/useConversationMessages';
import { useDMContext } from '@/hooks/useDMContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { genUserName } from '@/lib/genUserName';
import { MESSAGE_PROTOCOL, PROTOCOL_MODE, type MessageProtocol } from '@/lib/dmConstants';
import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowLeft, Send, Loader2, AlertTriangle, Key, ShieldCheck, ImagePlay, Smile } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import { GifPicker } from '@/components/GifPicker';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInsertText } from '@/hooks/useInsertText';
import type { NostrEvent } from '@nostrify/nostrify';
interface DMChatAreaProps {
pubkey: string | null;
onBack?: () => void;
className?: string;
}
const MessageBubble = memo(({
message,
isFromCurrentUser
}: {
message: {
id: string;
pubkey: string;
kind: number;
tags: string[][];
decryptedContent?: string;
decryptedEvent?: NostrEvent;
error?: string;
created_at: number;
isSending?: boolean;
};
isFromCurrentUser: boolean;
}) => {
// For NIP-17, use inner message kind (14/15); for NIP-04, use message kind (4)
const actualKind = message.decryptedEvent?.kind || message.kind;
const isNIP4Message = message.kind === 4;
const isFileAttachment = actualKind === 15; // Kind 15 = files/attachments
// Create a NostrEvent object for NoteContent (only used for kind 15)
// For NIP-17 file attachments, use the decryptedEvent which has the actual tags
const messageEvent: NostrEvent = message.decryptedEvent || {
id: message.id,
pubkey: message.pubkey,
created_at: message.created_at,
kind: message.kind,
tags: message.tags,
content: message.decryptedContent || '',
sig: '', // Not needed for display
};
return (
<div className={cn("flex mb-4", isFromCurrentUser ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[70%] rounded-lg px-4 py-2",
isFromCurrentUser
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
{message.error ? (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<p className="text-sm italic opacity-70 cursor-help">🔒 Failed to decrypt</p>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{message.error}</p>
</TooltipContent>
</Tooltip>
) : isFileAttachment ? (
// Kind 15: Use NoteContent to render files/media with imeta tags
<div className="text-sm">
<NoteContent event={messageEvent} className="whitespace-pre-wrap break-words" />
</div>
) : (
// Kind 4 (NIP-04) and Kind 14 (NIP-17 text): Display plain text
<p className="text-sm whitespace-pre-wrap break-words">
{message.decryptedContent}
</p>
)}
<div className="flex items-center gap-2 mt-1">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className={cn(
"text-xs opacity-70 cursor-default",
isFromCurrentUser ? "text-primary-foreground" : "text-muted-foreground"
)}>
{formatConversationTime(message.created_at)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{formatFullDateTime(message.created_at)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className={cn(
"flex-shrink-0 opacity-50",
isFromCurrentUser ? "text-primary-foreground" : "text-muted-foreground"
)}>
{message.kind === 4 ? (
<Key className="h-3 w-3" />
) : (
<ShieldCheck className="h-3 w-3" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{message.kind === 4 && "NIP-04 Kind 4 (Legacy DM)"}
{message.kind === 14 && "NIP-17 Kind 14 (Private Message)"}
{message.kind === 15 && "NIP-17 Kind 15 (Media)"}
{message.kind !== 4 && message.kind !== 14 && message.kind !== 15 && `Kind ${message.kind}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isNIP4Message && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<AlertTriangle className="h-3 w-3 text-yellow-600 dark:text-yellow-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Uses outdated NIP-04 encryption</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{message.isSending && (
<Loader2 className="h-3 w-3 animate-spin opacity-70" />
)}
</div>
</div>
</div>
);
});
MessageBubble.displayName = 'MessageBubble';
const ChatHeader = ({ pubkey, onBack }: { pubkey: string; onBack?: () => void }) => {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(pubkey);
const avatarUrl = metadata?.picture;
const initials = displayName.slice(0, 2).toUpperCase();
return (
<div className="p-4 border-b flex items-center gap-3">
{onBack && (
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="md:hidden"
>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Avatar shape={avatarShape} className="h-10 w-10">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h2 className="font-semibold truncate">{displayName}</h2>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={pubkey} className="text-xs text-muted-foreground truncate block" />
)}
</div>
</div>
);
};
const EmptyState = ({ isLoading }: { isLoading: boolean }) => {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="text-center text-muted-foreground max-w-sm">
{isLoading ? (
<>
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm">Loading conversations...</p>
<p className="text-xs mt-2">
Fetching encrypted messages from relays
</p>
</>
) : (
<>
<p className="text-sm">Select a conversation to start messaging</p>
<p className="text-xs mt-2">
Your messages are encrypted and stored locally
</p>
</>
)}
</div>
</div>
);
};
export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
const { user } = useCurrentUser();
const { sendMessage, protocolMode, isLoading } = useDMContext();
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(pubkey || '');
const [messageText, setMessageText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [gifOpen, setGifOpen] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const { feedSettings } = useFeedSettings();
const { emojis: allCustomEmojis } = useCustomEmojis();
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { insertAtCursor, insertEmoji } = useInsertText(textareaRef, messageText, setMessageText);
// Determine default protocol based on mode
const getDefaultProtocol = () => {
if (protocolMode === PROTOCOL_MODE.NIP04_ONLY) return MESSAGE_PROTOCOL.NIP04;
if (protocolMode === PROTOCOL_MODE.NIP17_ONLY) return MESSAGE_PROTOCOL.NIP17;
if (protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17) return MESSAGE_PROTOCOL.NIP17;
// Fallback to NIP-17 for any unexpected mode
return MESSAGE_PROTOCOL.NIP17;
};
const [selectedProtocol, setSelectedProtocol] = useState<MessageProtocol>(getDefaultProtocol());
const scrollAreaRef = useRef<HTMLDivElement>(null);
// Determine if selection is allowed
const allowSelection = protocolMode === PROTOCOL_MODE.NIP04_OR_NIP17;
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (scrollAreaRef.current) {
const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
}, [messages.length]);
const handleSend = useCallback(async () => {
if (!messageText.trim() || !pubkey || !user) return;
setIsSending(true);
try {
await sendMessage({
recipientPubkey: pubkey,
content: messageText.trim(),
protocol: selectedProtocol,
});
setMessageText('');
} catch (error) {
console.error('Failed to send message:', error);
} finally {
setIsSending(false);
}
}, [messageText, pubkey, user, sendMessage, selectedProtocol]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
const handleLoadMore = useCallback(async () => {
if (!scrollAreaRef.current || isLoadingMore) return;
const scrollContainer = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (!scrollContainer) return;
// Store current scroll position and height
const previousScrollHeight = scrollContainer.scrollHeight;
const previousScrollTop = scrollContainer.scrollTop;
setIsLoadingMore(true);
// Load more messages
loadEarlierMessages();
// Wait for DOM to update, then restore relative scroll position
setTimeout(() => {
if (scrollContainer) {
const newScrollHeight = scrollContainer.scrollHeight;
const heightDifference = newScrollHeight - previousScrollHeight;
scrollContainer.scrollTop = previousScrollTop + heightDifference;
}
setIsLoadingMore(false);
}, 0);
}, [loadEarlierMessages, isLoadingMore]);
if (!pubkey) {
return (
<Card className={cn("h-full", className)}>
<EmptyState isLoading={isLoading} />
</Card>
);
}
if (!user) {
return (
<Card className={cn("h-full flex items-center justify-center", className)}>
<div className="text-center text-muted-foreground">
<p className="text-sm">Please log in to view messages</p>
</div>
</Card>
);
}
return (
<Card className={cn("h-full flex flex-col", className)}>
<ChatHeader pubkey={pubkey} onBack={onBack} />
<ScrollArea ref={scrollAreaRef} className="flex-1 p-4">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<p className="text-sm">No messages yet</p>
<p className="text-xs mt-1">Send a message to start the conversation</p>
</div>
</div>
) : (
<div>
{hasMoreMessages && (
<div className="flex justify-center mb-4">
<Button
variant="outline"
size="sm"
onClick={handleLoadMore}
disabled={isLoadingMore}
className="text-xs"
>
{isLoadingMore ? (
<>
<Loader2 className="h-3 w-3 animate-spin mr-2" />
Loading...
</>
) : (
'Load Earlier Messages'
)}
</Button>
</div>
)}
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isFromCurrentUser={message.pubkey === user.pubkey}
/>
))}
</div>
)}
</ScrollArea>
<div className="p-4 border-t">
<div className="flex gap-2">
<div className="flex-1 flex flex-col gap-1.5">
<div className="relative">
<Textarea
ref={textareaRef}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
className="min-h-[80px] resize-none"
disabled={isSending}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={messageText}
onInsertEmoji={insertAtCursor}
/>
</div>
{/* Toolbar row */}
<div className="flex items-center gap-0.5">
{/* Emoji picker */}
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-1.5 rounded-full transition-colors',
emojiOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Smile className="size-[16px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<EmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
const text = selection.type === 'native' ? selection.emoji : `:${selection.shortcode}:`;
insertEmoji(text);
}}
/>
</PopoverContent>
</Popover>
{/* GIF picker */}
<Popover open={gifOpen} onOpenChange={setGifOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-1.5 rounded-full transition-colors',
gifOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<ImagePlay className="size-[16px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<GifPicker onSelect={(gif) => {
setMessageText((prev) => (prev ? prev + '\n' + gif.url : gif.url));
setGifOpen(false);
}} />
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleSend}
disabled={!messageText.trim() || isSending}
size="icon"
className="h-[44px] w-[90px]"
>
{isSending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Send className="h-5 w-5" />
)}
</Button>
<Select
value={selectedProtocol}
onValueChange={(value) => setSelectedProtocol(value as MessageProtocol)}
disabled={!allowSelection}
>
<SelectTrigger className="h-[32px] w-[90px] text-base md:text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MESSAGE_PROTOCOL.NIP17} className="text-base md:text-xs">
NIP-17
</SelectItem>
<SelectItem value={MESSAGE_PROTOCOL.NIP04} className="text-base md:text-xs">
NIP-04
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</Card>
);
};
-268
View File
@@ -1,268 +0,0 @@
import { useMemo, useState, memo } from 'react';
import { AlertTriangle, Info, Loader2 } from 'lucide-react';
import { useDMContext } from '@/hooks/useDMContext';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { formatConversationTime, formatFullDateTime } from '@/lib/dmUtils';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { LOADING_PHASES } from '@/lib/dmConstants';
interface DMConversationListProps {
selectedPubkey: string | null;
onSelectConversation: (pubkey: string) => void;
className?: string;
onStatusClick?: () => void;
}
interface ConversationItemProps {
pubkey: string;
isSelected: boolean;
onClick: () => void;
lastMessage: { decryptedContent?: string; error?: string } | null;
lastActivity: number;
hasNIP4Messages: boolean;
}
const ConversationItemComponent = ({
pubkey,
isSelected,
onClick,
lastMessage,
lastActivity,
hasNIP4Messages
}: ConversationItemProps) => {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(pubkey);
const avatarUrl = metadata?.picture;
const initials = displayName.slice(0, 2).toUpperCase();
const lastMessagePreview = lastMessage?.error
? '🔒 Encrypted message'
: lastMessage?.decryptedContent || 'No messages yet';
// Show skeleton only for name/avatar while loading (we already have message data)
const isLoadingProfile = author.isLoading && !metadata;
return (
<button
onClick={onClick}
className={cn(
"w-full text-left p-3 rounded-lg transition-colors hover:bg-accent block overflow-hidden",
isSelected && "bg-accent"
)}
>
<div className="flex items-start gap-3 max-w-full">
{isLoadingProfile ? (
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
) : (
<Avatar shape={avatarShape} className="h-10 w-10 flex-shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{isLoadingProfile ? (
<Skeleton className="h-[1.25rem] w-24" />
) : (
<span className="font-medium text-sm truncate">{displayName}</span>
)}
{hasNIP4Messages && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">
<AlertTriangle className="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-500" />
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs max-w-[200px]">Some messages use outdated NIP-04 encryption</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground whitespace-nowrap flex-shrink-0 cursor-default">
{formatConversationTime(lastActivity)}
</span>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-xs">{formatFullDateTime(lastActivity)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<p className="text-sm text-muted-foreground truncate">
{lastMessagePreview}
</p>
</div>
</div>
</button>
);
};
const ConversationItem = memo(ConversationItemComponent);
ConversationItem.displayName = 'ConversationItem';
const ConversationListSkeleton = () => {
return (
<div className="space-y-2 p-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-3 p-3">
<Skeleton className="h-10 w-10 rounded-full flex-shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
);
};
export const DMConversationList = ({
selectedPubkey,
onSelectConversation,
className,
onStatusClick
}: DMConversationListProps) => {
const { conversations, isLoading, loadingPhase } = useDMContext();
const [activeTab, setActiveTab] = useState<'known' | 'requests'>('known');
// Filter conversations by type
const { knownConversations, requestConversations } = useMemo(() => {
return {
knownConversations: conversations.filter(c => c.isKnown),
requestConversations: conversations.filter(c => c.isRequest),
};
}, [conversations]);
// Get the current list based on active tab
const currentConversations = activeTab === 'known' ? knownConversations : requestConversations;
// Show skeleton during initial load (cache + relays) if we have no conversations yet
const isInitialLoad = (loadingPhase === LOADING_PHASES.CACHE || loadingPhase === LOADING_PHASES.RELAYS) && conversations.length === 0;
return (
<Card className={cn("h-full flex flex-col overflow-hidden", className)}>
{/* Header - always visible */}
<div className="p-4 border-b flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-lg">Messages</h2>
{(loadingPhase === LOADING_PHASES.CACHE ||
loadingPhase === LOADING_PHASES.RELAYS ||
loadingPhase === LOADING_PHASES.SUBSCRIPTIONS) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{loadingPhase === LOADING_PHASES.CACHE && 'Loading from cache...'}
{loadingPhase === LOADING_PHASES.RELAYS && 'Querying relays for new messages...'}
{loadingPhase === LOADING_PHASES.SUBSCRIPTIONS && 'Setting up subscriptions...'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{onStatusClick && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onStatusClick}
aria-label="View messaging status"
>
<Info className="h-4 w-4" />
</Button>
)}
</div>
{/* Tab buttons - always visible */}
<div className="px-2 pt-2 flex-shrink-0">
<div className="grid grid-cols-2 gap-1 bg-muted p-1 rounded-lg">
<button
onClick={() => setActiveTab('known')}
className={cn(
"text-xs py-2 px-3 rounded-md transition-colors",
activeTab === 'known'
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
Active {knownConversations.length > 0 && `(${knownConversations.length})`}
</button>
<button
onClick={() => setActiveTab('requests')}
className={cn(
"text-xs py-2 px-3 rounded-md transition-colors",
activeTab === 'requests'
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
Requests {requestConversations.length > 0 && `(${requestConversations.length})`}
</button>
</div>
</div>
{/* Content area - show skeleton during initial load, otherwise show conversations */}
<div className="flex-1 min-h-0 mt-2 overflow-hidden">
{(isLoading || isInitialLoad) ? (
<ConversationListSkeleton />
) : conversations.length === 0 ? (
<div className="flex items-center justify-center h-full text-center text-muted-foreground px-4">
<div>
<p className="text-sm">No conversations yet</p>
<p className="text-xs mt-1">Start a new conversation to get started</p>
</div>
</div>
) : currentConversations.length === 0 ? (
<div className="flex items-center justify-center h-32 text-center text-muted-foreground px-4">
<p className="text-sm">No {activeTab} conversations</p>
</div>
) : (
<ScrollArea className="h-full block">
<div className="block w-full px-2 py-2 space-y-1">
{currentConversations.map((conversation) => (
<ConversationItem
key={conversation.pubkey}
pubkey={conversation.pubkey}
isSelected={selectedPubkey === conversation.pubkey}
onClick={() => onSelectConversation(conversation.pubkey)}
lastMessage={conversation.lastMessage}
lastActivity={conversation.lastActivity}
hasNIP4Messages={conversation.hasNIP4Messages}
/>
))}
</div>
</ScrollArea>
)}
</div>
</Card>
);
};
@@ -1,84 +0,0 @@
import { useState, useCallback } from 'react';
import { DMConversationList } from '@/components/dm/DMConversationList';
import { DMChatArea } from '@/components/dm/DMChatArea';
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
import { useDMContext } from '@/hooks/useDMContext';
import { useIsMobile } from '@/hooks/useIsMobile';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface DMMessagingInterfaceProps {
className?: string;
}
export const DMMessagingInterface = ({ className }: DMMessagingInterfaceProps) => {
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
const [statusModalOpen, setStatusModalOpen] = useState(false);
const isMobile = useIsMobile();
const { clearCacheAndRefetch } = useDMContext();
// On mobile, show only one panel at a time
const showConversationList = !isMobile || !selectedPubkey;
const showChatArea = !isMobile || selectedPubkey;
const handleSelectConversation = useCallback((pubkey: string) => {
setSelectedPubkey(pubkey);
}, []);
const handleBack = useCallback(() => {
setSelectedPubkey(null);
}, []);
return (
<>
{/* Status Modal */}
<Dialog open={statusModalOpen} onOpenChange={setStatusModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Messaging Status</DialogTitle>
<DialogDescription>
View loading status, cache info, and connection details
</DialogDescription>
</DialogHeader>
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
</DialogContent>
</Dialog>
<div className={cn("flex gap-4 overflow-hidden", className)}>
{/* Conversation List - Left Sidebar */}
<div className={cn(
"md:w-80 md:flex-shrink-0",
isMobile && !showConversationList && "hidden",
isMobile && showConversationList && "w-full"
)}>
<DMConversationList
selectedPubkey={selectedPubkey}
onSelectConversation={handleSelectConversation}
className="h-full"
onStatusClick={() => setStatusModalOpen(true)}
/>
</div>
{/* Chat Area - Right Panel */}
<div className={cn(
"flex-1 md:min-w-0",
isMobile && !showChatArea && "hidden",
isMobile && showChatArea && "w-full"
)}>
<DMChatArea
pubkey={selectedPubkey}
onBack={isMobile ? handleBack : undefined}
className="h-full"
/>
</div>
</div>
</>
);
};
-214
View File
@@ -1,214 +0,0 @@
import { useState } from 'react';
import { RefreshCw, Database, Wifi, CheckCircle2, Loader2 } from 'lucide-react';
import { useDMContext } from '@/hooks/useDMContext';
import { LOADING_PHASES } from '@/lib/dmConstants';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/hooks/useToast';
interface DMStatusInfoProps {
clearCacheAndRefetch?: () => Promise<void>;
}
export const DMStatusInfo = ({ clearCacheAndRefetch }: DMStatusInfoProps) => {
const [isClearing, setIsClearing] = useState(false);
const { toast } = useToast();
const {
loadingPhase,
subscriptions,
scanProgress,
isDoingInitialLoad,
lastSync,
conversations,
} = useDMContext();
const handleClearCache = async () => {
if (!clearCacheAndRefetch) return;
setIsClearing(true);
try {
await clearCacheAndRefetch();
toast({
title: 'Cache cleared',
description: 'Refetching messages from relays...',
});
setIsClearing(false);
} catch (error) {
console.error('Error clearing cache:', error);
toast({
title: 'Error',
description: 'Failed to clear cache. Please try again.',
variant: 'destructive',
});
setIsClearing(false);
}
};
const getLoadingPhaseInfo = () => {
switch (loadingPhase) {
case LOADING_PHASES.IDLE:
return { label: 'Idle', description: 'Not yet initialized', icon: Loader2, color: 'text-muted-foreground' };
case LOADING_PHASES.CACHE:
return { label: 'Loading from cache', description: 'Reading cached messages...', icon: Database, color: 'text-blue-500' };
case LOADING_PHASES.RELAYS:
return { label: 'Loading from relays', description: 'Fetching messages from Nostr relays...', icon: Wifi, color: 'text-yellow-500' };
case LOADING_PHASES.SUBSCRIPTIONS:
return { label: 'Connecting subscriptions', description: 'Setting up real-time message sync...', icon: RefreshCw, color: 'text-orange-500' };
case LOADING_PHASES.READY:
return { label: 'Ready', description: 'All systems operational', icon: CheckCircle2, color: 'text-green-500' };
default:
return { label: 'Unknown', description: 'Status unknown', icon: Loader2, color: 'text-muted-foreground' };
}
};
const phaseInfo = getLoadingPhaseInfo();
const PhaseIcon = phaseInfo.icon;
const formatTimestamp = (timestamp: number | null) => {
if (!timestamp) return 'Never';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
return date.toLocaleDateString();
};
return (
<div className="space-y-4">
{/* Loading Phase */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<PhaseIcon className={`h-5 w-5 ${phaseInfo.color} ${loadingPhase !== LOADING_PHASES.READY ? 'animate-pulse' : ''}`} />
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{phaseInfo.label}</p>
{isDoingInitialLoad && (
<Badge variant="secondary" className="text-xs">
Initial Load
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">{phaseInfo.description}</p>
</div>
</div>
</CardContent>
</Card>
{/* Scan Progress */}
{(scanProgress.nip4 !== null || scanProgress.nip17 !== null) && (
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Scanning Messages</p>
{scanProgress.nip4 !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">NIP-4 (Legacy)</span>
<span className="text-muted-foreground">{scanProgress.nip4.current} events</span>
</div>
<p className="text-xs text-muted-foreground">{scanProgress.nip4.status}</p>
</div>
)}
{scanProgress.nip17 !== null && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">NIP-17 (Private)</span>
<span className="text-muted-foreground">{scanProgress.nip17.current} events</span>
</div>
<p className="text-xs text-muted-foreground">{scanProgress.nip17.status}</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Subscriptions */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Real-time Subscriptions</p>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">NIP-4 (Legacy DMs)</span>
<Badge variant={subscriptions.isNIP4Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP4Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">NIP-17 (Private DMs)</span>
<Badge variant={subscriptions.isNIP17Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP17Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Cache Info */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<p className="text-sm font-medium">Cache Information</p>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Conversations</span>
<span className="font-medium">{conversations.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last NIP-4 sync</span>
<span className="font-medium">{formatTimestamp(lastSync.nip4)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Last NIP-17 sync</span>
<span className="font-medium">{formatTimestamp(lastSync.nip17)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
{clearCacheAndRefetch && (
<>
<Separator />
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">Cache Management</p>
<p className="text-xs text-muted-foreground">
Clear all cached messages and refetch from relays. This will force a fresh sync.
</p>
</div>
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="outline"
className="w-full"
>
{isClearing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Clearing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Clear Cache & Refetch
</>
)}
</Button>
</div>
</>
)}
</div>
);
};
-138
View File
@@ -1,138 +0,0 @@
import { createContext } from 'react';
import { type LoadingPhase, type ProtocolMode } from '@/lib/dmConstants';
import { type NostrEvent } from '@nostrify/nostrify';
import type { MessageProtocol } from '@/lib/dmConstants';
// ============================================================================
// DM Types and Constants
// ============================================================================
interface ParticipantData {
messages: DecryptedMessage[];
lastActivity: number;
lastMessage: DecryptedMessage | null;
hasNIP4: boolean;
hasNIP17: boolean;
}
type MessagesState = Map<string, ParticipantData>;
interface LastSyncData {
nip4: number | null;
nip17: number | null;
}
interface SubscriptionStatus {
isNIP4Connected: boolean;
isNIP17Connected: boolean;
}
interface ScanProgress {
current: number;
status: string;
}
interface ScanProgressState {
nip4: ScanProgress | null;
nip17: ScanProgress | null;
}
interface ConversationSummary {
id: string;
pubkey: string;
lastMessage: DecryptedMessage | null;
lastActivity: number;
hasNIP4Messages: boolean;
hasNIP17Messages: boolean;
isKnown: boolean;
isRequest: boolean;
lastMessageFromUser: boolean;
}
interface DecryptedMessage extends NostrEvent {
decryptedContent?: string;
error?: string;
isSending?: boolean;
clientFirstSeen?: number;
decryptedEvent?: NostrEvent; // For NIP-17: the inner kind 14/15 event
originalGiftWrapId?: string; // Store gift wrap ID for NIP-17 deduplication
}
/**
* File attachment for direct messages (NIP-92 compatible).
*
* All fields are required. Use with `useUploadFile` hook to upload files
* and generate the proper tags format.
*
* @example
* ```tsx
* import { useUploadFile } from '@/hooks/useUploadFile';
* import type { FileAttachment } from '@/contexts/DMContext';
*
* const { mutateAsync: uploadFile } = useUploadFile();
*
* const tags = await uploadFile(file);
* const attachment: FileAttachment = {
* url: tags[0][1],
* mimeType: file.type,
* size: file.size,
* name: file.name,
* tags: tags
* };
*
* await sendMessage({
* recipientPubkey: 'hex-pubkey',
* content: 'Check out this file!',
* attachments: [attachment]
* });
* ```
*
* @property url - Blossom server URL where file is hosted
* @property mimeType - MIME type of the file (e.g., 'image/png')
* @property size - File size in bytes
* @property name - Original filename
* @property tags - NIP-94 file metadata tags (includes hashes)
*/
export interface FileAttachment {
url: string;
mimeType: string;
size: number;
name: string;
tags: string[][];
}
/**
* Direct Messaging context interface providing access to all DM functionality.
*
* @property messages - Raw message state (Map of pubkey -> participant data)
* @property isLoading - True during initial load phases
* @property loadingPhase - Current loading phase (CACHE, RELAYS, SUBSCRIPTIONS, READY, IDLE)
* @property isDoingInitialLoad - True only during cache/relay loading (not subscriptions)
* @property lastSync - Unix timestamps of last successful sync for each protocol
* @property subscriptions - Connection status for real-time message subscriptions
* @property conversations - Array of conversation summaries sorted by last activity
* @property sendMessage - Send an encrypted direct message (NIP-04 or NIP-17)
* @property protocolMode - Current protocol mode (NIP04_ONLY, NIP17_ONLY, or BOTH)
* @property scanProgress - Progress info for large message history scans
* @property clearCacheAndRefetch - Clear IndexedDB cache and reload all messages from relays
*/
export interface DMContextType {
messages: MessagesState;
isLoading: boolean;
loadingPhase: LoadingPhase;
isDoingInitialLoad: boolean;
lastSync: LastSyncData;
subscriptions: SubscriptionStatus;
conversations: ConversationSummary[];
sendMessage: (params: {
recipientPubkey: string;
content: string;
protocol?: MessageProtocol;
attachments?: FileAttachment[];
}) => Promise<void>;
protocolMode: ProtocolMode;
scanProgress: ScanProgressState;
clearCacheAndRefetch: () => Promise<void>;
}
export const DMContext = createContext<DMContextType | null>(null);
-87
View File
@@ -1,87 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDMContext } from "@/hooks/useDMContext";
const MESSAGES_PER_PAGE = 25;
/**
* Hook to access paginated messages for a specific conversation.
*
* Returns the most recent messages (default 25) with the ability to load earlier messages.
* Automatically resets to default page size when switching conversations.
*
* @example
* ```tsx
* import { useConversationMessages } from '@/contexts/DMContext';
*
* function MessageThread({ recipientPubkey }: { recipientPubkey: string }) {
* const {
* messages,
* hasMoreMessages,
* loadEarlierMessages,
* totalCount
* } = useConversationMessages(recipientPubkey);
*
* return (
* <div>
* {hasMoreMessages && (
* <button onClick={loadEarlierMessages}>
* Load Earlier ({totalCount - messages.length} more)
* </button>
* )}
* {messages.map(msg => (
* <div key={msg.id}>{msg.decryptedContent}</div>
* ))}
* </div>
* );
* }
* ```
*
* @param conversationId - The pubkey of the conversation participant
* @returns Paginated message data with loading function
*/
export function useConversationMessages(conversationId: string) {
const { messages: allMessages } = useDMContext();
const [visibleCount, setVisibleCount] = useState(MESSAGES_PER_PAGE);
const result = useMemo(() => {
const conversationData = allMessages.get(conversationId);
if (!conversationData) {
return {
messages: [],
hasMoreMessages: false,
totalCount: 0,
lastMessage: null,
lastActivity: 0,
};
}
const totalMessages = conversationData.messages.length;
const hasMore = totalMessages > visibleCount;
// Return the most recent N messages (slice from the end)
const visibleMessages = conversationData.messages.slice(-visibleCount);
return {
messages: visibleMessages,
hasMoreMessages: hasMore,
totalCount: totalMessages,
lastMessage: conversationData.lastMessage,
lastActivity: conversationData.lastActivity,
};
}, [allMessages, conversationId, visibleCount]);
const loadEarlierMessages = useCallback(() => {
setVisibleCount(prev => prev + MESSAGES_PER_PAGE);
}, []);
// Reset visible count when conversation changes
useEffect(() => {
setVisibleCount(MESSAGES_PER_PAGE);
}, [conversationId]);
return {
...result,
loadEarlierMessages,
};
}
-45
View File
@@ -1,45 +0,0 @@
import { useContext } from "react";
import { DMContext, DMContextType } from "@/contexts/DMContext";
/**
* Hook to access the direct messaging system.
*
* Provides access to conversations, message sending, loading states, and cache management.
* Must be used within a DMProvider.
*
* @example
* ```tsx
* import { useDMContext } from '@/hooks/useDMContext';
* import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
*
* function MyComponent() {
* const { conversations, sendMessage, isLoading } = useDMContext();
*
* // Send a message
* await sendMessage({
* recipientPubkey: 'hex-pubkey',
* content: 'Hello!',
* protocol: MESSAGE_PROTOCOL.NIP17
* });
*
* // Display conversations
* return (
* <div>
* {isLoading ? 'Loading...' : conversations.map(c => (
* <div key={c.pubkey}>{c.lastMessage?.decryptedContent}</div>
* ))}
* </div>
* );
* }
* ```
*
* @returns DMContextType - The direct messaging context
* @throws Error if used outside DMProvider
*/
export function useDMContext(): DMContextType {
const context = useContext(DMContext);
if (!context) {
throw new Error('useDMContext must be used within DMProvider');
}
return context;
}
-86
View File
@@ -1,86 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
// ============================================================================
// Message Protocol Types
// ============================================================================
export const MESSAGE_PROTOCOL = {
NIP04: 'nip04',
NIP17: 'nip17',
UNKNOWN: 'unknown',
} as const;
export type MessageProtocol = typeof MESSAGE_PROTOCOL[keyof typeof MESSAGE_PROTOCOL];
// ============================================================================
// Protocol Mode (for user selection)
// ============================================================================
export const PROTOCOL_MODE = {
NIP04_ONLY: 'nip04_only',
NIP17_ONLY: 'nip17_only',
NIP04_OR_NIP17: 'nip04_or_nip17',
} as const;
export type ProtocolMode = typeof PROTOCOL_MODE[keyof typeof PROTOCOL_MODE];
// ============================================================================
// Loading Phases
// ============================================================================
export const LOADING_PHASES = {
IDLE: 'idle',
CACHE: 'cache',
RELAYS: 'relays',
SUBSCRIPTIONS: 'subscriptions',
READY: 'ready',
} as const;
export type LoadingPhase = typeof LOADING_PHASES[keyof typeof LOADING_PHASES];
// ============================================================================
// Protocol Configuration
// ============================================================================
export const PROTOCOL_CONFIG = {
[MESSAGE_PROTOCOL.NIP04]: {
label: 'NIP-04',
description: 'Legacy DMs',
kind: 4,
},
[MESSAGE_PROTOCOL.NIP17]: {
label: 'NIP-17',
description: 'Private DMs',
kind: 1059,
},
[MESSAGE_PROTOCOL.UNKNOWN]: {
label: 'Unknown',
description: 'Unknown protocol',
kind: 0,
},
} as const;
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get the message protocol from an event kind
*/
export function getMessageProtocol(event: NostrEvent): MessageProtocol {
switch (event.kind) {
case 4:
return MESSAGE_PROTOCOL.NIP04;
case 1059:
return MESSAGE_PROTOCOL.NIP17;
default:
return MESSAGE_PROTOCOL.UNKNOWN;
}
}
/**
* Check if a protocol is valid for sending messages
*/
export function isValidSendProtocol(protocol: MessageProtocol): boolean {
return protocol === MESSAGE_PROTOCOL.NIP04 || protocol === MESSAGE_PROTOCOL.NIP17;
}
-94
View File
@@ -1,94 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { openDatabase, STORE } from '@/lib/db';
// ============================================================================
// DM Message IndexedDB Store
// ============================================================================
interface StoredParticipant {
messages: NostrEvent[];
lastActivity: number;
hasNIP4: boolean;
hasNIP17: boolean;
}
export interface MessageStore {
participants: Record<string, StoredParticipant>;
lastSync: {
nip4: number | null;
nip17: number | null;
};
}
// ============================================================================
// Database Operations
// ============================================================================
/**
* Write messages to IndexedDB for a specific user.
* Messages are stored in their original encrypted form (kind 4 or kind 13).
* Silently skipped when IndexedDB is unavailable.
*/
export async function writeMessagesToDB(
userPubkey: string,
messageStore: MessageStore
): Promise<void> {
try {
const db = await openDatabase();
if (!db) return; // IndexedDB unavailable — skip persistence.
// Store messages in their original encrypted form (no NIP-44 wrapper needed)
// Each message content is already encrypted by the sender
await db.put(STORE.MESSAGES, messageStore, userPubkey);
} catch {
// Write failure is non-critical — DMs still work in-memory.
}
}
/**
* Read messages from IndexedDB for a specific user.
* Messages are stored in their original encrypted form (kind 4 or kind 13).
* Returns `undefined` when IndexedDB is unavailable.
*/
export async function readMessagesFromDB(
userPubkey: string
): Promise<MessageStore | undefined> {
try {
const db = await openDatabase();
if (!db) return undefined; // IndexedDB unavailable.
const data = await db.get(STORE.MESSAGES, userPubkey);
if (!data) return undefined;
return data as MessageStore;
} catch {
// Read failure — return undefined so the caller proceeds without cache.
return undefined;
}
}
/**
* Delete messages from IndexedDB for a specific user.
* Silently skipped when IndexedDB is unavailable.
*/
export async function deleteMessagesFromDB(userPubkey: string): Promise<void> {
try {
const db = await openDatabase();
if (!db) return;
await db.delete(STORE.MESSAGES, userPubkey);
} catch {
// Non-critical.
}
}
/**
* Clear all messages from IndexedDB.
* Silently skipped when IndexedDB is unavailable.
*/
export async function clearAllMessages(): Promise<void> {
try {
const db = await openDatabase();
if (!db) return;
await db.clear(STORE.MESSAGES);
} catch {
// Non-critical.
}
}
-98
View File
@@ -1,98 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Validate that an event is a proper DM event
*/
export function validateDMEvent(event: NostrEvent): boolean {
// Must be kind 4 (NIP-04 DM)
if (event.kind !== 4) return false;
// Must have a 'p' tag
const hasRecipient = event.tags?.some(([name]) => name === 'p');
if (!hasRecipient) return false;
// Must have content (even if encrypted)
if (!event.content) return false;
return true;
}
/**
* Get the recipient pubkey from a DM event
*/
export function getRecipientPubkey(event: NostrEvent): string | undefined {
return event.tags?.find(([name]) => name === 'p')?.[1];
}
/**
* Get the conversation partner pubkey from a DM event
* (the other person in the conversation, not the current user)
*/
export function getConversationPartner(event: NostrEvent, userPubkey: string): string | undefined {
const isFromUser = event.pubkey === userPubkey;
if (isFromUser) {
// If we sent it, the partner is the recipient
return getRecipientPubkey(event);
} else {
// If they sent it, the partner is the author
return event.pubkey;
}
}
/**
* Format timestamp for display (matches Signal/WhatsApp/Telegram pattern)
* Today: Show time (e.g., "2:45 PM")
* Yesterday: "Yesterday"
* This week: Day name (e.g., "Mon")
* This year: Month and day (e.g., "Jan 15")
* Older: Full date (e.g., "Jan 15, 2024")
*/
export function formatConversationTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
// Start of today (midnight)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Start of yesterday
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
// Start of this week (assuming week starts on Sunday, adjust if needed)
const weekStart = new Date(todayStart);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
if (date >= todayStart) {
// Today: Show time (e.g., "2:45 PM")
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
} else if (date >= yesterdayStart) {
// Yesterday
return 'Yesterday';
} else if (date >= weekStart) {
// This week: Show day name (e.g., "Monday")
return date.toLocaleDateString(undefined, { weekday: 'short' });
} else if (date.getFullYear() === now.getFullYear()) {
// This year: Show month and day (e.g., "Jan 15")
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} else {
// Older: Show full date (e.g., "Jan 15, 2024")
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
}
/**
* Format timestamp as full date and time for tooltips
* e.g., "Mon, Jan 15, 2024, 2:45 PM"
*/
export function formatFullDateTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}