SHA256
НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений
This commit is contained in:
+154
-11
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user