looood / src /components /message-item.tsx
looda3131's picture
Clean push without any binary history
cc276cc
"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, "&lt;").replace(/>/g, "&gt;");
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>`;
});
}
// 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 `<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;
// 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 <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 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 <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: '' // 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<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>
);
}