| |
|
| | "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, "<").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 `<mark class="search-highlight ${highlightClass} text-black rounded-sm px-0.5">${group1}</mark>`; |
| | }); |
| | } |
| | |
| | |
| | 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 `<img src="${imageUrl}" alt="${emoji}" class="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 }) => ( |
| | <a href={`#message-${message.replyTo?.id}`} className="block mb-2"> |
| | <div className={cn( |
| | "p-2 rounded-lg bg-opacity-50 border-l-4", |
| | isSender |
| | ? "bg-black/20 border-white/50" |
| | : "bg-black/5 dark:bg-white/10 border-primary" |
| | )}> |
| | <p className="font-bold text-xs">{message.replyTo?.displayName}</p> |
| | <p className="text-sm truncate opacity-80">{message.replyTo?.text || t(message.replyTo?.imageKey ? 'image' : message.replyTo?.videoKey ? 'video' : 'voiceMessage')}</p> |
| | </div> |
| | </a> |
| | ); |
| |
|
| | const AudioPlayer = ({ message, isSender, currentGroup }: { message: Message, isSender: boolean, currentGroup?: Group }) => { |
| | const { t } = useSettings(); |
| | const { currentUser } = useAuth(); |
| | const { db } = useFirebase(); |
| | const [secureUrl, setSecureUrl] = useState<string | null>(message.status === 'pending' ? message.audioKey || null : null); |
| | const [isLoading, setIsLoading] = useState(message.status !== 'pending'); |
| | const [error, setError] = useState<string | null>(null); |
| | const [isPlaying, setIsPlaying] = useState(false); |
| | const audioRef = useRef<HTMLAudioElement | null>(null); |
| | const canvasRef = useRef<HTMLCanvasElement | null>(null); |
| |
|
| | const audioContextRef = useRef<AudioContext | null>(null); |
| | const analyserRef = useRef<AnalyserNode | null>(null); |
| | const sourceRef = useRef<MediaElementAudioSourceNode | null>(null); |
| | const animationFrameRef = useRef<number | null>(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 <div className="flex items-center gap-2 p-2"><Loader2 className="w-4 h-4 animate-spin" /> <span className="text-xs">{t('loading')}</span></div>; |
| | } |
| | |
| | if (error) { |
| | return <div className="text-xs text-destructive p-2 flex items-center gap-2"><AlertTriangle className="h-4 w-4"/> {t('loadMediaError')}</div>; |
| | } |
| |
|
| | return ( |
| | <div className={cn("w-full max-w-[250px]", isSender ? 'voice-bubble-sent' : 'voice-bubble-received')}> |
| | {secureUrl && <audio ref={audioRef} src={secureUrl} preload="metadata" crossOrigin="anonymous" />} |
| | <button onClick={togglePlay} className="play-btn-style" disabled={!secureUrl}> |
| | {isPlaying ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>} |
| | </button> |
| | <canvas ref={canvasRef}></canvas> |
| | {message.audioDuration && <span className={cn("duration-style", isSender ? "text-primary-foreground/80" : "text-muted-foreground")}>{formatTime(message.audioDuration)}</span>} |
| | </div> |
| | ); |
| | }; |
| |
|
| | 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 ( |
| | <div className={cn( |
| | "absolute inset-0 bg-black/60 flex flex-col items-center justify-center text-white z-10 transition-opacity duration-300", |
| | isFadingOut && 'upload-progress-fade-out' |
| | )}> |
| | {!isFadingOut && ( |
| | <> |
| | <svg className="w-16 h-16 transform -rotate-90"> |
| | <circle |
| | className="text-white/20" |
| | strokeWidth="4" |
| | stroke="currentColor" |
| | fill="transparent" |
| | r={radius} |
| | cx="32" |
| | cy="32" |
| | /> |
| | <circle |
| | className="text-white" |
| | strokeWidth="4" |
| | strokeDasharray={circumference} |
| | strokeDashoffset={offset} |
| | strokeLinecap="round" |
| | stroke="currentColor" |
| | fill="transparent" |
| | r={radius} |
| | cx="32" |
| | cy="32" |
| | style={{ transition: 'stroke-dashoffset 0.3s ease-out' }} |
| | /> |
| | </svg> |
| | <span className="mt-2 text-sm font-bold">{progress.toFixed(0)}%</span> |
| | </> |
| | )} |
| | </div> |
| | ); |
| | }; |
| |
|
| |
|
| | 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<string | null>(message.status === 'pending' ? (message.imageKey || message.videoKey || null) : null); |
| | const [isLoading, setIsLoading] = useState(message.status !== 'pending'); |
| | const [error, setError] = useState<string | null>(null); |
| |
|
| | const mediaKey = type === 'image' ? message.imageKey : type === 'video' ? message.videoKey : message.fileKey; |
| |
|
| | |
| | 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 <div className="flex items-center justify-center bg-muted rounded-lg max-w-xs h-32"><Loader2 className="w-6 h-6 animate-spin" /></div>; |
| | } |
| | |
| | if (error || !secureUrl) { |
| | return <div className="text-xs text-destructive p-2 flex items-center gap-2"><AlertTriangle className="h-4 w-4"/> {t('loadMediaError')}</div>; |
| | } |
| |
|
| | if (message.deleteAfterDelivery && !isSender) { |
| | if (isViewed) { |
| | return ( |
| | <div className={cn(mediaContainerClasses, "p-4 bg-muted/50 border border-dashed flex flex-col items-center justify-center gap-2")}> |
| | <EyeOff className="w-8 h-8 text-muted-foreground"/> |
| | <span className="font-bold text-muted-foreground">Viewed</span> |
| | </div> |
| | ); |
| | } |
| | return ( |
| | <button onClick={handleViewOnceClick} disabled={isViewed} className={cn(mediaContainerClasses, "p-4 bg-muted/50 border border-dashed flex flex-col items-center justify-center gap-2 cursor-pointer hover:bg-muted/70 disabled:cursor-not-allowed disabled:opacity-60")}> |
| | <Eye className="w-8 h-8"/> |
| | <span className="font-bold">Tap to view</span> |
| | <span className="text-xs text-muted-foreground">{type === 'image' ? 'Photo' : 'Video'}</span> |
| | </button> |
| | ); |
| | } |
| |
|
| | if (type === 'image') { |
| | return ( |
| | <div className={mediaContainerClasses}> |
| | {message.status === 'pending' && typeof message.uploadProgress === 'number' && <UploadProgress progress={message.uploadProgress} />} |
| | <Image src={secureUrl} alt={alt || 'sent image'} className="w-full h-auto cursor-pointer" onClick={() => message.status !== 'pending' && onPreview?.(secureUrl, false, undefined)} onLoad={handleImageLoad} width={300} height={300} /> |
| | </div> |
| | ); |
| | } |
| |
|
| | if (type === 'video') { |
| | return ( |
| | <div className={mediaContainerClasses}> |
| | {message.status === 'pending' && typeof message.uploadProgress === 'number' && <UploadProgress progress={message.uploadProgress} />} |
| | <video src={secureUrl} controls className="w-full h-auto" onLoadedData={handleImageLoad} /> |
| | </div> |
| | ); |
| | } |
| |
|
| | if (type === 'file') { |
| | return ( |
| | <div className="p-3 rounded-lg border bg-muted/50 flex items-center gap-3 max-w-xs mt-1"> |
| | <FileIcon className="h-8 w-8 text-muted-foreground flex-shrink-0" /> |
| | <div className="flex-1 overflow-hidden"> |
| | <p className="font-medium truncate">{message.fileName}</p> |
| | <p className="text-xs text-muted-foreground">{message.fileSize ? formatBytes(message.fileSize) : ''}</p> |
| | </div> |
| | <Button variant="ghost" size="icon" onClick={handleDownload} title="Download file"> |
| | <Download className="h-5 w-5" /> |
| | </Button> |
| | </div> |
| | ) |
| | } |
| |
|
| | 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 <p className="whitespace-pre-wrap break-words">{safeText}</p>; |
| | } |
| |
|
| | return ( |
| | <p |
| | className="whitespace-pre-wrap break-words" |
| | dangerouslySetInnerHTML={{ __html: renderTextWithEmojisAndHighlight(safeText, searchQuery, isHighlighted) }} |
| | /> |
| | ); |
| | }; |
| |
|
| | 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<string | null>(message.text || null); |
| | const [isDecrypting, setIsDecrypting] = useState(false); |
| | const [error, setError] = useState<string | null>(null); |
| |
|
| | useEffect(() => { |
| | const decrypt = async () => { |
| | |
| | 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; |
| | |
| | if (typeof encryptedPayload === 'string') { |
| | 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 { |
| | 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 <p className="whitespace-pre-wrap break-words text-destructive">{error}</p>; |
| | } |
| | |
| | return <MessageText text={isDecrypting ? 'Decrypting...' : decryptedText || ''} searchQuery={searchQuery} isHighlighted={isHighlighted} />; |
| | }; |
| |
|
| |
|
| |
|
| | const UrlPreview = ({ data, isSender }: { data: URLPreviewData, isSender: boolean }) => ( |
| | <a href={data.url} target="_blank" rel="noopener noreferrer" className={cn( |
| | "mt-2 block rounded-lg overflow-hidden border", |
| | isSender ? "border-white/20 bg-black/10" : "border-border bg-muted/50", |
| | "hover:bg-opacity-50 transition-colors" |
| | )}> |
| | {data.imageUrl && ( |
| | <Image src={data.imageUrl} alt={data.title} className="w-full h-32 object-cover" width={300} height={128} /> |
| | )} |
| | <div className="p-2"> |
| | <p className={cn("text-xs font-bold truncate", isSender ? "text-primary-foreground/80" : "text-muted-foreground")}>{data.domain}</p> |
| | <p className={cn("text-sm font-bold truncate", isSender ? "text-primary-foreground" : "text-foreground")}>{data.title}</p> |
| | {data.description && <p className={cn("text-xs truncate", isSender ? "text-primary-foreground/80" : "text-muted-foreground")}>{data.description}</p>} |
| | </div> |
| | </a> |
| | ); |
| |
|
| |
|
| | const usePrevious = <T,>(value: T) => { |
| | const ref = useRef<T>(); |
| | 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<HTMLTextAreaElement>(null); |
| | const [pulsingReactions, setPulsingReactions] = useState<string[]>([]); |
| | const [isMenuOpen, setIsMenuOpen] = useState(false); |
| | const longPressTimer = useRef<NodeJS.Timeout>(); |
| |
|
| | 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 <Clock className="h-4 w-4 text-muted-foreground" />; |
| | if (message.status === 'failed') return <AlertTriangle className="h-4 w-4 text-destructive" />; |
| | |
| | const isReadBySomeone = message.readBy && Object.keys(message.readBy).length > 0; |
| | if (isReadBySomeone) return <CheckCheck className="h-4 w-4 text-sky-400" />; |
| | |
| | const isDeliveredToSomeone = message.deliveredTo && Object.keys(message.deliveredTo).length > 1; |
| | if (isDeliveredToSomeone) return <CheckCheck className="h-4 w-4 text-muted-foreground" />; |
| |
|
| | return <Check className="h-4 w-4 text-muted-foreground" />; |
| | }; |
| |
|
| |
|
| | 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: '' |
| | }; |
| | 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<HTMLTextAreaElement>) => { |
| | 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 ( |
| | <div className="flex items-center gap-2 italic text-muted-foreground"> |
| | <Ban className="h-4 w-4" /> |
| | <span>{t('deletedMessage')}</span> |
| | </div> |
| | ); |
| | } |
| | |
| | if (isPrivate && !canViewPrivateMessage) { |
| | return ( |
| | <div className="flex items-center gap-2 italic text-muted-foreground"> |
| | <Lock className="h-4 w-4" /> |
| | <span>Private message to {message.privateFor?.displayName}</span> |
| | </div> |
| | ); |
| | } |
| |
|
| | if (isEditing) { |
| | return ( |
| | <div className="space-y-2"> |
| | <Textarea |
| | ref={textareaRef} |
| | value={editText} |
| | onChange={(e) => setEditText(e.target.value)} |
| | onKeyDown={handleEditKeydown} |
| | className="bg-background text-foreground" |
| | rows={1} |
| | /> |
| | <div className="flex justify-end gap-2"> |
| | <Button variant="ghost" size="sm" onClick={handleCancelEdit}>{t('cancel')}</Button> |
| | <Button size="sm" onClick={handleSaveEdit}>{t('save')}</Button> |
| | </div> |
| | </div> |
| | ) |
| | } |
| | |
| | return ( |
| | <> |
| | {message.replyTo && <QuotedMessage message={message} isSender={isSender} t={t} />} |
| | {message.imageKey && ( |
| | <SecureMedia |
| | message={{ ...message, chatId: message.chatId || '' }} |
| | type="image" |
| | alt="sent image" |
| | onPreview={onPreviewImage} |
| | currentGroup={currentGroup} |
| | onMarkAsViewed={onMarkAsViewed} |
| | isViewed={isViewed} |
| | isSender={isSender} |
| | /> |
| | )} |
| | {message.videoKey && ( |
| | <SecureMedia message={{ ...message, chatId: message.chatId || '' }} type="video" currentGroup={currentGroup} isSender={isSender}/> |
| | )} |
| | {message.audioKey && ( |
| | <div className="relative"> |
| | {message.status === 'pending' && typeof message.uploadProgress === 'number' && <UploadProgress progress={message.uploadProgress} />} |
| | <AudioPlayer message={message} isSender={isSender} currentGroup={currentGroup} /> |
| | </div> |
| | )} |
| | {message.fileKey && ( |
| | <SecureMedia message={{ ...message, chatId: message.chatId || '' }} type="file" currentGroup={currentGroup} isSender={isSender}/> |
| | )} |
| | |
| | {isPrivate && ( |
| | <div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1"> |
| | <Lock className="h-3 w-3" /> |
| | Private message to {message.privateFor?.displayName} |
| | </div> |
| | )} |
| | |
| | {message.encryptedText ? <DecryptedContent message={message} currentGroup={currentGroup} t={t} searchQuery={searchQuery} isHighlighted={isHighlighted}/> : message.text && <MessageText text={message.text} searchQuery={searchQuery} isHighlighted={isHighlighted} />} |
| | |
| | {translatedText && ( |
| | <div className="border-t mt-2 pt-2"> |
| | <MessageText text={translatedText} searchQuery={searchQuery} isHighlighted={isHighlighted} /> |
| | </div> |
| | )} |
| | |
| | {message.urlPreviewData && <UrlPreview data={message.urlPreviewData} isSender={isSender} />} |
| | |
| | {message.status === 'pending' && !message.uploadProgress && !message.audioKey && !message.imageKey && !message.videoKey && !message.fileKey && ( |
| | <div className="flex items-center justify-center p-2"> |
| | <Loader2 className="w-4 h-4 animate-spin" /> |
| | </div> |
| | )} |
| | </> |
| | ) |
| | }; |
| | |
| | if (message.isSystemMessage) { |
| | return ( |
| | <div className="flex justify-center items-center my-4"> |
| | <div className="text-xs text-muted-foreground bg-muted px-3 py-1 rounded-full flex items-center gap-2"> |
| | <MessageText text={message.text || ''} searchQuery={searchQuery} isHighlighted={isHighlighted} /> |
| | </div> |
| | </div> |
| | ); |
| | } |
| | |
| | const hasMedia = message.imageKey || message.videoKey || message.fileKey; |
| | const hasAudio = !!message.audioKey; |
| | |
| | const wasCompressed = message.originalSize && message.compressedSize && isSender; |
| | const compressionSavings = wasCompressed ? 100 - (message.compressedSize! / message.originalSize!) * 100 : 0; |
| | const compressionTooltip = wasCompressed |
| | ? `Compressed: ${message.originalSize}b -> ${message.compressedSize}b (${compressionSavings.toFixed(0)}% saved)` |
| | : ""; |
| | |
| | const needsTranslation = message.detectedLang && message.detectedLang !== language; |
| | const canTranslate = !isSender && message.text && needsTranslation && !translatedText; |
| |
|
| | const reactionsTooltipContent = reactions |
| | .map(r => { |
| | const codePoint = emojiToCodePoint(r.emoji); |
| | return `<div class='flex items-center gap-1.5 p-1'> |
| | <img src="https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${codePoint}.png" alt="${r.emoji}" class="w-4 h-4" /> |
| | <span class='font-medium'>${r.users.map(u => u.displayName === currentUser?.displayName ? t('you') : u.displayName).join(', ')}</span> |
| | </div>`; |
| | }) |
| | .join(''); |
| |
|
| |
|
| | return ( |
| | <div |
| | id={`message-${message.id}`} |
| | className={cn( |
| | "flex items-end gap-2 my-2 group", |
| | !justConfirmed && "message-anim", |
| | isDeleting && 'disintegrating' |
| | )} |
| | style={{ '--transform-origin': isSender ? 'bottom right' : 'bottom left' } as React.CSSProperties} |
| | > |
| | <Avatar className="h-8 w-8 self-end shadow-sm"> |
| | <AvatarImage src={isSender ? currentUser?.photoURL : message.senderPhotoUrl} /> |
| | <AvatarFallback>{message.senderDisplayName ? message.senderDisplayName.charAt(0) : '?'}</AvatarFallback> |
| | </Avatar> |
| | <div className={cn( |
| | "flex flex-col gap-1 w-fit items-start", |
| | isSender ? 'items-end' : 'items-start', |
| | "max-w-xs md:max-w-md" |
| | )}> |
| | |
| | <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}> |
| | <PopoverTrigger asChild> |
| | <div |
| | onMouseDown={handleLongPress} |
| | onMouseUp={handlePressEnd} |
| | onTouchStart={handleLongPress} |
| | onTouchEnd={handlePressEnd} |
| | onContextMenu={(e) => e.preventDefault()} |
| | className={cn( |
| | "relative cursor-pointer", |
| | hasAudio ? "bg-transparent p-0" : |
| | isSender && !message.isDeleted && "bubble-sent", |
| | !isSender && !message.isDeleted && "bubble-received", |
| | message.isDeleted && 'bg-transparent text-muted-foreground', |
| | !hasMedia && !hasAudio && 'bubble-content' |
| | )} |
| | > |
| | <div className={cn( |
| | "text-left relative", |
| | (hasMedia || hasAudio) && !message.text && !message.compressedText && !isEditing ? 'p-0' : '', |
| | !hasMedia && !hasAudio && 'p-0', |
| | )}> |
| | {!isSender && !message.isDeleted && !hasAudio && <p className="text-xs font-bold mb-1 text-primary px-3 pt-2">{message.senderDisplayName}</p>} |
| | <div className={cn(!hasAudio && !hasMedia && "px-3 pb-2", hasAudio && "p-0", !isSender && "pt-0")}> |
| | {renderMessageContent()} |
| | </div> |
| | </div> |
| | </div> |
| | </PopoverTrigger> |
| | {canInteract && ( |
| | <PopoverContent className="w-auto p-0 bg-transparent border-0 shadow-none -translate-y-2"> |
| | <div className="reaction-picker flex items-center gap-1 p-1"> |
| | {reactionEmojis.map(emoji => ( |
| | <Button key={emoji} variant="ghost" size="icon" className="rounded-full w-9 h-9 transition-transform hover:scale-125" onClick={() => handleReactionSelect(emoji)}> |
| | <Image src={`https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${emojiToCodePoint(emoji)}.png`} alt={emoji} width={24} height={24} /> |
| | </Button> |
| | ))} |
| | <Button variant="ghost" size="icon" className="rounded-full w-9 h-9 transition-transform hover:scale-125" onClick={handleReply}><CornerUpLeft /></Button> |
| | |
| | <DropdownMenu> |
| | <DropdownMenuTrigger asChild> |
| | <Button variant="ghost" size="icon" className="rounded-full w-9 h-9 transition-transform hover:scale-125"><MoreHorizontal /></Button> |
| | </DropdownMenuTrigger> |
| | <DropdownMenuContent> |
| | {currentGroup && !isSender && <DropdownMenuItem onSelect={handlePrivateReply}><Lock className="mr-2 h-4 w-4" /> Reply Privately</DropdownMenuItem>} |
| | {message.text && <DropdownMenuItem onSelect={handleCopy}><Copy className="mr-2 h-4 w-4" /> {t('Copy')}</DropdownMenuItem>} |
| | {canTranslate && <DropdownMenuItem onSelect={() => onTranslate(message)} disabled={isTranslating}>{isTranslating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Languages className="mr-2 h-4 w-4"/>} {t('Translate')}</DropdownMenuItem>} |
| | {isSender && !!message.text && <DropdownMenuItem onSelect={() => setIsEditing(true)}><Pencil className="mr-2 h-4 w-4" /> {t('Edit')}</DropdownMenuItem>} |
| | {isSender && <DropdownMenuItem onSelect={onDelete} className="text-destructive"><Trash2 className="mr-2 h-4 w-4" /> {t('Delete')}</DropdownMenuItem>} |
| | </DropdownMenuContent> |
| | </DropdownMenu> |
| | </div> |
| | </PopoverContent> |
| | )} |
| | </Popover> |
| | {reactions.length > 0 && ( |
| | <TooltipProvider> |
| | <Tooltip> |
| | <TooltipTrigger asChild> |
| | <div className={cn("reactions-container", isSender ? 'justify-end' : 'justify-start')}> |
| | {reactions.map(({ emoji, count }, index) => ( |
| | <div |
| | key={emoji} |
| | className={cn( |
| | "reaction-item", |
| | pulsingReactions.includes(emoji) && 'reaction-pulse-anim' |
| | )} |
| | style={{ animationDelay: `${index * 50}ms` }} |
| | > |
| | <Image src={`https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/${emojiToCodePoint(emoji)}.png`} alt={emoji} width={18} height={18} style={{ verticalAlign: 'middle' }}/> |
| | <span className="text-xs font-bold text-foreground/80">{count}</span> |
| | </div> |
| | ))} |
| | </div> |
| | </TooltipTrigger> |
| | <TooltipContent> |
| | <div dangerouslySetInnerHTML={{ __html: reactionsTooltipContent }} /> |
| | </TooltipContent> |
| | </Tooltip> |
| | </TooltipProvider> |
| | )} |
| | |
| | |
| | |
| | <div className="flex items-center gap-1.5 px-2"> |
| | <MessageStatusIcon /> |
| | <span className="text-xs text-muted-foreground"> |
| | {formattedTimestamp} |
| | </span> |
| | {message.editedAt && <span className="text-xs text-muted-foreground">{t('edited')}</span>} |
| | {wasCompressed && ( |
| | <TooltipProvider> |
| | <Tooltip> |
| | <TooltipTrigger> |
| | <ArrowDownUp className="h-3 w-3 text-muted-foreground hover:text-foreground" /> |
| | </TooltipTrigger> |
| | <TooltipContent> |
| | <p>{compressionTooltip}</p> |
| | </TooltipContent> |
| | </Tooltip> |
| | </TooltipProvider> |
| | )} |
| | </div> |
| | </div> |
| | </div> |
| | ); |
| | } |
| |
|