mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Improve image loading
This commit is contained in:
@@ -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.
|
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.
|
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".
|
3. The supporting text slot for MDUI lists is "description".
|
||||||
|
4. When working with lists/sets in states, use the "useImmer" hook.
|
||||||
@@ -6,5 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { PRODUCT_NAME } from "./config";
|
import { PRODUCT_NAME } from "./config";
|
||||||
|
import { enableMapSet } from "immer";
|
||||||
|
|
||||||
document.title = PRODUCT_NAME;
|
document.title = PRODUCT_NAME;
|
||||||
|
enableMapSet();
|
||||||
@@ -238,6 +238,7 @@
|
|||||||
|
|
||||||
.message-attachments {
|
.message-attachments {
|
||||||
padding: 5px 0 0 0;
|
padding: 5px 0 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
.attachment {
|
.attachment {
|
||||||
a {
|
a {
|
||||||
@@ -255,6 +256,45 @@
|
|||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
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;
|
right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-wrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric"
|
|||||||
import { getAuthHeaders } from "../../../auth/api";
|
import { getAuthHeaders } from "../../../auth/api";
|
||||||
import { useAppState } from "../../state";
|
import { useAppState } from "../../state";
|
||||||
import { ub64 } from "../../../utils/utils";
|
import { ub64 } from "../../../utils/utils";
|
||||||
|
import { useImmer } from "use-immer";
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
message: MessageType;
|
message: MessageType;
|
||||||
@@ -31,7 +32,10 @@ interface Rect {
|
|||||||
|
|
||||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||||
const [decryptedFiles, setDecryptedFiles] = useState<Map<string, string>>(new Map());
|
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<{
|
const [fullscreenImage, setFullscreenImage] = useState<{
|
||||||
src: string;
|
src: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -65,7 +69,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
const decryptedUrl = await decryptFile(file);
|
const decryptedUrl = await decryptFile(file);
|
||||||
console.log(decryptedUrl);
|
console.log(decryptedUrl);
|
||||||
if (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 {
|
try {
|
||||||
|
// no-op decrypt indicator removed from UI
|
||||||
// Fetch encrypted file
|
// Fetch encrypted file
|
||||||
const response = await fetch(file.path, {
|
const response = await fetch(file.path, {
|
||||||
headers: getAuthHeaders(user.authToken!)
|
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 blob = new Blob([decrypted.buffer as ArrayBuffer]);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
setDecryptedFiles(prev => new Map(prev).set(file.path, url));
|
updateDecryptedFiles(draft => {
|
||||||
|
draft.set(file.path, url);
|
||||||
|
});
|
||||||
return url;
|
return url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt file:", error);
|
console.error("Failed to decrypt file:", error);
|
||||||
return null;
|
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;
|
if (!fullscreenImage) return;
|
||||||
const { src, name } = fullscreenImage;
|
const { src, name } = fullscreenImage;
|
||||||
try {
|
try {
|
||||||
|
setIsDownloadingFullscreen(true);
|
||||||
if (src.startsWith("blob:")) {
|
if (src.startsWith("blob:")) {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = src;
|
link.href = src;
|
||||||
link.download = name;
|
link.download = name;
|
||||||
link.click();
|
link.click();
|
||||||
|
setIsDownloadingFullscreen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +227,16 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setIsDownloadingFullscreen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadFile = async (file: Attachment) => {
|
const downloadFile = async (file: Attachment) => {
|
||||||
try {
|
try {
|
||||||
|
updateDownloadingPaths(draft => {
|
||||||
|
draft.add(file.path);
|
||||||
|
});
|
||||||
// Prefer decrypted URL if present (DM encrypted case)
|
// Prefer decrypted URL if present (DM encrypted case)
|
||||||
const decrypted = decryptedFiles.get(file.path);
|
const decrypted = decryptedFiles.get(file.path);
|
||||||
if (decrypted) {
|
if (decrypted) {
|
||||||
@@ -226,6 +244,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
link.href = decrypted;
|
link.href = decrypted;
|
||||||
link.download = file.name || "file";
|
link.download = file.name || "file";
|
||||||
link.click();
|
link.click();
|
||||||
|
updateDownloadingPaths(draft => {
|
||||||
|
draft.delete(file.path);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +265,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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 isEncryptedDm = Boolean(isDm && file.encrypted);
|
||||||
const decryptedUrl = decryptedFiles.get(file.path);
|
const decryptedUrl = decryptedFiles.get(file.path);
|
||||||
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
|
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
|
||||||
|
const isDownloading = downloadingPaths.has(file.path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attachment" key={idx}>
|
<div className="attachment" key={idx}>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
imageSrc ? (
|
<div className="image-wrapper">
|
||||||
<img
|
<img
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) imageRefs.current.set(file.path, el);
|
if (el) imageRefs.current.set(file.path, el);
|
||||||
@@ -316,13 +342,15 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={file.name || "image"}
|
alt={file.name || "image"}
|
||||||
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
onClick={(e) => handleImageClick(file, e.currentTarget)}
|
||||||
className="attachement-image"
|
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
|
||||||
|
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
{!loadedImages.has(file.path) && (
|
||||||
<mdui-list-item>
|
<div className="loading-overlay">
|
||||||
Decrypting image...
|
<mdui-circular-progress />
|
||||||
</mdui-list-item>
|
</div>
|
||||||
)
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@@ -331,8 +359,11 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
await downloadFile(file);
|
await downloadFile(file);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<mdui-list-item icon="download--filled">
|
<mdui-list-item>
|
||||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
<span className="with-icon-gap">
|
||||||
|
{isDownloading ? <mdui-circular-progress /> : null}
|
||||||
|
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||||
|
</span>
|
||||||
</mdui-list-item>
|
</mdui-list-item>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -372,7 +403,13 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
/>
|
/>
|
||||||
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||||
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
||||||
<mdui-button-icon icon="download" onClick={downloadImage} />
|
{isDownloadingFullscreen ? (
|
||||||
|
<div className="progress-wrapper">
|
||||||
|
<mdui-circular-progress />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user