| "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(() => { |
| |
| const handleImageLoad = () => scrollToBottom(); |
| window.addEventListener('imageLoaded', handleImageLoad); |
| return () => { |
| window.removeEventListener('imageLoaded', handleImageLoad); |
| }; |
| }, [scrollToBottom]); |
|
|
|
|
| |
| 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.isBot || 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': |
| |
| break; |
| case 'unblock': |
| |
| 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(); |
| |
| |
| 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 && !recipient.isBot && !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 && !recipient.isBot && ( |
| <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 && !recipient.isBot && ( |
| <> |
| {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> |
| ); |
| } |
|
|