import { formatTime, id } from "@/utils/utils"; import type { Attachment, Message as MessageType, Reaction } from "@/core/types"; import defaultAvatar from "@/images/default-avatar.png"; import Quote from "@/core/components/Quote"; import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; import api from "@/core/api"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; import { createPortal } from "react-dom"; import { parseProfileLink } from "@/core/profileLinks"; import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; import styles from "@/pages/chat/css/Message.module.scss"; import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss"; interface MessageReactionsProps { reactions?: Reaction[]; onReactionClick: (emoji: string) => void; messageId?: number; // Add messageId to ensure unique keys } function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { const { user } = useUserStore(); const [visibleReactions, setVisibleReactions] = useState([]); const [animatingReactions, setAnimatingReactions] = useState>(new Set()); const [isVisible, setIsVisible] = useState(false); // Handle reactions with animation useEffect(() => { if (!reactions || reactions.length === 0) { // If we have visible reactions, animate them out if (visibleReactions.length > 0) { visibleReactions.forEach(reaction => { setAnimatingReactions(prev => new Set(prev).add(reaction.emoji)); }); // After animation completes, hide the component setTimeout(() => { setVisibleReactions([]); setAnimatingReactions(new Set()); setIsVisible(false); }, 200); } else { // No visible reactions, hide immediately setIsVisible(false); } return; } // Show the component when we have reactions setIsVisible(true); // Deduplicate reactions by emoji (safety measure) const uniqueReactions = reactions.reduce((acc, reaction) => { const existing = acc.find(r => r.emoji === reaction.emoji); if (existing) { // Keep the one with the higher count if (reaction.count > existing.count) { acc[acc.indexOf(existing)] = reaction; } } else { acc.push(reaction); } return acc; }, [] as Reaction[]); // Animate out removed reactions visibleReactions.forEach(reaction => { if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) { setAnimatingReactions(prev => new Set(prev).add(reaction.emoji)); setTimeout(() => { setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji)); setAnimatingReactions(prev => { const newSet = new Set(prev); newSet.delete(reaction.emoji); return newSet; }); }, 200); } }); // Update existing reactions and add new ones setVisibleReactions(prev => { const updated = [...prev]; // Update existing reactions uniqueReactions.forEach(reaction => { const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji); if (existingIndex !== -1) { updated[existingIndex] = reaction; } else { // Add new reaction only if it doesn't already exist if (!updated.some(r => r.emoji === reaction.emoji)) { updated.push(reaction); } } }); return updated; }); }, [reactions]); // Don't render if not visible if (!isVisible) { return null; } return (
{visibleReactions.map((reaction, index) => { const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id); const isAnimating = animatingReactions.has(reaction.emoji); return ( ); })}
); } interface MessageProps { message: MessageType; isAuthor: boolean; onContextMenu: (e: React.MouseEvent, message: MessageType) => void; onReactionClick?: (messageId: number, emoji: string) => void; isDm?: boolean; dmRecipientPublicKey?: string; } interface Rect { left: number; top: number; width: number; height: number } export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) { const [decryptedFiles, updateDecryptedFiles] = useImmer>(new Map()); const [loadedImages, updateLoadedImages] = useImmer>(new Set()); const [downloadingPaths, updateDownloadingPaths] = useImmer>(new Set()); const [isDownloadingFullscreen, setIsDownloadingFullscreen] = useState(false); const [fullscreenImage, setFullscreenImage] = useState<{ src: string; name: string; element: HTMLImageElement; startRect: Rect; endRect: Rect; } | null>(null); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); const { user } = useUserStore(); const { setProfileDialog } = useProfileStore(); const imageRefs = useRef>(new Map()); const dmEnvelope = message.runtimeData?.dmEnvelope; const formattedMessage = useMemo(() => { // First, temporarily replace existing fromchat.ru links to avoid conflicts const linkPlaceholders: string[] = []; let content = escapeHtml(message.content).replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`; linkPlaceholders.push(match); return placeholder; }); // Now process @mentions that aren't in existing links content = content.replace(/@([a-zA-Z0-9_.-]+)/g, (match, username) => { return `${match}`; }); // Restore the original links linkPlaceholders.forEach((link, index) => { content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link); }); const rendered = parse(content, { async: false }).trim(); return { __html: rendered }; }, [message.content, styles.mentionLink]); // Auto-decrypt images in DMs useEffect(() => { if (isDm && message.files) { message.files.forEach(async (file) => { const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); if (isImage && file.encrypted && !decryptedFiles.has(file.path)) { const decryptedUrl = await decryptFile(file); if (decryptedUrl) { updateDecryptedFiles(draft => { draft.set(file.path, decryptedUrl); }); } } }); } }, [message.files, isDm, decryptedFiles]); async function decryptFile(file: Attachment): Promise { if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; // Check if already decrypted if (decryptedFiles.has(file.path)) { return decryptedFiles.get(file.path) || null; } try { // no-op decrypt indicator removed from UI // Fetch encrypted file const response = await fetch(file.path, { headers: api.user.auth.getAuthHeaders(user.authToken!) }); if (!response.ok) throw new Error("Failed to fetch file"); const encryptedData = await response.arrayBuffer(); // Get current user's keys const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); // Derive shared secret with the recipient's public key const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey)); // Derive wrapping key using the salt from the DM envelope const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1])); const wk = await importAesGcmKey(wkRaw); // Unwrap the message key const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk)); // Decrypt the file using the message key const iv = new Uint8Array(encryptedData, 0, 12); const ciphertext = new Uint8Array(encryptedData, 12); const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext); // Create blob URL for download const blob = new Blob([decrypted.buffer as ArrayBuffer]); const url = URL.createObjectURL(blob); updateDecryptedFiles(draft => { draft.set(file.path, url); }); return url; } catch (error) { console.error("Failed to decrypt file:", error); return null; } finally { // no-op decrypt indicator removed from UI } }; async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) { // Use decrypted URL if available, otherwise decrypt first const decryptedUrl = decryptedFiles.get(file.path); if (decryptedUrl) { openFullscreenFromThumb(imageElement, decryptedUrl, file.name || "image"); } else if (file.encrypted && isDm) { const newDecryptedUrl = await decryptFile(file); if (newDecryptedUrl) { openFullscreenFromThumb(imageElement, newDecryptedUrl, file.name || "image"); } } else { openFullscreenFromThumb(imageElement, file.path, file.name || "image"); } }; function computeEndRect(naturalWidth: number, naturalHeight: number): Rect { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const maxWidth = Math.floor(viewportWidth * 0.9); const maxHeight = Math.floor(viewportHeight * 0.9); const widthRatio = maxWidth / naturalWidth; const heightRatio = maxHeight / naturalHeight; const scale = Math.min(widthRatio, heightRatio, 1); const width = Math.round(naturalWidth * scale); const height = Math.round(naturalHeight * scale); const left = Math.round((viewportWidth - width) / 2); const top = Math.round((viewportHeight - height) / 2); return { left, top, width, height }; }; function openFullscreenFromThumb(imgEl: HTMLImageElement, src: string, name: string) { const rect = imgEl.getBoundingClientRect(); const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; const tempImg = new Image(); tempImg.src = src; // Hide original while animating imgEl.style.visibility = "hidden"; tempImg.onload = () => { const endRect = computeEndRect(tempImg.naturalWidth, tempImg.naturalHeight); setFullscreenImage({ src, name, element: imgEl, startRect, endRect }); // Start animation on next frame to ensure DOM has overlay mounted requestAnimationFrame(() => setIsAnimatingOpen(true)); }; }; function closeFullscreen() { // Reverse animation setIsAnimatingOpen(false); // Wait for transition to finish setTimeout(() => { if (fullscreenImage?.element) { fullscreenImage.element.style.visibility = "visible"; } setFullscreenImage(null); }, 300); }; async function downloadImage() { if (!fullscreenImage) return; const { src, name } = fullscreenImage; try { setIsDownloadingFullscreen(true); if (src.startsWith("blob:")) { const link = document.createElement("a"); link.href = src; link.download = name; link.click(); setIsDownloadingFullscreen(false); return; } // Fetch with credentials/headers when not a blob URL const response = await fetch(src, { headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined, credentials: "include" }); if (!response.ok) throw new Error("Failed to download image"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url); } catch (e) { console.error(e); } finally { setIsDownloadingFullscreen(false); } }; async function downloadFile(file: Attachment) { try { updateDownloadingPaths(draft => { draft.add(file.path); }); // Prefer decrypted URL if present (DM encrypted case) const decrypted = decryptedFiles.get(file.path); if (decrypted) { const link = document.createElement("a"); link.href = decrypted; link.download = file.name || "file"; link.click(); updateDownloadingPaths(draft => { draft.delete(file.path); }); return; } // If not decrypted or public file, fetch with credentials/headers const response = await fetch(file.path, { headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined, credentials: "include" }); if (!response.ok) throw new Error("Failed to download file"); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = file.name || "file"; link.click(); URL.revokeObjectURL(url); } catch (e) { console.error(e); } finally { updateDownloadingPaths(draft => { draft.delete(file.path); }); } }; async function handleProfileClick() { if (!user.authToken || !message.user_id) return; try { const userProfile = await api.user.profile.fetchById(user.authToken, message.user_id); if (userProfile) { setProfileDialog({ ...userProfile, userId: userProfile.id, memberSince: userProfile.created_at, isOwnProfile: false }); } } catch (error) { console.error("Failed to fetch user profile:", error); } } async function handleLinkClick(e: React.MouseEvent) { if (e.target.tagName === 'A') { const link = (e.target as unknown as HTMLAnchorElement).href; const profileLink = parseProfileLink(link); if (profileLink) { e.preventDefault(); e.stopPropagation(); if (!user.authToken) return; try { let userProfile; if (profileLink.userId) { userProfile = await api.user.profile.fetchById(user.authToken, profileLink.userId); } else if (profileLink.username) { userProfile = await api.user.profile.fetchByUsername(user.authToken, profileLink.username); } if (userProfile) { setProfileDialog({ ...userProfile, userId: userProfile.id, memberSince: userProfile.created_at, isOwnProfile: userProfile.id === user.currentUser?.id }); } else { throw new Error(`Invalid link: ${link}`); } } catch (error) { console.error("Failed to fetch user profile from link:", error); } } } } function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); onContextMenu(e, message); } const messageText = message.content.trim(); const isEmojiMessage = useMemo(() => { const emojiRegex = /^[\s\p{Emoji}]*$/u; return messageText.length > 0 && emojiRegex.test(messageText); }, [messageText]); // Check if message has only one emoji const isSingleEmojiMessage = useMemo(() => { const emojiRegex = /^[\p{Emoji}]+$/u; return messageText.length > 0 && emojiRegex.test(messageText) && messageText.length <= 4; // Most emojis are 1-4 characters }, [messageText]); return ( <>
{!isAuthor && !isDm && (
{message.username} { e.target.src = defaultAvatar; }} />
)}
{!isAuthor && !isDm && !isSingleEmojiMessage && (
{message.username}
)} {message.reply_to && ( {message.reply_to.username} {message.reply_to.content} )}
{message.files && message.files.length > 0 && ( {message.files.map((file, idx) => { const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); const isEncryptedDm = Boolean(isDm && file.encrypted); const decryptedUrl = decryptedFiles.get(file.path); const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined; const isDownloading = downloadingPaths.has(file.path); const isSending = message.runtimeData?.sendingState?.status === 'sending'; return (
{isImage ? (
{ if (el) imageRefs.current.set(file.path, el); }} src={imageSrc} alt={file.name || "image"} onClick={(e) => handleImageClick(file, e.currentTarget)} onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} className={`${styles.attachementImage} ${loadedImages.has(file.path) ? "" : styles.loading}`} /> {(!loadedImages.has(file.path) || isSending) && (
)}
) : ( { e.preventDefault(); await downloadFile(file); }} > {isDownloading ? : null} {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} )}
); })}
)} onReactionClick?.(message.id, emoji)} messageId={message.id} />
{formatTime(message.timestamp)} {message.is_edited ? " (edited)" : undefined} {isAuthor && message.is_read && ( )} {isAuthor && message.runtimeData?.sendingState && ( {message.runtimeData.sendingState.status === 'sending' && ( )} {message.runtimeData.sendingState.status === 'failed' && ( error )} {message.runtimeData.sendingState.status === 'sent' && ( check )} )}
{/* Fullscreen Image Viewer with shared-element like transition */} {fullscreenImage && createPortal(
{fullscreenImage.name} e.stopPropagation()} />
e.stopPropagation()}> {isDownloadingFullscreen ? (
) : ( )}
, id("root") )} ); }