From c89f0a55306eb0a400f5ecd2f858e0b838c75c20 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 26 Sep 2025 14:58:51 +0300 Subject: [PATCH] Improve image loading --- .cursor/rules/ui.mdc | 3 +- frontend/src/core/init.ts | 4 +- frontend/src/resources/css/_chat.scss | 48 ++++++++++++++++ frontend/src/ui/components/chat/Message.tsx | 63 ++++++++++++++++----- 4 files changed, 103 insertions(+), 15 deletions(-) diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc index c414568..a49b459 100644 --- a/.cursor/rules/ui.mdc +++ b/.cursor/rules/ui.mdc @@ -6,4 +6,5 @@ When you work with UI: 1. Use MDUI components as HTML elements with the name "mdui-***". In JSX/TSX use the same elements and props as in HTML. 2. Do NOT dynamically create HTML if it's going to be loaded when the page loads, instead put it statically in the HTML. -3. The supporting text slot for MDUI lists is "description". \ No newline at end of file +3. The supporting text slot for MDUI lists is "description". +4. When working with lists/sets in states, use the "useImmer" hook. \ No newline at end of file diff --git a/frontend/src/core/init.ts b/frontend/src/core/init.ts index e18753e..44c4079 100644 --- a/frontend/src/core/init.ts +++ b/frontend/src/core/init.ts @@ -6,5 +6,7 @@ */ import { PRODUCT_NAME } from "./config"; +import { enableMapSet } from "immer"; -document.title = PRODUCT_NAME; \ No newline at end of file +document.title = PRODUCT_NAME; +enableMapSet(); \ No newline at end of file diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 3d35ecf..c4d30f7 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -238,6 +238,7 @@ .message-attachments { padding: 5px 0 0 0; + overflow: hidden; .attachment { a { @@ -255,6 +256,45 @@ &:last-child { margin-bottom: 0; } + &.loading { + filter: blur(10px); + transition: filter 200ms ease; + } + } + + .attachement-image.placeholder { + background: $color-dark-surface-container-highest; + pointer-events: none; + } + + .image-wrapper { + position: relative; + display: inline-block; + } + + .loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.08); + backdrop-filter: blur(6px); + border-radius: 8px; + } + + .preload-image { + position: absolute; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; + } + + .with-icon-gap { + display: inline-flex; + align-items: center; + gap: 8px; } } } @@ -556,4 +596,12 @@ right: 12px; } } + + .progress-wrapper { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + } } \ No newline at end of file diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index b3098e1..0e27b5c 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -11,6 +11,7 @@ import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric" import { getAuthHeaders } from "../../../auth/api"; import { useAppState } from "../../state"; import { ub64 } from "../../../utils/utils"; +import { useImmer } from "use-immer"; interface MessageProps { message: MessageType; @@ -31,7 +32,10 @@ interface Rect { 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 [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; @@ -65,7 +69,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo const decryptedUrl = await decryptFile(file); console.log(decryptedUrl); if (decryptedUrl) { - setDecryptedFiles(prev => new Map(prev).set(file.path, decryptedUrl)); + updateDecryptedFiles(draft => { + draft.set(file.path, decryptedUrl); + }); } } }); @@ -85,6 +91,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } try { + // no-op decrypt indicator removed from UI // Fetch encrypted file const response = await fetch(file.path, { headers: getAuthHeaders(user.authToken!) @@ -116,11 +123,15 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo const blob = new Blob([decrypted.buffer as ArrayBuffer]); const url = URL.createObjectURL(blob); - setDecryptedFiles(prev => new Map(prev).set(file.path, url)); + 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 } }; @@ -191,11 +202,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo 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; } @@ -214,11 +227,16 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo URL.revokeObjectURL(url); } catch (e) { console.error(e); + } finally { + setIsDownloadingFullscreen(false); } }; const downloadFile = async (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) { @@ -226,6 +244,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo link.href = decrypted; link.download = file.name || "file"; link.click(); + updateDownloadingPaths(draft => { + draft.delete(file.path); + }); return; } @@ -244,6 +265,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo URL.revokeObjectURL(url); } catch (e) { console.error(e); + } finally { + updateDownloadingPaths(draft => { + draft.delete(file.path); + }); } }; @@ -304,11 +329,12 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo 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); return (
{isImage ? ( - imageSrc ? ( +
{ if (el) imageRefs.current.set(file.path, el); @@ -316,13 +342,15 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo src={imageSrc} alt={file.name || "image"} onClick={(e) => handleImageClick(file, e.currentTarget)} - className="attachement-image" + onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} + className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`} /> - ) : ( - - Decrypting image... - - ) + {!loadedImages.has(file.path) && ( +
+ +
+ )} +
) : ( - - {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} + + + {isDownloading ? : null} + {(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")} + )} @@ -372,7 +403,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo />
e.stopPropagation()}> - + {isDownloadingFullscreen ? ( +
+ +
+ ) : ( + + )}
)}