From 8a6b7903af33ff4d84cccc87eaa59df3bf5d5f19 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 25 Sep 2025 23:38:31 +0300 Subject: [PATCH] Implement image preview --- backend/routes/messaging.py | 4 +- frontend/src/core/types.d.ts | 4 + frontend/src/resources/css/_chat.scss | 36 ++ .../src/ui/components/chat/ChatMessages.tsx | 3 +- frontend/src/ui/components/chat/Message.tsx | 385 +++++++++++++----- .../components/chat/MessagePanelRenderer.tsx | 3 +- frontend/src/ui/panels/DMPanel.ts | 8 +- 7 files changed, 338 insertions(+), 105 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 3b09e29..9e6b535 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -256,7 +256,7 @@ async def dm_send( provided = None original_name = provided or Path(file.filename or "file").name # Save using provided/original name to allow client to reference path directly - safe_name = original_name + safe_name = uid = uuid.uuid4().hex out_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}" out_path = FILES_ENCRYPTED_DIR / out_name @@ -270,7 +270,7 @@ async def dm_send( sender_id=current_user.id, recipient_id=env.recipient_id, path=f"/api/uploads/files/encrypted/{out_name}", - name=safe_name + name=original_name ) db.add(df) db.commit() diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index b5214ec..b14d215 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -61,6 +61,10 @@ export interface Message { profile_picture?: string; reply_to?: Message; files?: Attachment[]; + + runtimeData?: { + dmEnvelope?: DmEnvelope; + } } /** diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index ad46b9e..055c66c 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -506,4 +506,40 @@ color: $color-dark-on-surface-variant; } } +} + +// Fullscreen Image Viewer +.fullscreen-image-overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(20px); + z-index: 9999; + opacity: 1; + transition: opacity 0.3s ease; + + &.closing { + opacity: 0; + } + + .fullscreen-animated-image { + position: absolute; + object-fit: contain; + border-radius: 12px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4); + transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease; + } + + .fullscreen-controls { + position: absolute; + display: flex; + gap: 8px; + + &.top-right { + top: 12px; + right: 12px; + } + } } \ No newline at end of file diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index e1e3799..c53dd38 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -129,8 +129,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o onContextMenu={handleContextMenu} isLoadingProfile={isLoadingProfile} isDm={isDm} - dmRecipientPublicKey={dmRecipientPublicKey} - dmEnvelope={(message as any).dmEnvelope} /> + dmRecipientPublicKey={dmRecipientPublicKey} /> ))} {children} diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index ae7588a..0c75e05 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -1,10 +1,10 @@ import { formatTime } from "../../../utils/utils"; -import type { Message as MessageType } from "../../../core/types"; +import type { Attachment, Message as MessageType } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; import Quote from "../core/Quote"; import { parse } from "marked"; import DOMPurify from "dompurify"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { getCurrentKeys } from "../../../auth/crypto"; import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric"; @@ -20,17 +20,29 @@ interface MessageProps { isLoadingProfile?: boolean; isDm?: boolean; dmRecipientPublicKey?: string; - dmEnvelope?: { - salt: string; - iv2: string; - wrappedMk: string; - }; } -export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey, dmEnvelope }: MessageProps) { +interface Rect { + left: number; + top: number; + width: number; + height: number +} + +export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) { const [formattedMessage, setFormattedMessage] = useState({ __html: "" }); const [decryptedFiles, setDecryptedFiles] = useState>(new Map()); + const [fullscreenImage, setFullscreenImage] = useState<{ + src: string; + name: string; + element: HTMLImageElement; + startRect: Rect; + endRect: Rect; + } | null>(null); + const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); const { user } = useAppState(); + const imageRefs = useRef>(new Map()); + const dmEnvelope = message.runtimeData?.dmEnvelope; useEffect(() => { (async () => { @@ -42,8 +54,30 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo })(); }, [message]); - const decryptFile = async (file: any): Promise => { - if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null; + // Auto-decrypt images in DMs + useEffect(() => { + if (isDm && message.files) { + message.files.forEach(async (file) => { + console.log(file); + const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); + if (isImage && file.encrypted && !decryptedFiles.has(file.path)) { + console.log("Decrypting..."); + const decryptedUrl = await decryptFile(file); + console.log(decryptedUrl); + if (decryptedUrl) { + setDecryptedFiles(prev => new Map(prev).set(file.path, decryptedUrl)); + } + } + }); + } + }, [message.files, isDm, decryptedFiles]); + + const decryptFile = async (file: Attachment): Promise => { + if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) { + debugger; + console.warn("Conditions not met") + return null; + } // Check if already decrypted if (decryptedFiles.has(file.path)) { @@ -90,6 +124,129 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } }; + const handleImageClick = async (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"); + } + }; + + const 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 }; + }; + + const 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)); + }; + }; + + const closeFullscreen = () => { + // Reverse animation + setIsAnimatingOpen(false); + // Wait for transition to finish + setTimeout(() => { + if (fullscreenImage?.element) { + fullscreenImage.element.style.visibility = "visible"; + } + setFullscreenImage(null); + }, 300); + }; + + const downloadImage = async () => { + if (!fullscreenImage) return; + const { src, name } = fullscreenImage; + try { + if (src.startsWith("blob:")) { + const link = document.createElement("a"); + link.href = src; + link.download = name; + link.click(); + return; + } + + // Fetch with credentials/headers when not a blob URL + const response = await fetch(src, { + headers: user.authToken ? 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); + } + }; + + const downloadFile = async (file: Attachment) => { + try { + // 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(); + return; + } + + // If not decrypted or public file, fetch with credentials/headers + const response = await fetch(file.path, { + headers: user.authToken ? 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); + } + }; + function handleContextMenu(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -97,96 +254,128 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } return ( -
-
- {/* Add profile picture for received messages */} - {!isAuthor && !isDm && ( -
- {message.username} !isLoadingProfile && onProfileClick(message.username)} - style={{ cursor: isLoadingProfile ? "default" : "pointer" }} - className={isLoadingProfile ? "loading" : ""} - onError={(e) => { - const target = e.target as HTMLImageElement; - target.src = defaultAvatar; - }} - /> -
- )} - - {!isAuthor && !isDm && ( -
!isLoadingProfile && onProfileClick(message.username)} - style={{ cursor: isLoadingProfile ? "default" : "pointer" }}> - {message.username} -
- )} - - {/* Add reply preview if this is a reply */} - {message.reply_to && ( - - {message.reply_to.username} - {message.reply_to.content} - - )} - -
- - {message.files && message.files.length > 0 && ( - - {message.files.map((file, idx) => { - const isImage = !file.encrypted && /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); - const downloadUrl = decryptedFiles.get(file.path) || file.path; - return ( - - ); - })} - - )} - -
- {formatTime(message.timestamp)} - {message.is_edited ? " (edited)" : undefined} - - {isAuthor && message.is_read && ( - + <> +
+
+ {/* Add profile picture for received messages */} + {!isAuthor && !isDm && ( +
+ {message.username} !isLoadingProfile && onProfileClick(message.username)} + style={{ cursor: isLoadingProfile ? "default" : "pointer" }} + className={isLoadingProfile ? "loading" : ""} + onError={(e) => { + const target = e.target as HTMLImageElement; + target.src = defaultAvatar; + }} + /> +
)} + + {!isAuthor && !isDm && ( +
!isLoadingProfile && onProfileClick(message.username)} + style={{ cursor: isLoadingProfile ? "default" : "pointer" }}> + {message.username} +
+ )} + + {/* Add reply preview if this is a reply */} + {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; + + return ( +
+ {isImage ? ( + imageSrc ? ( + { + if (el) imageRefs.current.set(file.path, el); + }} + src={imageSrc} + alt={file.name || "image"} + style={{ maxWidth: "200px", borderRadius: "8px", cursor: "pointer" }} + onClick={(e) => handleImageClick(file, e.currentTarget)} + /> + ) : ( + + Decrypting image... + + ) + ) : ( + { + e.preventDefault(); + await downloadFile(file); + }} + > + + {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} + + + )} +
+ ); + })} +
+ )} + +
+ {formatTime(message.timestamp)} + {message.is_edited ? " (edited)" : undefined} + + {isAuthor && message.is_read && ( + + )} +
-
+ + {/* Fullscreen Image Viewer with shared-element like transition */} + {fullscreenImage && ( +
+ {fullscreenImage.name} e.stopPropagation()} + /> +
e.stopPropagation()}> + + +
+
+ )} + ); } diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 7febc76..cde7c07 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -6,6 +6,7 @@ import { setGlobalMessageHandler } from "../../../core/websocket"; import type { Message } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; import AnimatedOpacity from "../core/animations/AnimatedOpacity"; +import type { DMPanel } from "../../panels/DMPanel"; interface MessagePanelRendererProps { panel: MessagePanel | null; @@ -202,7 +203,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen { if (editMessage || editVisible) { setPendingAction({ type: "reply", message: message }); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index 074c952..dbaed1b 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -19,7 +19,7 @@ export interface DMPanelData { } export class DMPanel extends MessagePanel { - private dmData: DMPanelData | null = null; + public dmData: DMPanelData | null = null; private messagesLoaded: boolean = false; constructor( @@ -65,7 +65,11 @@ export class DMPanel extends MessagePanel { timestamp: env.timestamp, is_read: false, is_edited: false, - files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [] + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [], + + runtimeData: { + dmEnvelope: env + } }; if (reply_to_id) {