| | "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.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 && !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> |
| | ); |
| | } |
| | |
| |
|