mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement image preview
This commit is contained in:
@@ -256,7 +256,7 @@ async def dm_send(
|
|||||||
provided = None
|
provided = None
|
||||||
original_name = provided or Path(file.filename or "file").name
|
original_name = provided or Path(file.filename or "file").name
|
||||||
# Save using provided/original name to allow client to reference path directly
|
# 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_name = f"{current_user.id}_{env.recipient_id}_{env.id}_{safe_name}"
|
||||||
out_path = FILES_ENCRYPTED_DIR / out_name
|
out_path = FILES_ENCRYPTED_DIR / out_name
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ async def dm_send(
|
|||||||
sender_id=current_user.id,
|
sender_id=current_user.id,
|
||||||
recipient_id=env.recipient_id,
|
recipient_id=env.recipient_id,
|
||||||
path=f"/api/uploads/files/encrypted/{out_name}",
|
path=f"/api/uploads/files/encrypted/{out_name}",
|
||||||
name=safe_name
|
name=original_name
|
||||||
)
|
)
|
||||||
db.add(df)
|
db.add(df)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
Vendored
+4
@@ -61,6 +61,10 @@ export interface Message {
|
|||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
reply_to?: Message;
|
reply_to?: Message;
|
||||||
files?: Attachment[];
|
files?: Attachment[];
|
||||||
|
|
||||||
|
runtimeData?: {
|
||||||
|
dmEnvelope?: DmEnvelope;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -506,4 +506,40 @@
|
|||||||
color: $color-dark-on-surface-variant;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -129,8 +129,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
|||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
isLoadingProfile={isLoadingProfile}
|
isLoadingProfile={isLoadingProfile}
|
||||||
isDm={isDm}
|
isDm={isDm}
|
||||||
dmRecipientPublicKey={dmRecipientPublicKey}
|
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||||
dmEnvelope={(message as any).dmEnvelope} />
|
|
||||||
))}
|
))}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { formatTime } from "../../../utils/utils";
|
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 defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
import Quote from "../core/Quote";
|
import Quote from "../core/Quote";
|
||||||
import { parse } from "marked";
|
import { parse } from "marked";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { getCurrentKeys } from "../../../auth/crypto";
|
import { getCurrentKeys } from "../../../auth/crypto";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
|
||||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
|
||||||
@@ -20,17 +20,29 @@ interface MessageProps {
|
|||||||
isLoadingProfile?: boolean;
|
isLoadingProfile?: boolean;
|
||||||
isDm?: boolean;
|
isDm?: boolean;
|
||||||
dmRecipientPublicKey?: string;
|
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 [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||||
const [decryptedFiles, setDecryptedFiles] = useState<Map<string, string>>(new Map());
|
const [decryptedFiles, setDecryptedFiles] = useState<Map<string, string>>(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 { user } = useAppState();
|
||||||
|
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||||
|
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -42,8 +54,30 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
})();
|
})();
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
const decryptFile = async (file: any): Promise<string | null> => {
|
// Auto-decrypt images in DMs
|
||||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
|
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<string | null> => {
|
||||||
|
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
|
||||||
|
debugger;
|
||||||
|
console.warn("Conditions not met")
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already decrypted
|
// Check if already decrypted
|
||||||
if (decryptedFiles.has(file.path)) {
|
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) {
|
function handleContextMenu(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -97,96 +254,128 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`message ${isAuthor ? "sent" : "received"}`}
|
<div
|
||||||
data-id={message.id}
|
className={`message ${isAuthor ? "sent" : "received"}`}
|
||||||
onContextMenu={handleContextMenu}
|
data-id={message.id}
|
||||||
>
|
onContextMenu={handleContextMenu}
|
||||||
<div className="message-inner">
|
>
|
||||||
{/* Add profile picture for received messages */}
|
<div className="message-inner">
|
||||||
{!isAuthor && !isDm && (
|
{/* Add profile picture for received messages */}
|
||||||
<div className="message-profile-pic">
|
{!isAuthor && !isDm && (
|
||||||
<img
|
<div className="message-profile-pic">
|
||||||
src={message.profile_picture || defaultAvatar}
|
<img
|
||||||
alt={message.username}
|
src={message.profile_picture || defaultAvatar}
|
||||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
alt={message.username}
|
||||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||||
className={isLoadingProfile ? "loading" : ""}
|
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
||||||
onError={(e) => {
|
className={isLoadingProfile ? "loading" : ""}
|
||||||
const target = e.target as HTMLImageElement;
|
onError={(e) => {
|
||||||
target.src = defaultAvatar;
|
const target = e.target as HTMLImageElement;
|
||||||
}}
|
target.src = defaultAvatar;
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{!isAuthor && !isDm && (
|
|
||||||
<div
|
|
||||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
|
||||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
|
||||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
|
||||||
{message.username}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add reply preview if this is a reply */}
|
|
||||||
{message.reply_to && (
|
|
||||||
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
|
|
||||||
<span className="reply-username">{message.reply_to.username}</span>
|
|
||||||
<span className="reply-text">{message.reply_to.content}</span>
|
|
||||||
</Quote>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
|
|
||||||
|
|
||||||
{message.files && message.files.length > 0 && (
|
|
||||||
<mdui-list className="message-attachments">
|
|
||||||
{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 (
|
|
||||||
<div className="attachment" key={idx}>
|
|
||||||
{isImage ? (
|
|
||||||
<img src={file.path} alt={file.name || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} />
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={downloadUrl}
|
|
||||||
download={file.name || "file"}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
onClick={async (e) => {
|
|
||||||
if (file.encrypted && !decryptedFiles.has(file.path)) {
|
|
||||||
e.preventDefault();
|
|
||||||
const decryptedUrl = await decryptFile(file);
|
|
||||||
if (decryptedUrl) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = decryptedUrl;
|
|
||||||
link.download = file.name || "file";
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<mdui-list-item icon="download--filled">
|
|
||||||
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
|
||||||
</mdui-list-item>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</mdui-list>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="message-time">
|
|
||||||
{formatTime(message.timestamp)}
|
|
||||||
{message.is_edited ? " (edited)" : undefined}
|
|
||||||
|
|
||||||
{isAuthor && message.is_read && (
|
|
||||||
<span className="material-symbols outlined"></span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isAuthor && !isDm && (
|
||||||
|
<div
|
||||||
|
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
||||||
|
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
||||||
|
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
||||||
|
{message.username}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add reply preview if this is a reply */}
|
||||||
|
{message.reply_to && (
|
||||||
|
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
|
||||||
|
<span className="reply-username">{message.reply_to.username}</span>
|
||||||
|
<span className="reply-text">{message.reply_to.content}</span>
|
||||||
|
</Quote>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="message-content" dangerouslySetInnerHTML={formattedMessage} />
|
||||||
|
|
||||||
|
{message.files && message.files.length > 0 && (
|
||||||
|
<mdui-list className="message-attachments">
|
||||||
|
{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 (
|
||||||
|
<div className="attachment" key={idx}>
|
||||||
|
{isImage ? (
|
||||||
|
imageSrc ? (
|
||||||
|
<img
|
||||||
|
ref={(el) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<mdui-list-item>
|
||||||
|
Decrypting image...
|
||||||
|
</mdui-list-item>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await downloadFile(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<mdui-list-item icon="download--filled">
|
||||||
|
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
|
||||||
|
</mdui-list-item>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</mdui-list>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="message-time">
|
||||||
|
{formatTime(message.timestamp)}
|
||||||
|
{message.is_edited ? " (edited)" : undefined}
|
||||||
|
|
||||||
|
{isAuthor && message.is_read && (
|
||||||
|
<span className="material-symbols outlined"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Fullscreen Image Viewer with shared-element like transition */}
|
||||||
|
{fullscreenImage && (
|
||||||
|
<div
|
||||||
|
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
|
||||||
|
onClick={closeFullscreen}>
|
||||||
|
<img
|
||||||
|
src={fullscreenImage.src}
|
||||||
|
alt={fullscreenImage.name}
|
||||||
|
className={`fullscreen-animated-image ${isAnimatingOpen ? "to-end" : "to-start"}`}
|
||||||
|
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="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
|
||||||
|
<mdui-button-icon icon="close" onClick={closeFullscreen} />
|
||||||
|
<mdui-button-icon icon="download" onClick={downloadImage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { setGlobalMessageHandler } from "../../../core/websocket";
|
|||||||
import type { Message } from "../../../core/types";
|
import type { Message } from "../../../core/types";
|
||||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||||
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
|
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
|
||||||
|
import type { DMPanel } from "../../panels/DMPanel";
|
||||||
|
|
||||||
interface MessagePanelRendererProps {
|
interface MessagePanelRendererProps {
|
||||||
panel: MessagePanel | null;
|
panel: MessagePanel | null;
|
||||||
@@ -202,7 +203,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
|||||||
<ChatMessages
|
<ChatMessages
|
||||||
messages={panelState.messages}
|
messages={panelState.messages}
|
||||||
isDm={panel.isDm()}
|
isDm={panel.isDm()}
|
||||||
dmRecipientPublicKey={(panel as any).dmData?.publicKey}
|
dmRecipientPublicKey={(panel as DMPanel).dmData?.publicKey}
|
||||||
onReplySelect={(message) => {
|
onReplySelect={(message) => {
|
||||||
if (editMessage || editVisible) {
|
if (editMessage || editVisible) {
|
||||||
setPendingAction({ type: "reply", message: message });
|
setPendingAction({ type: "reply", message: message });
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface DMPanelData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DMPanel extends MessagePanel {
|
export class DMPanel extends MessagePanel {
|
||||||
private dmData: DMPanelData | null = null;
|
public dmData: DMPanelData | null = null;
|
||||||
private messagesLoaded: boolean = false;
|
private messagesLoaded: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -65,7 +65,11 @@ export class DMPanel extends MessagePanel {
|
|||||||
timestamp: env.timestamp,
|
timestamp: env.timestamp,
|
||||||
is_read: false,
|
is_read: false,
|
||||||
is_edited: 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) {
|
if (reply_to_id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user