Files
web/frontend/src/pages/chat/ui/right/Message.tsx
T
2025-11-26 16:28:18 +03:00

638 lines
28 KiB
TypeScript

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<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(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 (
<div className={styles.messageReactions}>
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
className={`${styles.reactionButton} ${hasUserReacted ? styles.reacted : ""} ${isAnimating ? styles.removing : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className={styles.reactionEmoji}>{reaction.emoji}</span>
<span className={styles.reactionCount}>{reaction.count}</span>
</button>
);
})}
</div>
);
}
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<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
const [downloadingPaths, updateDownloadingPaths] = useImmer<Set<string>>(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<Map<string, HTMLImageElement>>(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 `<a href="https://fromchat.ru/@${username}" class="${styles.mentionLink}">${match}</a>`;
});
// 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<string | null> {
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<HTMLDivElement>) {
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 (
<>
<div
className={`${styles.message} ${isAuthor ? styles.sent : styles.received} ${isEmojiMessage ? styles.emojiMessage : ""} ${isSingleEmojiMessage ? "" : ""}`}
data-id={message.id}
onContextMenu={handleContextMenu}
>
{!isAuthor && !isDm && (
<div className={styles.messageProfilePic} onClick={handleProfileClick}>
<img
src={message.profile_picture || defaultAvatar}
alt={message.username}
onError={(e) => {
e.target.src = defaultAvatar;
}}
/>
</div>
)}
<div className={styles.messageInner}>
{!isAuthor && !isDm && !isSingleEmojiMessage && (
<div
className={styles.messageUsername}
onClick={handleProfileClick}>
{message.username}
<StatusBadge
verified={message.verified || false}
userId={message.user_id}
size="small"
/>
</div>
)}
{message.reply_to && (
<Quote className={`${styles.replyPreview} ${replyPreviewStyles.contextualContent}`} background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className={replyPreviewStyles.replyUsername}>{message.reply_to.username}</span>
<span className={replyPreviewStyles.replyText}>{message.reply_to.content}</span>
</Quote>
)}
<div
className={`${styles.messageContent} ${isEmojiMessage ? styles.emojiContent : ""} ${isSingleEmojiMessage ? styles.singleEmojiContent : ""}`}
dangerouslySetInnerHTML={formattedMessage}
onClick={handleLinkClick} />
{message.files && message.files.length > 0 && (
<MaterialList className={styles.messageAttachments}>
{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 (
<div className={styles.attachment} key={idx}>
{isImage ? (
<div className={styles.imageWrapper}>
<img
ref={(el) => {
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) && (
<div className={styles.loadingOverlay}>
<MaterialCircularProgress />
</div>
)}
</div>
) : (
<a
href="#"
onClick={async (e) => {
e.preventDefault();
await downloadFile(file);
}}
>
<MaterialListItem>
<span className={styles.withIconGap}>
{isDownloading ? <MaterialCircularProgress /> : null}
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</span>
</MaterialListItem>
</a>
)}
</div>
);
})}
</MaterialList>
)}
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
/>
<div className={styles.messageTime}>
{formatTime(message.timestamp)}
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className={styles.messageStatusIndicator}>
{message.runtimeData.sendingState.status === 'sending' && (
<MaterialCircularProgress style={{ width: '16px', height: '16px' }} />
)}
{message.runtimeData.sendingState.status === 'failed' && (
<span className={`material-symbols ${styles.errorIcon}`}>error</span>
)}
{message.runtimeData.sendingState.status === 'sent' && (
<span className={`material-symbols ${styles.successIcon}`}>check</span>
)}
</span>
)}
</div>
</div>
</div>
{/* Fullscreen Image Viewer with shared-element like transition */}
{fullscreenImage && createPortal(
<div
className={`${styles.fullscreenImageOverlay} ${isAnimatingOpen ? "" : styles.closing}`}
onClick={closeFullscreen}>
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
className={styles.fullscreenAnimatedImage}
style={{
left: `${isAnimatingOpen ? fullscreenImage.endRect.left : fullscreenImage.startRect.left}px`,
top: `${isAnimatingOpen ? fullscreenImage.endRect.top : fullscreenImage.startRect.top}px`,
width: `${isAnimatingOpen ? fullscreenImage.endRect.width : fullscreenImage.startRect.width}px`,
height: `${isAnimatingOpen ? fullscreenImage.endRect.height : fullscreenImage.startRect.height}px`
}}
onClick={e => e.stopPropagation()}
/>
<div className={`${styles.fullscreenControls} ${styles.topRight}`} onClick={e => e.stopPropagation()}>
<MaterialIconButton icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className={styles.progressWrapper}>
<MaterialCircularProgress />
</div>
) : (
<MaterialIconButton icon="download" onClick={downloadImage} />
)}
</div>
</div>,
id("root")
)}
</>
);
}