Первая версия вложенных файлов работает

This commit is contained in:
AidarKC
2026-09-11 02:38:02 +03:00
parent ea0098d705
commit f9ae811702
29 changed files with 1463 additions and 57 deletions
+229 -8
View File
@@ -29,7 +29,13 @@ import {
} from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
import {
downloadAndDecryptDmFile,
encryptAndUploadDmFile,
formatDmFileSize,
} from '../services/dm-file-service.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
@@ -686,10 +692,10 @@ function renderLog(
const bubble = document.createElement('div');
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const autoKind = parsedText.callSummary ? 'call-tech' : (parsedText.fileAttachment ? 'file-tech' : '');
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || '');
const emojiOnly = !parsedText.callSummary && !parsedText.fileAttachment && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || '');
if (emojiOnly) {
bubble.classList.add('bubble--emoji-only');
}
@@ -734,6 +740,50 @@ function renderLog(
bubble.append(callCard);
}
if (parsedText.fileAttachment) {
const attachment = parsedText.fileAttachment;
const fileCard = document.createElement('div');
fileCard.className = 'dm-file-card';
const fileIcon = document.createElement('span');
fileIcon.className = 'dm-file-card__icon';
fileIcon.textContent = '📎';
const fileCopy = document.createElement('span');
fileCopy.className = 'dm-file-card__copy';
const fileName = document.createElement('strong');
fileName.className = 'dm-file-card__name';
fileName.textContent = attachment.name || 'file';
const fileMeta = document.createElement('span');
fileMeta.className = 'dm-file-card__meta';
fileMeta.textContent = formatDmFileSize(attachment.size);
fileCopy.append(fileName, fileMeta);
const downloadBtn = document.createElement('button');
downloadBtn.type = 'button';
downloadBtn.className = 'ui-button dm-file-card__download';
downloadBtn.textContent = 'Скачать';
downloadBtn.addEventListener('click', async (event) => {
event.stopPropagation();
if (downloadBtn.disabled) return;
downloadBtn.disabled = true;
const previousLabel = downloadBtn.textContent;
downloadBtn.textContent = 'Скачивание…';
try {
await downloadAndDecryptDmFile(attachment);
showToast('Файл расшифрован и передан браузеру для сохранения', { timeoutMs: 1600 });
} catch (error) {
showToast(error?.message || 'Не удалось скачать файл', { kind: 'error', timeoutMs: 2400 });
} finally {
downloadBtn.disabled = false;
downloadBtn.textContent = previousLabel;
}
});
fileCard.append(fileIcon, fileCopy, downloadBtn);
bubble.append(fileCard);
}
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
renderMessageText(textNode, parsedText.displayText || '');
@@ -764,7 +814,7 @@ function renderLog(
bubble.append(replyBox);
}
}
if (!parsedText.callSummary) {
if (!parsedText.callSummary && !parsedText.fileAttachment) {
bubble.append(textNode);
}
@@ -1118,7 +1168,9 @@ export function render({ navigate, route, chrome }) {
</div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<input id="chat-file-input" type="file" hidden />
<div class="dm-actions-col">
<button class="ghost-btn dm-file-btn" type="button" id="chat-file-toggle" aria-label="Прикрепить файл" title="Прикрепить файл" hidden>📎</button>
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
@@ -1127,6 +1179,9 @@ export function render({ navigate, route, chrome }) {
const input = form.elements.message;
const emojiSlot = form.querySelector('#chat-emoji-picker-slot');
const emojiToggle = form.querySelector('#chat-emoji-toggle');
const fileToggle = form.querySelector('#chat-file-toggle');
const fileInput = form.querySelector('#chat-file-input');
const fileTransferEnabled = isDmFileTransferEnabled();
const editBanner = form.querySelector('#chat-edit-banner');
const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel');
@@ -1135,6 +1190,9 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
let emojiLongPressTimer = null;
let emojiLongPressTriggered = false;
let fileActionVisible = false;
const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading;
@@ -1162,6 +1220,19 @@ export function render({ navigate, route, chrome }) {
emojiToggle?.setAttribute('aria-expanded', 'false');
};
const clearEmojiLongPress = () => {
if (emojiLongPressTimer != null) {
window.clearTimeout(emojiLongPressTimer);
emojiLongPressTimer = null;
}
};
const setFileActionVisible = (visible) => {
fileActionVisible = Boolean(visible && fileTransferEnabled);
if (fileToggle) fileToggle.hidden = !fileActionVisible;
syncComposerLayout();
};
// На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера —
// поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель.
const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true;
@@ -1453,6 +1524,117 @@ export function render({ navigate, route, chrome }) {
}
};
const sendFileAttachmentMessage = async (attachment) => {
const replying = activeEdit ? null : activeReply;
const fileBlock = buildDmFileTechBlock(attachment);
const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : '';
const fallbackText = `📎 ${String(attachment?.name || 'file')}`;
const finalText = `${replyBlock}${fileBlock}${fallbackText}`;
// Как и обычное исходящее сообщение, отправка файла сразу закрывает текущий
// визуальный раздел «Новые сообщения», не дожидаясь ответа сервера.
hideUnreadSeparator({ rerender: false });
const tempId = addOutgoingPendingMessage(chatId, finalText);
renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
const result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
});
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
deliveryState: result?.deliveryState || 'accepted',
});
const localRevisionApplied = await applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
deliveryState: result?.deliveryState || 'accepted',
});
if (replying) cancelReplyMode({ restoreDraft: false });
renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) notifyUnreadStateUpdated();
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
addAppLogEntry({
level: 'info',
source: 'outgoing-dm-file',
message: `Файл отправлен для ${chatId}`,
details: {
toLogin: chatId,
fileId: attachment?.id || '',
fileSize: Number(attachment?.size || 0),
messageId: result?.outgoingKey || '',
replyToBaseKey: replying?.baseKey || '',
},
});
} catch (error) {
addChatMessage(chatId, `Ошибка отправки файла: ${error?.message || 'unknown'}`);
addAppLogEntry({
level: 'warn',
source: 'outgoing-dm-file',
message: 'Файл загружен, но DM с ключом не отправлен',
details: {
toLogin: chatId,
fileId: attachment?.id || '',
error: error?.message || 'unknown',
},
});
renderChatLog({ scrollMode: 'latest', markAsRead: false });
throw error;
}
};
const handleSelectedFile = async (file) => {
if (!fileTransferEnabled) {
showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 });
return;
}
if (activeEdit) {
showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 });
return;
}
if (!file) return;
const originalLabel = fileToggle?.textContent || '📎';
if (fileToggle) {
fileToggle.disabled = true;
fileToggle.textContent = '…';
fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файла');
}
showToast(`Шифрую и загружаю «${String(file.name || 'file')}»…`, { timeoutMs: 1600 });
try {
const attachment = await encryptAndUploadDmFile({
file,
login: state.session.login,
sessionId: state.session.sessionId,
wsUrl: authService.serverUrl,
});
await sendFileAttachmentMessage(attachment);
setFileActionVisible(false);
showToast('Файл отправлен.', { timeoutMs: 1200 });
} catch (error) {
showToast(error?.message || 'Не удалось отправить файл', { kind: 'error', timeoutMs: 2600 });
} finally {
if (fileToggle) {
fileToggle.disabled = false;
fileToggle.textContent = originalLabel;
fileToggle.setAttribute('aria-label', 'Прикрепить файл');
}
if (fileInput) fileInput.value = '';
}
};
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const readState = resolveEffectiveReadState(getChatMessages(chatId), msg);
@@ -1465,7 +1647,7 @@ export function render({ navigate, route, chrome }) {
: '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !parsed?.fileAttachment,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
@@ -1577,9 +1759,38 @@ export function render({ navigate, route, chrome }) {
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
rememberEmojiSelection();
emojiLongPressTriggered = false;
clearEmojiLongPress();
if (fileTransferEnabled) {
emojiLongPressTimer = window.setTimeout(() => {
emojiLongPressTimer = null;
emojiLongPressTriggered = true;
closeEmojiPicker();
setFileActionVisible(true);
}, 520);
}
try {
emojiToggle.setPointerCapture?.(event.pointerId);
} catch {
// Pointer capture is optional; long press still works without it.
}
event.preventDefault();
});
emojiToggle?.addEventListener('click', () => {
emojiToggle?.addEventListener('pointerup', clearEmojiLongPress);
emojiToggle?.addEventListener('pointercancel', clearEmojiLongPress);
emojiToggle?.addEventListener('pointerleave', (event) => {
if (event.buttons) clearEmojiLongPress();
});
emojiToggle?.addEventListener('contextmenu', (event) => {
if (fileTransferEnabled) event.preventDefault();
});
emojiToggle?.addEventListener('click', (event) => {
if (emojiLongPressTriggered) {
emojiLongPressTriggered = false;
event.preventDefault();
return;
}
setFileActionVisible(false);
emojiPickerOpen = !emojiPickerOpen;
emojiSlot.hidden = !emojiPickerOpen;
emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen));
@@ -1587,9 +1798,18 @@ export function render({ navigate, route, chrome }) {
// открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно.
if (emojiPickerOpen && !isFinePointer) input?.blur();
});
fileToggle?.addEventListener('click', () => {
if (!fileTransferEnabled || fileToggle.disabled) return;
fileInput?.click();
});
fileInput?.addEventListener('change', () => {
const file = fileInput.files?.[0] || null;
if (file) void handleSelectedFile(file);
});
screen.addEventListener('pointerdown', (event) => {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
if (form.contains(event.target)) return;
if (emojiPickerOpen) closeEmojiPicker();
if (fileActionVisible) setFileActionVisible(false);
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
@@ -1658,6 +1878,7 @@ export function render({ navigate, route, chrome }) {
screen.cleanup = () => {
hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations();
clearEmojiLongPress();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);