looood / src /components /chat-window.tsx
looda3131's picture
Clean push without any binary history
cc276cc
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from 'react';
import { useAuth } from '@/contexts/auth-context';
import { useGroups } from '@/contexts/groups-context';
import { useSettings } from '@/contexts/settings-context';
import { useChat } from '@/hooks/use-chat';
import { Message, ChatRecipient, User, Group, CallType, ActionPayload } from '@/lib/types';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Ban, Info, MoreVertical, Loader2, MessageCircleX, Phone, Video, FileText, Search, X, Smile } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { MessageInput } from '@/components/message-input';
import { MessageItem } from '@/components/message-item';
import { Skeleton } from '@/components/ui/skeleton';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { MediaPreviewModal } from '@/components/media-preview-modal';
import { GroupInfoPanel } from '@/components/group-info-panel';
import { SendAudioModal } from '@/components/send-audio-modal';
import { useChatUtils } from '@/contexts/chat-utils-context';
import { generateLocalSummary, type LocalSummary } from '@/lib/local-summarizer';
import { useTheme } from 'next-themes';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { generateSuggestedReplies } from '@/lib/suggested-replies';
import EmojiPicker, { EmojiStyle, Theme as EmojiTheme } from 'emoji-picker-react';
interface ChatWindowProps {
recipient: ChatRecipient | null;
onClose: () => void;
onViewProfile: (user: User) => void;
onPreviewImage: (imageUrl: string, isViewOnce?: boolean, onViewed?: () => void) => void;
onAddMembers: (group: Group) => void;
onStartCall: (peer: User, type: CallType) => void;
openChangeNameModal: () => void;
openChangeStatusModal: () => void;
openChangeBioModal: () => void;
onOpenSettings: (section?: string) => void;
}
const BlockedBanner = ({ recipient, isBlockedByYou, t }: { recipient: ChatRecipient; isBlockedByYou: boolean, t: (key: any, options?: any) => string }) => (
<div className="flex flex-col items-center text-center p-4 bg-destructive/10 border-b border-destructive/20">
<Ban className="w-8 h-8 text-destructive mb-2" />
<p className="text-sm text-destructive-foreground">
{isBlockedByYou ? t('blockedBannerYou', { name: recipient.displayName }) : t('blockedBannerOther', { name: recipient.displayName })}
</p>
</div>
);
export function ChatWindow({
recipient,
onClose,
onViewProfile,
onPreviewImage,
onAddMembers,
onStartCall,
openChangeBioModal,
openChangeNameModal,
openChangeStatusModal,
onOpenSettings,
}: ChatWindowProps) {
const { currentUser } = useAuth();
const { groups } = useGroups();
const { clearChatHistory, deleteMessage } = useChatUtils();
const { playSound, t, formatDistanceToNow, addToast, toggleSound, isSoundEnabled, privacySettings, updatePrivacySettings, fontStyle, setFontStyle, dataMode, setDataMode, language } = useSettings();
const { messages, loading, sendTextMessage, sendMediaMessage, sendVoiceMessage, editMessage, toggleReaction, chatId, typingUsers } = useChat(recipient);
const { theme, setTheme } = useTheme();
const messagesEndRef = useRef<HTMLDivElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const lastRange = useRef<Range | null>(null);
const [confirmAction, setConfirmAction] = useState<{ type: 'block' | 'unblock' | 'remove' | 'clear_history' } | null>(null);
const [messageToDelete, setMessageToDelete] = useState<Message | null>(null);
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
const [mediaFile, setMediaFile] = useState<{ file: File, originalSize: number, compressedBlob?: Blob } | null>(null);
const [audioToSend, setAudioToSend] = useState<{ file: File; duration: number } | null>(null);
const [isGroupInfoOpen, setGroupInfoOpen] = useState(false);
const [clientReadyStatus, setClientReadyStatus] = useState<string | null>(null);
const [isSummarizing, setIsSummarizing] = useState(false);
const [summary, setSummary] = useState<LocalSummary | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [viewedOnceMessages, setViewedOnceMessages] = useState<Set<string>>(new Set());
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const currentGroup = useMemo<Group | undefined>(() => {
if (!recipient?.isGroup) return undefined;
return groups.find(g => g.id === recipient.uid);
}, [recipient, groups]);
const scrollToBottom = useCallback(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "auto" });
}
}, []);
useLayoutEffect(() => {
scrollToBottom();
}, [messages, typingUsers, chatId, scrollToBottom]);
useEffect(() => {
// This event is dispatched from MessageItem when an image loads
const handleImageLoad = () => scrollToBottom();
window.addEventListener('imageLoaded', handleImageLoad);
return () => {
window.removeEventListener('imageLoaded', handleImageLoad);
};
}, [scrollToBottom]);
// Search logic effect
useEffect(() => {
if (isSearching && searchQuery.trim()) {
setTimeout(() => {
const firstResult = chatContainerRef.current?.querySelector('mark.search-highlight');
if (firstResult) {
firstResult.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}, 100);
}
}, [searchQuery, messages, isSearching]);
const isBlockedByYou = useMemo(() => {
if(!recipient || !currentUser?.blockedUsers) return false;
return !!currentUser?.blockedUsers?.[recipient.uid]
}, [currentUser, recipient]);
const isBlockedByRecipient = useMemo(() => {
if(!recipient || !currentUser?.blockedBy) return false;
return !!currentUser?.blockedBy?.[recipient.uid]
}, [currentUser, recipient]);
useEffect(() => {
if (!recipient) return;
if (recipient.isGroup) {
setClientReadyStatus(null);
} else if ((recipient as User).isOnline) {
setClientReadyStatus(t('online'));
} else if ((recipient as User).lastSeen) {
try {
const lastSeenText = t('lastSeen', { time: formatDistanceToNow(new Date((recipient as User).lastSeen as number), { addSuffix: true }) });
setClientReadyStatus(lastSeenText);
} catch (error) {
setClientReadyStatus(t('offline'));
}
} else {
setClientReadyStatus(t('offline'));
}
}, [recipient, t, formatDistanceToNow]);
const handleConfirmAction = async () => {
if (!confirmAction || !chatId) return;
playSound('touch');
switch (confirmAction.type) {
case 'block':
// await blockUser(recipient);
break;
case 'unblock':
// await unblockUser(recipient);
break;
case 'remove':
onClose();
break;
case 'clear_history':
await clearChatHistory(chatId);
break;
}
setConfirmAction(null);
};
const handleDeleteMessage = () => {
if (messageToDelete && messageToDelete.id && chatId) {
playSound('touch');
setDeletingMessageId(messageToDelete.id);
setTimeout(() => {
deleteMessage(chatId, messageToDelete.id!);
setDeletingMessageId(null);
}, 400);
}
setMessageToDelete(null);
};
const handleGetSuggestedReplies = useCallback(async (message: Message): Promise<string[]> => {
if (!message.text) return [];
return generateSuggestedReplies(message.text, language);
}, [language]);
const handleSummarizeChat = async () => {
if (messages.length === 0) {
addToast("Chat is empty, nothing to summarize.", { variant: 'default' });
return;
}
setIsSummarizing(true);
setSummary(null);
const localSummary = generateLocalSummary(messages, currentUser!);
setSummary(localSummary);
};
const handlePrivateReply = useCallback(({ displayName, uid }: { displayName: string, uid: string }) => {
if (editorRef.current) {
const firstLetter = displayName.split(' ').map(n => n[0]).join('').toLowerCase();
editorRef.current.innerHTML = `@'${'\'\''}${firstLetter} `;
editorRef.current.focus();
// Move cursor to the end
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(editorRef.current);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
}, []);
const handleMarkAsViewed = useCallback((messageId: string) => {
setViewedOnceMessages(prev => new Set(prev).add(messageId));
}, []);
const handleEmojiSelect = (emojiData: any) => {
const editor = editorRef.current;
if (!editor) return;
let range: Range;
if (lastRange.current && editor.contains(lastRange.current.commonAncestorContainer)) {
range = lastRange.current;
} else {
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
}
range.deleteContents();
const img = document.createElement('img');
img.src = emojiData.imageUrl;
img.alt = emojiData.emoji;
img.className = 'emoji';
range.insertNode(img);
range.setStartAfter(img);
range.collapse(true);
lastRange.current = range.cloneRange();
setIsEmojiPickerOpen(false);
};
if (!currentUser || !recipient) {
return null;
}
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
const recipientStatus = clientReadyStatus;
const canCall = !recipient.isGroup && !isBlockedByRecipient && !isBlockedByYou;
const handleSummaryModalClose = () => {
setIsSummarizing(false);
setSummary(null);
}
const toggleSearch = () => {
setIsSearching(!isSearching);
setSearchQuery('');
};
return (
<div className="flex flex-col h-full w-full flex-1">
<div className="chat-window-background z-0"></div>
<header className="flex-shrink-0 flex items-center justify-between p-2 md:p-4 border-b sticky top-0 z-10 glass-header">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Button onClick={() => { playSound('touch'); onClose(); }} variant="ghost" size="icon" className="md:hidden">
<ArrowLeft />
</Button>
{!isSearching ? (
<button onClick={() => { if (!recipient.isGroup) { playSound('touch'); onViewProfile(recipient as User); } }} className="flex items-center gap-3 cursor-pointer disabled:cursor-default min-w-0" disabled={recipient.isGroup}>
<div className="relative">
<Avatar>
<AvatarImage src={recipient.photoURL} alt={recipient.displayName} />
<AvatarFallback>{recipient.displayName?.charAt(0) ?? '?'}</AvatarFallback>
</Avatar>
{(recipient as User).isOnline && !recipient.isGroup && (
<span className="absolute bottom-0 right-0 block h-3 w-3 rounded-full bg-green-500 ring-2 ring-card" />
)}
</div>
<div className="text-left truncate">
<h2 className="font-bold text-lg text-foreground truncate">{recipient.displayName}</h2>
{recipientStatus && <p className="text-xs text-muted-foreground truncate">{recipientStatus}</p>}
</div>
</button>
) : (
<div className="relative w-full flex items-center gap-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
placeholder={t('searchInChat')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-muted border-none focus-visible:ring-primary w-full"
autoFocus
/>
</div>
)}
</div>
<div className="flex items-center gap-1">
<Button onClick={toggleSearch} variant="ghost" size="icon" className="rounded-full">
{isSearching ? <X /> : <Search />}
</Button>
{canCall && (
<>
<Button onClick={() => onStartCall(recipient as User, 'audio')} variant="outline" size="icon" className="rounded-full hover:bg-green-500/10 hover:text-green-500">
<Phone />
</Button>
<Button onClick={() => onStartCall(recipient as User, 'video')} variant="outline" size="icon" className="rounded-full hover:bg-blue-500/10 hover:text-blue-500">
<Video />
</Button>
</>
)}
{recipient.isGroup && (
<Button variant="ghost" size="icon" title={t('groupInfo')} onClick={() => { playSound('touch'); setGroupInfoOpen(true); }}>
<Info />
</Button>
)}
<DropdownMenu onOpenChange={() => playSound('touch')}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title="More options">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={handleSummarizeChat}>
<FileText className="mr-2 h-4 w-4" />
{t('summarizeChat')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConfirmAction({ type: 'clear_history' })}>
<MessageCircleX className="mr-2 h-4 w-4" />
{t('clearHistory')}
</DropdownMenuItem>
{!recipient.isGroup && (
<>
{isBlockedByYou ? (
<DropdownMenuItem onSelect={() => setConfirmAction({ type: 'unblock' })}>
{t('unblockUser')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => setConfirmAction({type: 'block'})} className="text-destructive">
{t('blockUser')}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setConfirmAction({type: 'remove'})} className="text-destructive">
{t('removeContact')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="flex-1 flex flex-col min-h-0 relative">
{(isBlockedByYou || isBlockedByRecipient) && <BlockedBanner recipient={recipient} isBlockedByYou={!!isBlockedByYou} t={t} />}
<div ref={chatContainerRef} className="flex-1 overflow-y-auto p-4 md:p-6 chat-container">
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className={cn("flex items-center gap-3", i % 2 === 1 && "flex-row-reverse")}>
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className={cn("h-16", i % 2 === 1 ? "w-3/4" : "w-1/2")} />
</div>
))}
</div>
) : (
messages.map(msg => (
<MessageItem
key={msg.id}
message={msg}
isSender={msg.sender === currentUser.uid}
isDeleting={deletingMessageId === msg.id}
onDelete={() => { playSound('touch'); setMessageToDelete(msg); }}
onToggleReaction={(emoji) => { msg.id && toggleReaction(msg.id, emoji); }}
onPreviewImage={onPreviewImage}
onEditMessage={(messageId, newText) => editMessage(messageId, newText)}
onTranslate={() => {}}
onPrivateReply={handlePrivateReply}
currentGroup={currentGroup}
searchQuery={searchQuery}
isHighlighted={isSearching}
isViewed={viewedOnceMessages.has(msg.id)}
onMarkAsViewed={handleMarkAsViewed}
/>
))
)}
{typingUsers.length > 0 && (
<div className="flex items-center gap-2 p-2 text-muted-foreground">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span className="text-sm italic">
{typingUsers.join(', ')} {typingUsers.length > 1 ? t('typing_plural') : t('typing')}
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="flex-shrink-0">
<MessageInput
sendTextMessage={sendTextMessage}
onSelectMedia={(file) => setMediaFile({ file, originalSize: file.size })}
onSendAudio={(data) => setAudioToSend(data)}
chatId={chatId || null}
disabled={!!isBlockedByYou || !!isBlockedByRecipient}
lastMessage={lastMessage}
onGetSuggestedReplies={handleGetSuggestedReplies}
isGroupChat={!!recipient.isGroup}
currentGroup={currentGroup}
editorRef={editorRef}
onToggleEmojiPicker={() => {
playSound('touch');
const willOpen = !isEmojiPickerOpen;
if (willOpen) {
// Save cursor position before blurring
const selection = window.getSelection();
if (selection && selection.rangeCount > 0 && editorRef.current?.contains(selection.anchorNode)) {
lastRange.current = selection.getRangeAt(0).cloneRange();
}
// Blur the input to prevent keyboard from opening on mobile
editorRef.current?.blur();
}
setIsEmojiPickerOpen(willOpen);
}}
isEmojiPickerOpen={isEmojiPickerOpen}
/>
<div className={cn("emoji-picker-sheet-container", isEmojiPickerOpen && "open")}>
<EmojiPicker
onEmojiClick={handleEmojiSelect}
autoFocusSearch={false}
theme={theme === 'dark' ? EmojiTheme.DARK : EmojiTheme.LIGHT}
emojiStyle={EmojiStyle.APPLE}
height="100%"
width="100%"
lazyLoadEmojis
searchDisabled
skinTonesDisabled
previewConfig={{ showPreview: false }}
/>
</div>
</div>
</div>
{currentGroup && (
<GroupInfoPanel
isOpen={isGroupInfoOpen}
onClose={() => setGroupInfoOpen(false)}
group={currentGroup}
onAddMembers={() => onAddMembers(currentGroup)}
/>
)}
{mediaFile && (
<MediaPreviewModal
file={mediaFile.file}
originalSize={mediaFile.originalSize}
isOpen={!!mediaFile}
onClose={() => setMediaFile(null)}
onSend={sendMediaMessage}
/>
)}
{audioToSend && (
<SendAudioModal
file={audioToSend.file}
duration={audioToSend.duration}
isOpen={!!audioToSend}
onClose={() => setAudioToSend(null)}
onSend={sendVoiceMessage}
/>
)}
<AlertDialog open={!!confirmAction} onOpenChange={(isOpen) => !isOpen && setConfirmAction(null)}>
<AlertDialogContent className="scaleIn">
<AlertDialogHeader>
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
<AlertDialogDescription>
{confirmAction?.type === 'block' && t('blockUserConfirm', { name: recipient.displayName })}
{confirmAction?.type === 'unblock' && t('unblockUserConfirm', { name: recipient.displayName })}
{confirmAction?.type === 'remove' && t('removeContactConfirm', { name: recipient.displayName })}
{confirmAction?.type === 'clear_history' && t('clearHistoryConfirm', { name: recipient.displayName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => playSound('touch')}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmAction} className={confirmAction?.type === 'block' || confirmAction?.type === 'remove' ? 'bg-destructive hover:bg-destructive/90' : ''}>
{t('confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!messageToDelete} onOpenChange={(isOpen) => !isOpen && setMessageToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteMessageTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteMessageConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => playSound('touch')}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteMessage} className="bg-destructive hover:bg-destructive/90">
{t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={isSummarizing} onOpenChange={(isOpen) => !isOpen && handleSummaryModalClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('chatSummary')}</AlertDialogTitle>
<AlertDialogDescription>
A quick overview of the chat.
</AlertDialogDescription>
</AlertDialogHeader>
{summary ? (
<div className="max-h-60 overflow-y-auto p-4 bg-muted rounded-lg text-sm space-y-2">
<p><strong>Topic:</strong> {summary.topic}</p>
<p><strong>Participants:</strong> {Array.isArray(summary.participants) ? summary.participants.join(', ') : ''}</p>
<div><strong>Summary:</strong>
<ul className="list-disc pl-5 mt-1">
{summary.summaryPoints.map((point, index) => <li key={index}>{point}</li>)}
</ul>
</div>
</div>
) : (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)}
<AlertDialogFooter>
<AlertDialogAction onClick={handleSummaryModalClose}>{t('close')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}