"use client"; import { Message, URLPreviewData, Group, ChatRecipient, ActionPayload, UserProfile } from "@/lib/types"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Button } from "./ui/button"; import { SmilePlus, Clock, Trash2, Ban, CornerUpLeft, Play, Pause, Loader2, Info, Check, CheckCheck, ArrowDownUp, ExternalLink, Pencil, X, Languages, Mic, AlertTriangle, Download, File as FileIcon, Copy, Lock, MoreHorizontal, UserCheck, Eye, EyeOff } from "lucide-react"; import { useSettings } from "@/contexts/settings-context"; import { useAuth } from "@/contexts/auth-context"; import { useAppContext } from "@/contexts/app-context"; import { useFirebase } from "@/contexts/firebase-context"; import { useMemo, useState, useRef, useEffect } from "react"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { Textarea } from "./ui/textarea"; import { cryptoService } from "@/lib/crypto-service"; import { storageService } from "@/lib/storage-service"; import Image from 'next/image'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; import { Capacitor } from "@capacitor/core"; const emojiToCodePoint = (emoji: string): string => { const codePoints = []; for (let i = 0; i < emoji.length; i++) { const code = emoji.codePointAt(i); if (code) { codePoints.push(code.toString(16)); if (code > 0xffff) { i++; } } } return codePoints.join('-'); }; const renderTextWithEmojisAndHighlight = (text: string | null | undefined, searchQuery?: string, isHighlighted?: boolean): string => { if (!text) return ''; let content = text; content = content.replace(//g, ">"); if (searchQuery) { const regex = new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); content = content.replace(regex, (match, group1) => { const highlightClass = isHighlighted ? 'bg-orange-400 dark:bg-orange-600' : 'bg-yellow-300 dark:bg-yellow-500'; return `${group1}`; }); } // This regex finds unicode characters but they are rendered as images via the `img.emoji` CSS selector logic const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])(\ufe0f)?/g; return content.replace(emojiRegex, (emoji) => { try { const unified = emojiToCodePoint(emoji); if (!unified) return emoji; const imageUrl = `https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${unified}.png`; return `${emoji}`; } catch (error) { console.error(`Could not convert emoji: ${emoji}`, error); return emoji; } }); }; interface MessageItemProps { message: Message; isSender: boolean; isDeleting: boolean; onDelete: () => void; onToggleReaction: (emoji: string) => void; onPreviewImage: (imageUrl: string, isViewOnce?: boolean, onViewed?: () => void) => void; onEditMessage: (messageId: string, newText: string) => void; onTranslate: (message: Message) => void; onPrivateReply: (user: UserProfile) => void; translatedText?: string; isTranslating?: boolean; currentGroup?: Group; searchQuery?: string; isHighlighted?: boolean; isViewed: boolean; onMarkAsViewed: (messageId: string) => void; } const QuotedMessage = ({ message, isSender, t }: { message: Message, isSender: boolean, t: (key: any, options?: any) => string }) => (

{message.replyTo?.displayName}

{message.replyTo?.text || t(message.replyTo?.imageKey ? 'image' : message.replyTo?.videoKey ? 'video' : 'voiceMessage')}

); const AudioPlayer = ({ message, isSender, currentGroup }: { message: Message, isSender: boolean, currentGroup?: Group }) => { const { t } = useSettings(); const { currentUser } = useAuth(); const { db } = useFirebase(); const [secureUrl, setSecureUrl] = useState(message.status === 'pending' ? message.audioKey || null : null); const [isLoading, setIsLoading] = useState(message.status !== 'pending'); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const audioRef = useRef(null); const canvasRef = useRef(null); const audioContextRef = useRef(null); const analyserRef = useRef(null); const sourceRef = useRef(null); const animationFrameRef = useRef(null); const volumeRef = useRef(0); const timeRef = useRef(0); useEffect(() => { const fetchAndDecryptAudio = async () => { if (!message.audioKey || !currentUser || message.status === 'pending') return; if (secureUrl && secureUrl.startsWith('blob:')) { setIsLoading(false); return; } setIsLoading(true); setError(null); try { const cachedBlob = await storageService.getCachedMedia(message.audioKey); if (cachedBlob) { setSecureUrl(URL.createObjectURL(cachedBlob)); return; } const isNative = Capacitor.isNativePlatform(); const baseUrl = isNative ? process.env.NEXT_PUBLIC_API_BASE_URL : ''; const apiUrl = `${baseUrl}/api/media?fileKey=${encodeURIComponent(message.audioKey)}`; const response = await fetch(apiUrl); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to get media file'); } const originalBlob = await response.blob(); let blobToCache = originalBlob; if (message.encryptionKey) { blobToCache = await cryptoService.decryptFile(originalBlob, message.encryptionKey, message.keyVersion, currentUser, db, currentGroup, message.fileType); } await storageService.cacheMedia(message.audioKey, blobToCache); setSecureUrl(URL.createObjectURL(blobToCache)); } catch (error: any) { console.error("Error fetching or decrypting audio:", error); setError(error.message); } finally { setIsLoading(false); } }; fetchAndDecryptAudio(); }, [message.audioKey, message.encryptionKey, message.keyVersion, currentUser, db, currentGroup, message.status, secureUrl, message.fileType]); const drawWave = (ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement, amplitude: number, lineWidth: number, alphaShift: number) => { let centerY = canvas.height / 2; ctx.beginPath(); ctx.moveTo(0, centerY); const normalizedVolume = volumeRef.current / 128; const waveAmplitude = amplitude * normalizedVolume; for (let x = 0; x <= canvas.width; x++) { let y = centerY + Math.sin(x * 0.05 + timeRef.current * 0.08) * waveAmplitude; ctx.lineTo(x, y); } let gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, `rgba(0,255,255,${0.9 - alphaShift})`); gradient.addColorStop(0.5, `rgba(0,128,255,${0.6 - alphaShift})`); gradient.addColorStop(1, `rgba(0,0,80,${0.2 - alphaShift})`); ctx.strokeStyle = gradient; ctx.lineWidth = lineWidth; ctx.shadowBlur = 15; ctx.shadowColor = "#00eaff"; ctx.stroke(); }; const animate = () => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; if (analyserRef.current) { const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount); analyserRef.current.getByteFrequencyData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } volumeRef.current = sum / dataArray.length; } else { volumeRef.current = 0; } if (canvas.width !== canvas.clientWidth || canvas.height !== canvas.clientHeight) { canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; } ctx.clearRect(0, 0, canvas.width, canvas.height); drawWave(ctx, canvas, 15, 3, 0); drawWave(ctx, canvas, 20, 2, 0.3); timeRef.current++; animationFrameRef.current = requestAnimationFrame(animate); }; const setupAudioAnalysis = () => { if (typeof window === 'undefined' || !audioRef.current || audioContextRef.current) return; const AudioContext = window.AudioContext || (window as any).webkitAudioContext; const audioCtx = new AudioContext(); audioContextRef.current = audioCtx; sourceRef.current = audioCtx.createMediaElementSource(audioRef.current); analyserRef.current = audioCtx.createAnalyser(); analyserRef.current.fftSize = 256; sourceRef.current.connect(analyserRef.current); analyserRef.current.connect(audioCtx.destination); }; const stopAnimation = () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (ctx && canvas) { ctx.clearRect(0, 0, canvas.width, canvas.height); volumeRef.current = 0; drawWave(ctx, canvas, 15, 3, 0); drawWave(ctx, canvas, 20, 2, 0.3); } }; useEffect(() => { const audio = audioRef.current; if (!audio || !secureUrl) return; const handleEnded = () => { setIsPlaying(false); stopAnimation(); }; audio.addEventListener('ended', handleEnded); audio.addEventListener('pause', handleEnded); const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (ctx && canvas) { drawWave(ctx, canvas, 15, 3, 0); drawWave(ctx, canvas, 20, 2, 0.3); } return () => { audio.removeEventListener('ended', handleEnded); audio.removeEventListener('pause', handleEnded); stopAnimation(); if (sourceRef.current) sourceRef.current.disconnect(); if (analyserRef.current) analyserRef.current.disconnect(); if (audioContextRef.current && audioContextRef.current.state !== 'closed') { audioContextRef.current.close().catch(console.error); audioContextRef.current = null; } }; }, [secureUrl]); const togglePlay = () => { const audio = audioRef.current; if (!audio || !secureUrl) return; setupAudioAnalysis(); if (isPlaying) { audio.pause(); } else { audio.play().then(() => { if (audioContextRef.current?.state === 'suspended') { audioContextRef.current.resume(); } animate(); }); } setIsPlaying(!isPlaying); }; const formatTime = (seconds: number) => { const floorSeconds = Math.floor(seconds); const min = Math.floor(floorSeconds / 60); const sec = floorSeconds % 60; return `${min}:${sec < 10 ? '0' : ''}${sec}`; }; if (isLoading) { return
{t('loading')}
; } if (error) { return
{t('loadMediaError')}
; } return (
{secureUrl &&
); }; function formatBytes(bytes: number, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } const UploadProgress = ({ progress }: { progress: number }) => { const radius = 20; const circumference = 2 * Math.PI * radius; const [offset, setOffset] = useState(circumference); const [isFadingOut, setIsFadingOut] = useState(false); useEffect(() => { const newOffset = circumference - (progress / 100) * circumference; setOffset(newOffset); if (progress >= 100) { const timer = setTimeout(() => setIsFadingOut(true), 300); return () => clearTimeout(timer); } else { setIsFadingOut(false); } }, [progress, circumference]); return (
{!isFadingOut && ( <> {progress.toFixed(0)}% )}
); }; const SecureMedia = ({ message, type, alt, onPreview, currentGroup, onMarkAsViewed, isViewed, isSender }: { message: Message, type: 'image' | 'video' | 'file', alt?: string, onPreview?: (url: string, isViewOnce?: boolean, onViewed?: () => void) => void, currentGroup?: Group, onMarkAsViewed?: (messageId: string) => void; isViewed?: boolean; isSender: boolean; }) => { const { currentUser } = useAuth(); const { db } = useFirebase(); const { t } = useSettings(); const [secureUrl, setSecureUrl] = useState(message.status === 'pending' ? (message.imageKey || message.videoKey || null) : null); const [isLoading, setIsLoading] = useState(message.status !== 'pending'); const [error, setError] = useState(null); const mediaKey = type === 'image' ? message.imageKey : type === 'video' ? message.videoKey : message.fileKey; // This effect will dispatch an event when the image has loaded const handleImageLoad = () => { window.dispatchEvent(new CustomEvent('imageLoaded')); }; useEffect(() => { const fetchAndDecryptMedia = async () => { if (!mediaKey || !currentUser || message.status === 'pending') return; if (mediaKey.startsWith('blob:')) { setIsLoading(false); setSecureUrl(mediaKey); return; } if (secureUrl && secureUrl.startsWith('blob:')) { setIsLoading(false); return; } setIsLoading(true); setError(null); try { const cachedBlob = await storageService.getCachedMedia(mediaKey); if (cachedBlob) { setSecureUrl(URL.createObjectURL(cachedBlob)); return; } const isNative = Capacitor.isNativePlatform(); const baseUrl = isNative ? process.env.NEXT_PUBLIC_API_BASE_URL : ''; const apiUrl = `${baseUrl}/api/media?fileKey=${encodeURIComponent(mediaKey)}`; const response = await fetch(apiUrl); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to get media file'); } const originalBlob = await response.blob(); let blobToCache = originalBlob; if (message.encryptionKey) { blobToCache = await cryptoService.decryptFile(originalBlob, message.encryptionKey, message.keyVersion, currentUser, db, currentGroup, message.fileType); } await storageService.cacheMedia(mediaKey, blobToCache); setSecureUrl(URL.createObjectURL(blobToCache)); } catch (err: any) { console.error("Error fetching or decrypting media:", err); setError(err.message || "Could not load media."); } finally { setIsLoading(false); } }; fetchAndDecryptMedia(); }, [mediaKey, message.status, message.encryptionKey, message.keyVersion, type, currentUser, db, currentGroup, secureUrl, message.fileType]); const handleDownload = () => { if (!secureUrl || !message.fileName) return; const a = document.createElement('a'); a.href = secureUrl; a.download = message.fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); }; const handleViewOnceClick = () => { if (!secureUrl || !onPreview || !message.id || isViewed) return; if (onMarkAsViewed) { onMarkAsViewed(message.id); } onPreview(secureUrl, true); }; const mediaContainerClasses = "relative max-w-xs rounded-lg mt-1 overflow-hidden"; if (isLoading) { return
; } if (error || !secureUrl) { return
{t('loadMediaError')}
; } if (message.deleteAfterDelivery && !isSender) { if (isViewed) { return (
Viewed
); } return ( ); } if (type === 'image') { return (
{message.status === 'pending' && typeof message.uploadProgress === 'number' && } {alt message.status !== 'pending' && onPreview?.(secureUrl, false, undefined)} onLoad={handleImageLoad} width={300} height={300} />
); } if (type === 'video') { return (
{message.status === 'pending' && typeof message.uploadProgress === 'number' && }
); } if (type === 'file') { return (

{message.fileName}

{message.fileSize ? formatBytes(message.fileSize) : ''}

) } return null; } const MessageText = ({ text, searchQuery, isHighlighted }: { text: string; searchQuery?: string; isHighlighted?: boolean; }) => { const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); const safeText = text || ''; if (!isClient) { return

{safeText}

; } return (

); }; const DecryptedContent = ({ message, currentGroup, t, searchQuery, isHighlighted }: { message: Message; currentGroup?: Group, t: (key: any) => string, searchQuery?: string, isHighlighted?: boolean }) => { const { currentUser } = useAuth(); const { db } = useFirebase(); const [decryptedText, setDecryptedText] = useState(message.text || null); const [isDecrypting, setIsDecrypting] = useState(false); const [error, setError] = useState(null); useEffect(() => { const decrypt = async () => { // If text is already available (e.g., sender's own message, or already decrypted), just use it. if (message.text) { setDecryptedText(message.text); return; } if (message.encryptedText && typeof message.encryptedText === 'object' && !message.isSystemMessage && currentUser) { setIsDecrypting(true); setError(null); try { let text; if (currentGroup) { const encryptedPayload = message.encryptedText as EncryptedMessage; // This logic needs to be robust for both old and new group encryption formats. if (typeof encryptedPayload === 'string') { // Old format if (!message.keyVersion) { throw new Error("Cannot decrypt old message: Key version is missing."); } const groupKey = await cryptoService.getGroupKey(currentGroup.id, message.keyVersion, currentUser.uid, db); if (!groupKey) throw new Error("Could not retrieve group key for decryption. Your access might be revoked."); text = await cryptoService.decryptGroupMessage(encryptedPayload, groupKey); } else { // New Hybrid format text = await cryptoService.decryptMessage(encryptedPayload, currentUser.uid); } } else { text = await cryptoService.decryptMessage(message.encryptedText as EncryptedMessage, currentUser.uid); } setDecryptedText(text); } catch (e: any) { console.warn(`Decryption Error for message: ${message.id}`, e); setError(e.message || "Decryption failed."); setDecryptedText(null); } finally { setIsDecrypting(false); } } else { setDecryptedText(message.text || null); } }; decrypt(); }, [message, currentUser, currentGroup, db]); if (error) { return

{error}

; } return ; }; const UrlPreview = ({ data, isSender }: { data: URLPreviewData, isSender: boolean }) => ( {data.imageUrl && ( {data.title} )}

{data.domain}

{data.title}

{data.description &&

{data.description}

}
); const usePrevious = (value: T) => { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; }; export function MessageItem({ message, isSender, isDeleting, onDelete, onToggleReaction, onPreviewImage, onEditMessage, onTranslate, onPrivateReply, translatedText, isTranslating, currentGroup, searchQuery, isHighlighted, isViewed, onMarkAsViewed }: MessageItemProps) { const { currentUser } = useAuth(); const { setReplyTo, setPrivateReplyTo } = useAppContext(); const { playSound, t, formatDistanceToNow, language, addToast } = useSettings(); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(message.text || ""); const textareaRef = useRef(null); const [pulsingReactions, setPulsingReactions] = useState([]); const [isMenuOpen, setIsMenuOpen] = useState(false); const longPressTimer = useRef(); const reactionEmojis = ['❤️', '😂', '👍', '😢', '😡']; const reactions = useMemo(() => { if (!message.reactions) return []; return Object.entries(message.reactions) .map(([emoji, users]) => ({ emoji, count: Object.keys(users).length, users: Object.values(users) })) .filter(r => r.count > 0) .sort((a,b) => b.count - a.count); }, [message.reactions]); const prevReactions = usePrevious(reactions); const prevStatus = usePrevious(message.status); const justConfirmed = prevStatus === 'pending' && message.status === 'sent'; useEffect(() => { if (prevReactions && reactions.length >= prevReactions.length) { const newPulsing: string[] = []; reactions.forEach(currentReaction => { const prevReaction = prevReactions.find(pr => pr.emoji === currentReaction.emoji); if (prevReaction && currentReaction.count > prevReaction.count) { newPulsing.push(currentReaction.emoji); } }); if (newPulsing.length > 0) { setPulsingReactions(newPulsing); const timer = setTimeout(() => setPulsingReactions([]), 300); return () => clearTimeout(timer); } } }, [reactions, prevReactions]); const formattedTimestamp = useMemo(() => { if (message.status === 'pending') return t('pending'); if (message.status === 'failed') return 'Failed'; if (!message.timestamp || typeof message.timestamp !== 'number') return 'sending...'; return formatDistanceToNow(new Date(message.timestamp), { addSuffix: true }); }, [message.timestamp, message.status, t, formatDistanceToNow]); useEffect(() => { if (isEditing && textareaRef.current) { textareaRef.current.focus(); textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }, [isEditing]); const MessageStatusIcon = () => { if (!isSender || message.isSystemMessage) return null; if (message.status === 'pending') return ; if (message.status === 'failed') return ; const isReadBySomeone = message.readBy && Object.keys(message.readBy).length > 0; if (isReadBySomeone) return ; const isDeliveredToSomeone = message.deliveredTo && Object.keys(message.deliveredTo).length > 1; if (isDeliveredToSomeone) return ; return ; }; const canInteract = !message.isDeleted && message.status !== 'pending' && message.status !== 'failed' && !message.isSystemMessage; const handleReply = () => { if(!message.id) return; playSound('touch'); setReplyTo({ id: message.id, text: message.text || (message.imageKey ? t('image') : message.videoKey ? t('video') : message.audioKey ? t('voiceMessage') : ""), displayName: isSender ? t('you') : message.senderDisplayName, imageKey: message.imageKey, videoKey: message.videoKey, audioKey: message.audioKey, }); setIsMenuOpen(false); }; const handlePrivateReply = () => { if (!currentGroup) return; const senderAsProfile: UserProfile = { uid: message.sender, displayName: message.senderDisplayName, photoURL: message.senderPhotoUrl, publicId: '' // Not needed for this action }; setPrivateReplyTo(senderAsProfile); setIsMenuOpen(false); }; const handleCopy = () => { const textToCopy = message.text || ''; navigator.clipboard.writeText(textToCopy).then(() => { addToast('Message copied!'); }, (err) => { console.error('Could not copy text: ', err); addToast('Failed to copy message.', { variant: 'destructive' }); }); setIsMenuOpen(false); } const handleSaveEdit = () => { if (message.id && editText.trim() && editText.trim() !== message.text) { onEditMessage(message.id, editText.trim()); } setIsEditing(false); }; const handleCancelEdit = () => { setEditText(message.text || ""); setIsEditing(false); }; const handleEditKeydown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSaveEdit(); } if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit(); } } const handleReactionSelect = (emoji: string) => { onToggleReaction(emoji); setIsMenuOpen(false); }; const isPrivate = !!message.privateFor; const canViewPrivateMessage = isPrivate && (currentUser?.uid === message.sender || currentUser?.uid === message.privateFor.uid); const handleLongPress = () => { longPressTimer.current = setTimeout(() => { setIsMenuOpen(true); }, 300); }; const handlePressEnd = () => { clearTimeout(longPressTimer.current); }; const handleMenuItemSelect = (action: () => void) => { action(); setIsMenuOpen(false); } const renderMessageContent = () => { if (message.isDeleted) { return (
{t('deletedMessage')}
); } if (isPrivate && !canViewPrivateMessage) { return (
Private message to {message.privateFor?.displayName}
); } if (isEditing) { return (