НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений

This commit is contained in:
AidarKC
2026-06-18 11:46:58 +04:00
parent 2225c2d173
commit 92fd315505
25 changed files with 1910 additions and 516 deletions
+154 -11
View File
@@ -3,6 +3,7 @@ import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
addChatMessage,
addSignedMessageToChat,
addSystemChatMessage,
addOutgoingPendingMessage,
getChatMessages,
@@ -165,6 +166,35 @@ function resolveDeliveryStatus(msg) {
return '…';
}
function formatFileSize(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value < 1024) return `${Math.max(0, Math.trunc(value))} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function messagePlainText(msg) {
return String(msg?.text || '').trim();
}
async function downloadAttachment(attachment) {
const fileName = String(attachment?.fileName || 'file.bin');
const mime = String(attachment?.mime || 'application/octet-stream');
const plainBytes = await authService.downloadAndDecryptDmAttachment(attachment, state.entrySettings.shineServerHttp);
const blob = new Blob([plainBytes], { type: mime });
const url = URL.createObjectURL(blob);
try {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
} finally {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}
function scrollToLatestMessage(list) {
if (!list) return;
const apply = () => {
@@ -198,9 +228,35 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
const bubbleKind = String(msg?.kind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = msg.text || '';
const plainText = messagePlainText(msg);
if (plainText) {
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = plainText;
bubble.append(textNode);
}
const attachments = Array.isArray(msg?.attachments) ? msg.attachments : [];
if (attachments.length) {
const attachmentsNode = document.createElement('div');
attachmentsNode.className = 'stack';
attachments.forEach((attachment) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary-btn';
btn.textContent = `${attachment?.fileName || 'file'}${formatFileSize(attachment?.origSize || attachment?.encSize || 0)}`;
btn.addEventListener('click', async (event) => {
event.stopPropagation();
try {
await downloadAttachment(attachment);
} catch (error) {
showToast(`Не удалось скачать файл: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
});
attachmentsNode.append(btn);
});
bubble.append(attachmentsNode);
}
const metaNode = document.createElement('div');
metaNode.className = 'bubble-meta';
@@ -218,7 +274,7 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
metaNode.append(statusNode);
}
bubble.append(textNode, metaNode);
bubble.append(metaNode);
bubble.addEventListener('click', () => {
if (typeof onOpenActions === 'function') onOpenActions(msg);
});
@@ -332,17 +388,64 @@ export function render({ navigate, route }) {
const form = document.createElement('form');
form.className = 'chat-input dm-chat-input';
form.innerHTML = `
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea>
<input type="file" id="chat-file-input" multiple hidden />
<div class="stack" id="chat-attachments-preview"></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-voice-btn" type="button" id="chat-file-pick" title="Вложить файлы">📎</button>
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
`;
const fileInput = form.querySelector('#chat-file-input');
const attachmentsPreview = form.querySelector('#chat-attachments-preview');
let pendingFiles = [];
const renderPendingFiles = () => {
if (!attachmentsPreview) return;
attachmentsPreview.innerHTML = '';
pendingFiles.forEach((file, index) => {
const row = document.createElement('div');
row.className = 'meta-muted';
row.textContent = `${file.name}${formatFileSize(file.size)}`;
row.addEventListener('click', () => {
pendingFiles = pendingFiles.filter((_, current) => current !== index);
renderPendingFiles();
});
attachmentsPreview.append(row);
});
};
const buildMessagePayloadText = (plainText, preparedAttachments) => {
const parts = [];
const text = String(plainText || '').trim();
if (text) parts.push(text);
preparedAttachments.forEach((item) => {
parts.push(`<<file:file-format(1.0):${item.type}|${item.fileName}|${item.origSize}|${item.origHashB64u}|${item.encHashB64u}|${item.encSize}|${item.keyB64u}|${item.nonceB64u}>>`);
});
return parts.join('\n');
};
const ensureUploads = async (preparedAttachments) => {
for (const item of preparedAttachments) {
const exists = await authService.headDmFile(item.encHashB64u, state.entrySettings.shineServerHttp);
if (!exists) {
await authService.uploadDmFileCiphertext({
encHashB64u: item.encHashB64u,
encSize: item.encSize,
ciphertextBytes: item.ciphertextBytes,
serverHttpBase: state.entrySettings.shineServerHttp,
});
}
}
};
const sendTextMessage = async (rawText) => {
const text = String(rawText || '').trim();
if (!text) return;
const tempId = addOutgoingPendingMessage(chatId, text);
if (!text && pendingFiles.length === 0) return;
const tempLabel = text || `Файлы: ${pendingFiles.length}`;
const tempId = addOutgoingPendingMessage(chatId, tempLabel);
renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
@@ -357,16 +460,47 @@ export function render({ navigate, route }) {
});
try {
const result = await authService.sendDirectMessage({
const filesToSend = pendingFiles.slice(0, 12);
const preparedAttachments = [];
for (const file of filesToSend) {
preparedAttachments.push(await authService.prepareEncryptedDmAttachment(file));
}
await ensureUploads(preparedAttachments);
const messagePayloadText = buildMessagePayloadText(text, preparedAttachments);
const result = await authService.sendDirectMessageWithAttachments({
login: state.session.login,
toLogin: chatId,
text,
text: messagePayloadText,
storagePwd: state.session.storagePwdInMemory,
attachments: preparedAttachments,
});
pendingFiles = [];
if (fileInput) fileInput.value = '';
renderPendingFiles();
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
});
if (result?.localOutgoingBlobB64) {
try {
const parsed = authService.parseSignedMessageBlob(result.localOutgoingBlobB64);
addSignedMessageToChat({
chatId,
messageKey: result?.outgoingKey || parsed?.messageKey || '',
baseKey: result?.baseKey || parsed?.baseKey || '',
from: 'out',
text: parsed?.text || '',
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: result.localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
attachments: Array.isArray(parsed?.bodyAttachments) ? parsed.bodyAttachments : [],
deleted: Boolean(parsed?.deleted),
});
} catch {
// ignore local parse failure; server backlog/realtime will reconcile later
}
}
renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
@@ -417,6 +551,15 @@ export function render({ navigate, route }) {
};
const input = form.elements.message;
form.querySelector('#chat-file-pick')?.addEventListener('click', () => fileInput?.click());
fileInput?.addEventListener('change', () => {
const selected = Array.from(fileInput.files || []);
if (selected.length > 12) {
showToast('Можно приложить не больше 12 файлов', { kind: 'error', timeoutMs: 1400 });
}
pendingFiles = selected.slice(0, 12);
renderPendingFiles();
});
autoResizeComposer(input);
input?.addEventListener('input', () => autoResizeComposer(input));
input?.addEventListener('focus', () => {
@@ -441,7 +584,7 @@ export function render({ navigate, route }) {
}
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
if (!text && pendingFiles.length === 0) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);
@@ -465,7 +608,7 @@ export function render({ navigate, route }) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
if (!text && pendingFiles.length === 0) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);