mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Fix files, delete, edit in DMs
This commit is contained in:
@@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s
|
||||
import { randomBytes } from "../utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "../auth/crypto";
|
||||
import { request } from "../core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, User } from "../core/types";
|
||||
import type { SendDMRequest, DmEnvelope, User, DMEditWebSocketMessage, DmEncryptedJSON, BaseDmEnvelope } from "../core/types";
|
||||
import { b64, ub64 } from "../utils/utils";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
@@ -46,7 +46,7 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string): Promise<void> {
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
@@ -78,4 +79,93 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditWebSocketMessage);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+66
-4
@@ -155,6 +155,7 @@ export interface SendDMRequest {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
// Responses
|
||||
@@ -174,22 +175,54 @@ export interface BackupBlob {
|
||||
blob: string;
|
||||
}
|
||||
|
||||
export interface DmEnvelope {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number;
|
||||
export interface BaseDmEnvelope {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export interface DmEnvelope extends BaseDmEnvelope {
|
||||
id: number;
|
||||
senderId: number;
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
name: string;
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
}
|
||||
|
||||
export interface FetchDMResponse {
|
||||
messages: DmEnvelope[]
|
||||
}
|
||||
|
||||
export interface DmEncryptedJSON {
|
||||
type: "text",
|
||||
data: {
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
files?: Attachment[];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------
|
||||
// WebSocket types
|
||||
// ---------------
|
||||
@@ -231,6 +264,18 @@ export interface WebSocketCredentials {
|
||||
credentials: string;
|
||||
}
|
||||
|
||||
export interface DMEditWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmEdit",
|
||||
data: {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
salt: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
path: string;
|
||||
encrypted: boolean;
|
||||
@@ -239,6 +284,23 @@ export interface Attachment {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// Encrypted message JSON (plaintext structure before encryption)
|
||||
// -----------
|
||||
|
||||
export type ChatMessageKind = "text"; // Extendable for future kinds
|
||||
|
||||
export interface EncryptedTextMessageData {
|
||||
content: string;
|
||||
files?: Attachment[];
|
||||
reply_to_id?: number | null;
|
||||
}
|
||||
|
||||
export interface EncryptedMessageJson {
|
||||
type: ChatMessageKind;
|
||||
data: EncryptedTextMessageData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// React types
|
||||
// -----------
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
|
||||
.quote.reply-preview {
|
||||
user-select: none;
|
||||
margin-bottom: 10px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
|
||||
@@ -18,7 +18,20 @@ interface ChatInputWrapperProps {
|
||||
onCloseEdit?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper({ onSendMessage, onSaveEdit, replyTo, replyToVisible, onClearReply, onCloseReply, editingMessage, editVisible = false, onClearEdit, onCloseEdit }: ChatInputWrapperProps) {
|
||||
export function ChatInputWrapper(
|
||||
{
|
||||
onSendMessage,
|
||||
onSaveEdit,
|
||||
replyTo,
|
||||
replyToVisible,
|
||||
onClearReply,
|
||||
onCloseReply,
|
||||
editingMessage,
|
||||
editVisible = false,
|
||||
onClearEdit,
|
||||
onCloseEdit
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"
|
||||
import { fetchUserProfile } from "../../../api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
import { request } from "../../../core/websocket";
|
||||
import { MaterialDialog } from "../core/Dialog";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
@@ -17,9 +16,11 @@ interface ChatMessagesProps {
|
||||
children?: ReactNode;
|
||||
onReplySelect?: (message: MessageType) => void;
|
||||
onEditSelect?: (message: MessageType) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
dmRecipientPublicKey?: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect }: ChatMessagesProps) {
|
||||
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) {
|
||||
const { messages: hookMessages } = useChat();
|
||||
const { user } = useAppState();
|
||||
|
||||
@@ -38,7 +39,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
|
||||
// Delete dialog
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<number | null>(null);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteDialogOpen) {
|
||||
@@ -88,28 +89,31 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
};
|
||||
|
||||
async function confirmDelete() {
|
||||
if (toBeDeleted) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
try {
|
||||
await request({
|
||||
type: "deleteMessage",
|
||||
data: { message_id: toBeDeleted },
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: user.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message:", error);
|
||||
}
|
||||
|
||||
setDeleteDialogOpen(false);
|
||||
if (!toBeDeleted || !user.authToken) return;
|
||||
try {
|
||||
onDelete?.(toBeDeleted.id);
|
||||
// if (toBeDeleted.isDm) {
|
||||
// // For DM, send dmDelete
|
||||
// await request({
|
||||
// type: "dmDelete",
|
||||
// data: { id: toBeDeleted.id },
|
||||
// credentials: { scheme: "Bearer", credentials: user.authToken }
|
||||
// });
|
||||
// } else {
|
||||
// await request({
|
||||
// type: "deleteMessage",
|
||||
// data: { message_id: toBeDeleted.id },
|
||||
// credentials: { scheme: "Bearer", credentials: user.authToken }
|
||||
// });
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message:", error);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(message: MessageType) {
|
||||
setToBeDeleted(message.id);
|
||||
setToBeDeleted({ id: message.id, isDm });
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
|
||||
@@ -124,7 +128,9 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
|
||||
onProfileClick={handleProfileClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
isLoadingProfile={isLoadingProfile}
|
||||
isDm={isDm} />
|
||||
isDm={isDm}
|
||||
dmRecipientPublicKey={dmRecipientPublicKey}
|
||||
dmEnvelope={(message as any).dmEnvelope} />
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useAppState } from "../../state";
|
||||
import { useDM } from "../../hooks/useDM";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import defaultAvatar from "../../../resources/images/default-avatar.png";
|
||||
|
||||
export function DMPanel() {
|
||||
const { chat } = useAppState();
|
||||
const { sendDMMessage, isLoadingHistory } = useDM();
|
||||
const [message, setMessage] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeDm = chat.activeDm;
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chat.messages]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim() || !activeDm?.publicKey) return;
|
||||
|
||||
try {
|
||||
await sendDMMessage(activeDm.userId, activeDm.publicKey, message);
|
||||
setMessage("");
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileClick = () => {
|
||||
// TODO: Implement profile dialog for DM user
|
||||
console.log("Profile clicked for DM user:", activeDm?.username);
|
||||
};
|
||||
|
||||
if (!activeDm) {
|
||||
return (
|
||||
<div className="chat-main" id="chat-inner">
|
||||
<div className="chat-header">
|
||||
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">Выберите пользователя</h4>
|
||||
<p>
|
||||
<span className="online-status"></span>
|
||||
Выберите пользователя для начала разговора
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Выберите пользователя из списка для начала личных сообщений
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-main" id="chat-inner">
|
||||
<div className="chat-header">
|
||||
<img
|
||||
src={defaultAvatar}
|
||||
alt="Avatar"
|
||||
className="chat-header-avatar"
|
||||
onClick={handleProfileClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{activeDm.username}</h4>
|
||||
<p>
|
||||
<span className="online-status"></span>
|
||||
Личные сообщения
|
||||
</p>
|
||||
</div>
|
||||
<a href="#" id="hide-chat">Свернуть чат</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages" id="chat-messages">
|
||||
{isLoadingHistory ? (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "var(--mdui-color-on-surface-variant)"
|
||||
}}>
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ChatMessages />
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input">
|
||||
<form className="input-group" id="message-form" onSubmit={handleSendMessage}>
|
||||
<input
|
||||
type="text"
|
||||
className="message-input"
|
||||
id="message-input"
|
||||
placeholder="Напишите сообщение..."
|
||||
autoComplete="off"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="send-btn">
|
||||
<span className="material-symbols filled">send</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,10 +38,7 @@ export function DMUsersList() {
|
||||
if (!user.publicKey) {
|
||||
// Get public key if not already loaded
|
||||
const authToken = useAppState.getState().user.authToken;
|
||||
if (!authToken) {
|
||||
console.error("No auth token available");
|
||||
return;
|
||||
}
|
||||
if (!authToken) return;
|
||||
|
||||
const publicKey = await fetchUserPublicKey(user.id, authToken);
|
||||
if (publicKey) {
|
||||
|
||||
@@ -5,6 +5,12 @@ import Quote from "../core/Quote";
|
||||
import { parse } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentKeys } from "../../../auth/crypto";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "../../../utils/crypto/symmetric";
|
||||
import { getAuthHeaders } from "../../../auth/api";
|
||||
import { useAppState } from "../../state";
|
||||
import { ub64 } from "../../../utils/utils";
|
||||
|
||||
interface MessageProps {
|
||||
message: MessageType;
|
||||
@@ -13,10 +19,18 @@ interface MessageProps {
|
||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||
isLoadingProfile?: boolean;
|
||||
isDm?: boolean;
|
||||
dmRecipientPublicKey?: string;
|
||||
dmEnvelope?: {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false }: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: DOMPurify.sanitize(message.content).trim() });
|
||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLoadingProfile = false, isDm = false, dmRecipientPublicKey, dmEnvelope }: MessageProps) {
|
||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||
const [decryptedFiles, setDecryptedFiles] = useState<Map<string, string>>(new Map());
|
||||
const { user } = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -28,6 +42,54 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
})();
|
||||
}, [message]);
|
||||
|
||||
const decryptFile = async (file: any): 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 {
|
||||
// Fetch encrypted file
|
||||
const response = await fetch(file.path, {
|
||||
headers: 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 = 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);
|
||||
|
||||
setDecryptedFiles(prev => new Map(prev).set(file.path, url));
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt file:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
function handleContextMenu(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -81,13 +143,31 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
|
||||
<mdui-list className="message-attachments">
|
||||
{message.files.map((file, idx) => {
|
||||
const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || ""));
|
||||
const downloadUrl = decryptedFiles.get(file.path) || file.path;
|
||||
return (
|
||||
<div className="attachment" key={idx}>
|
||||
{isImage ? (
|
||||
<img src={file.path} alt={file.filename || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} />
|
||||
) : (
|
||||
<a href={file.path} download target="_blank" rel="noreferrer">
|
||||
<mdui-list-item icon="download--filled">{file.filename || file.path.split("/").pop()}</mdui-list-item>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={file.filename || "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.filename || "file";
|
||||
link.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mdui-list-item icon="download--filled">{(file.filename || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}</mdui-list-item>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -153,6 +153,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
<ChatMessages
|
||||
messages={panelState.messages}
|
||||
isDm={panel.isDm()}
|
||||
dmRecipientPublicKey={(panel as any).dmData?.publicKey}
|
||||
onReplySelect={(message) => {
|
||||
if (editMessage || editVisible) {
|
||||
setPendingAction({ type: "reply", message: message });
|
||||
@@ -169,6 +170,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
|
||||
setEditMessage(message);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => panel.handleDeleteMessage(id)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
decryptDm,
|
||||
sendDMViaWebSocket
|
||||
} from "../../api/dmApi";
|
||||
import type { User, Message } from "../../core/types";
|
||||
import type { User, Message, DmEncryptedJSON } from "../../core/types";
|
||||
import { websocket } from "../../core/websocket";
|
||||
|
||||
interface DMUser extends User {
|
||||
@@ -41,7 +41,8 @@ export function useDM() {
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = await decryptDm(lastMessage, publicKey);
|
||||
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
||||
console.log(lastPlaintext);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
@@ -93,50 +94,7 @@ export function useDM() {
|
||||
// Load last messages and unread counts for visible users
|
||||
// Call loadUserLastMessage directly without dependency
|
||||
for (const dmUser of dmUsersWithState) {
|
||||
if (!user.authToken) continue;
|
||||
|
||||
try {
|
||||
// Get public key
|
||||
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
|
||||
if (!publicKey) continue;
|
||||
|
||||
// Get message history
|
||||
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
|
||||
if (messages.length === 0) continue;
|
||||
|
||||
// Find last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let lastPlaintext: string | null = null;
|
||||
|
||||
try {
|
||||
lastPlaintext = await decryptDm(lastMessage, publicKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt last message:", error);
|
||||
}
|
||||
|
||||
// Calculate unread count
|
||||
const lastReadId = getLastReadId(dmUser.id);
|
||||
let unreadCount = 0;
|
||||
for (const msg of messages) {
|
||||
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
|
||||
unreadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user state
|
||||
setDmUsersState(prev => prev.map(u =>
|
||||
u.id === dmUser.id
|
||||
? {
|
||||
...u,
|
||||
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
|
||||
unreadCount,
|
||||
publicKey
|
||||
}
|
||||
: u
|
||||
));
|
||||
} catch (error) {
|
||||
console.error("Failed to load last message for user:", dmUser.id, error);
|
||||
}
|
||||
await loadUserLastMessage(dmUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM users:", error);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel";
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "../../api/dmApi";
|
||||
import type { Message, WebSocketMessage } from "../../core/types";
|
||||
import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types";
|
||||
import type { UserState } from "../state";
|
||||
|
||||
export interface DMPanelData {
|
||||
@@ -21,11 +23,9 @@ export class DMPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
user: UserState,
|
||||
callbacks: MessagePanelCallbacks,
|
||||
onStateChange: (state: MessagePanelState) => void
|
||||
user: UserState
|
||||
) {
|
||||
super("dm", user, callbacks, onStateChange);
|
||||
super("dm", user);
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
@@ -42,6 +42,45 @@ export class DMPanel extends MessagePanel {
|
||||
// DM doesn't need special cleanup
|
||||
}
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
|
||||
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
|
||||
let content = plaintext;
|
||||
let reply_to_id: number | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
|
||||
if (obj && obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = {
|
||||
id: env.id,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||
dmEnvelope: {
|
||||
salt: env.salt,
|
||||
iv2: env.iv2,
|
||||
wrappedMk: env.wrappedMk
|
||||
}
|
||||
};
|
||||
|
||||
if (reply_to_id) {
|
||||
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||
if (referenced) dmMsg.reply_to = referenced;
|
||||
}
|
||||
|
||||
return dmMsg;
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
|
||||
|
||||
@@ -53,18 +92,8 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const text = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
|
||||
decryptedMessages.push({
|
||||
id: env.id,
|
||||
content: text,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
decryptedMessages.push(dmMsg);
|
||||
|
||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
@@ -89,19 +118,27 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(content: string, _replyToId?: number, files: File[] = []): Promise<void> {
|
||||
async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? undefined
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
|
||||
if (files.length === 0) {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
content,
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
} else {
|
||||
const json = JSON.stringify({ type: "text", data: { content: content.trim() } });
|
||||
await sendDmWithFiles(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
@@ -130,25 +167,16 @@ export class DMPanel extends MessagePanel {
|
||||
// Handle incoming WebSocket DM messages
|
||||
handleWebSocketMessage = async (response: WebSocketMessage): Promise<void> => {
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
const { senderId, recipientId, ...envelope } = response.data;
|
||||
const envelope = response.data as DmEnvelope;
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (senderId === this.dmData.userId || recipientId === this.dmData.userId) {
|
||||
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
||||
try {
|
||||
const plaintext = await decryptDm(envelope, this.dmData.publicKey);
|
||||
const isAuthor = senderId !== this.dmData.userId;
|
||||
|
||||
this.addMessage({
|
||||
id: envelope.id,
|
||||
content: plaintext,
|
||||
username: isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData.username,
|
||||
timestamp: envelope.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false
|
||||
});
|
||||
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (senderId === this.dmData.userId) {
|
||||
if (envelope.senderId === this.dmData.userId) {
|
||||
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -156,6 +184,43 @@ export class DMPanel extends MessagePanel {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.type === "dmEdited" && this.dmData) {
|
||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await decryptDm(
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
);
|
||||
let content = plaintext;
|
||||
let files: Message["files"] | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
|
||||
if (obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
files = obj.data.files;
|
||||
}
|
||||
} catch {}
|
||||
const updates: Partial<Message> = { content, is_edited: true, files };
|
||||
this.updateMessage(id, updates);
|
||||
} catch (e) {
|
||||
this.updateMessage(id, { is_edited: true });
|
||||
}
|
||||
}
|
||||
if (response.type === "dmDeleted" && this.dmData) {
|
||||
const { id } = response.data;
|
||||
this.removeMessage(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for DM switching
|
||||
@@ -191,4 +256,29 @@ export class DMPanel extends MessagePanel {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async handleDeleteMessage(messageId: number): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
// Fire and forget; UI will update via dmDeleted
|
||||
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
const msg = this.getMessages().find(m => m.id === messageId);
|
||||
// Build encrypted JSON preserving files and reply_to if present
|
||||
const payload: EncryptedMessageJson = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content,
|
||||
files: msg?.files,
|
||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||
}
|
||||
};
|
||||
editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,12 @@ export interface MessagePanelCallbacks {
|
||||
|
||||
export abstract class MessagePanel {
|
||||
protected state: MessagePanelState;
|
||||
protected callbacks: MessagePanelCallbacks;
|
||||
public onStateChange: ((state: MessagePanelState) => void) | null;
|
||||
protected currentUser: UserState;
|
||||
public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
|
||||
protected readonly currentUser: UserState;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
currentUser: UserState,
|
||||
callbacks: MessagePanelCallbacks,
|
||||
onStateChange: (state: MessagePanelState) => void
|
||||
) {
|
||||
this.state = {
|
||||
id,
|
||||
@@ -40,8 +37,6 @@ export abstract class MessagePanel {
|
||||
isTyping: false
|
||||
};
|
||||
this.currentUser = currentUser;
|
||||
this.callbacks = callbacks;
|
||||
this.onStateChange = onStateChange;
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by subclasses
|
||||
@@ -115,23 +110,10 @@ export abstract class MessagePanel {
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
handleSendMessage = (content: string, replyToId?: number, files: File[] = []): void => {
|
||||
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
|
||||
this.sendMessage(content, replyToId, files);
|
||||
};
|
||||
|
||||
handleEditMessage = (messageId: number, content: string): void => {
|
||||
this.callbacks.onEditMessage(messageId, content);
|
||||
};
|
||||
|
||||
handleDeleteMessage = (messageId: number): void => {
|
||||
this.callbacks.onDeleteMessage(messageId);
|
||||
};
|
||||
|
||||
handleReplyToMessage = (messageId: number, content: string): void => {
|
||||
this.callbacks.onReplyToMessage(messageId, content);
|
||||
};
|
||||
|
||||
handleProfileClick = (): void => {
|
||||
this.callbacks.onProfileClick();
|
||||
};
|
||||
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
||||
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
||||
abstract handleProfileClick(): void;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessagePanel, type MessagePanelCallbacks, type MessagePanelState } from "./MessagePanel";
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import { API_BASE_URL } from "../../core/config";
|
||||
import { getAuthHeaders } from "../../auth/api";
|
||||
import { request } from "../../core/websocket";
|
||||
@@ -10,11 +10,9 @@ export class PublicChatPanel extends MessagePanel {
|
||||
|
||||
constructor(
|
||||
chatName: string,
|
||||
currentUser: UserState,
|
||||
callbacks: MessagePanelCallbacks,
|
||||
onStateChange: (state: MessagePanelState) => void
|
||||
currentUser: UserState
|
||||
) {
|
||||
super(`public-${chatName}`, currentUser, callbacks, onStateChange);
|
||||
super(`public-${chatName}`, currentUser);
|
||||
this.updateState({
|
||||
title: chatName,
|
||||
online: true // Public chats are always "online"
|
||||
@@ -137,4 +135,36 @@ export class PublicChatPanel extends MessagePanel {
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken) return;
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: messageId,
|
||||
content: content
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteMessage(id: number): Promise<void> {
|
||||
await request({
|
||||
type: "deleteMessage",
|
||||
data: { message_id: id },
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken!
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
}
|
||||
@@ -277,37 +277,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
// Create or get public chat panel
|
||||
let publicChatPanel = chat.publicChatPanel;
|
||||
if (!publicChatPanel) {
|
||||
const callbacks = {
|
||||
onSendMessage: (_content: string) => {},
|
||||
onEditMessage: async (messageId: number, content: string) => {
|
||||
if (!user.authToken) return;
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: messageId,
|
||||
content: content
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: user.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
},
|
||||
onDeleteMessage: (_messageId: number) => {},
|
||||
onReplyToMessage: (_messageId: number, _content: string) => {},
|
||||
onProfileClick: () => {}
|
||||
};
|
||||
|
||||
publicChatPanel = new PublicChatPanel(
|
||||
chatName,
|
||||
user,
|
||||
callbacks,
|
||||
() => {} // State change handled by MessagePanelRenderer
|
||||
);
|
||||
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||
} else {
|
||||
publicChatPanel.setChatName(chatName);
|
||||
publicChatPanel.setAuthToken(user.authToken);
|
||||
@@ -346,19 +316,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
||||
// Create or get DM panel
|
||||
let dmPanel = chat.dmPanel;
|
||||
if (!dmPanel) {
|
||||
const callbacks = {
|
||||
onSendMessage: (_content: string) => {},
|
||||
onEditMessage: (_messageId: number, _content: string) => {},
|
||||
onDeleteMessage: (_messageId: number) => {},
|
||||
onReplyToMessage: (_messageId: number, _content: string) => {},
|
||||
onProfileClick: () => {}
|
||||
};
|
||||
|
||||
dmPanel = new DMPanel(
|
||||
user,
|
||||
callbacks,
|
||||
() => {} // State change handled by MessagePanelRenderer
|
||||
);
|
||||
dmPanel = new DMPanel(user);
|
||||
} else {
|
||||
dmPanel.setAuthToken(user.authToken);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user