НЕ ПРОВЕРЕНО: UI редактирования и удаления личных сообщений
This commit is contained in:
parent
a95bd245cf
commit
cf2152dcfc
@ -7,12 +7,15 @@
|
||||
1. Отправка обычного текста без вложений.
|
||||
2. Повторная отправка того же логического сообщения с тем же `timeMs + nonce`, но большим `revisionTimeMs`.
|
||||
3. Обновление текста у уже существующего сообщения в UI без появления нового пузыря.
|
||||
4. Игнорирование более старой ревизии на сервере.
|
||||
5. Удаление сообщения пустой ревизией (`attachmentsCount = 0`, `encryptedBodyLen = 0`) и исчезновение из UI.
|
||||
6. Доставка backlog после переподключения сессии для последней версии сообщения.
|
||||
4. Показ в UI метки `изменено: <дата время>` после редактирования.
|
||||
5. Игнорирование более старой ревизии на сервере и в клиентском state.
|
||||
6. Удаление сообщения пустой ревизией (`attachmentsCount = 0`, `encryptedBodyLen = 0`) и исчезновение из UI.
|
||||
7. Работа меню сообщения: `Скопировать как текст / Прочесть / Изменить / Удалить`.
|
||||
8. Режим редактирования с возвратом предыдущего draft после отмены или завершения редактирования.
|
||||
9. Доставка backlog после переподключения сессии для последней версии сообщения.
|
||||
|
||||
- ожидаемый результат:
|
||||
Контентные сообщения `type=1/2` приходят в формате `SHiNE_DM`, сервер хранит только последнюю ревизию по `messageKey`, более старая ревизия не перетирает новую, а пустая ревизия убирает сообщение из интерфейса.
|
||||
Контентные сообщения `type=1/2` приходят в формате `SHiNE_DM`, сервер хранит только последнюю ревизию по `messageKey`, более старая ревизия не перетирает новую, редактирование обновляет существующий пузырь с пометкой `изменено`, а пустая ревизия убирает сообщение из интерфейса.
|
||||
|
||||
- статус:
|
||||
pending
|
||||
|
||||
@ -191,6 +191,9 @@ UI сейчас работает так:
|
||||
- показывает только текст `encryptedBody`;
|
||||
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
|
||||
- не показывает удалённые сообщения;
|
||||
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
|
||||
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
|
||||
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
|
||||
- не показывает и не принимает вложения.
|
||||
|
||||
## Что обязательно помнить
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.209
|
||||
server.version=1.2.198
|
||||
client.version=1.2.211
|
||||
server.version=1.2.199
|
||||
|
||||
@ -21,14 +21,25 @@ import { showToast } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
|
||||
function openMessageActionsModal({ messageText = '', onReadAloud }) {
|
||||
function truncatePreviewText(value, maxLen = 72) {
|
||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return '';
|
||||
if (normalized.length <= maxLen) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function openDeleteMessageConfirmModal({ onConfirm }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="chat-message-actions-modal-overlay">
|
||||
<div class="modal-card stack dm-dialog-card dm-message-actions-menu" id="chat-message-actions-modal">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Копировать</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
||||
<div class="modal" id="chat-delete-message-modal">
|
||||
<div class="modal-card stack dm-dialog-card">
|
||||
<h3 class="modal-title">Удалить сообщение?</h3>
|
||||
<p class="meta-muted">Оно исчезнет и у тебя, и у собеседника.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="chat-delete-message-no">Нет</button>
|
||||
<button class="primary-btn" type="button" id="chat-delete-message-yes">Да</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -37,9 +48,75 @@ function openMessageActionsModal({ messageText = '', onReadAloud }) {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'chat-message-actions-modal-overlay') close();
|
||||
root.querySelector('#chat-delete-message-no')?.addEventListener('click', close);
|
||||
root.querySelector('#chat-delete-message-yes')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onConfirm === 'function') await onConfirm();
|
||||
});
|
||||
}
|
||||
|
||||
function openMessageActionsMenu({
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
messageText = '',
|
||||
canEdit = false,
|
||||
canDelete = false,
|
||||
onReadAloud,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
|
||||
const menuId = `chat-message-actions-menu-${Date.now()}`;
|
||||
root.innerHTML = `
|
||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||
<div class="modal-card stack dm-dialog-card dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
|
||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const menu = root.querySelector(`#${menuId}`);
|
||||
if (!menu) return;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.removeEventListener('resize', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
const onDocumentPointerDown = (event) => {
|
||||
if (menu.contains(event.target)) return;
|
||||
close();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown, true);
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
|
||||
const left = Math.min(
|
||||
Math.max(12, Number(anchorX || 0) - (menuRect.width / 2)),
|
||||
Math.max(12, viewportWidth - menuRect.width - 12)
|
||||
);
|
||||
const top = Math.min(
|
||||
Math.max(12, Number(anchorY || 0) + 10),
|
||||
Math.max(12, viewportHeight - menuRect.height - 12)
|
||||
);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
|
||||
menu.classList.add('is-visible');
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
@ -52,10 +129,21 @@ function openMessageActionsModal({ messageText = '', onReadAloud }) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onReadAloud === 'function') await onReadAloud();
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-edit')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onEdit === 'function') await onEdit();
|
||||
});
|
||||
|
||||
root.querySelector('#msg-action-delete')?.addEventListener('click', async () => {
|
||||
close();
|
||||
if (typeof onDelete === 'function') await onDelete();
|
||||
});
|
||||
}
|
||||
|
||||
function showTtsMissingConfigDialog(navigate) {
|
||||
@ -166,6 +254,12 @@ function resolveDeliveryStatus(msg) {
|
||||
return '…';
|
||||
}
|
||||
|
||||
function resolveMessageEditedTimeMs(msg) {
|
||||
const revisionTimeMs = Number(msg?.revisionTimeMs || 0);
|
||||
if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0;
|
||||
return revisionTimeMs;
|
||||
}
|
||||
|
||||
function scrollToLatestMessage(list) {
|
||||
if (!list) return;
|
||||
const apply = () => {
|
||||
@ -221,8 +315,17 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||
}
|
||||
|
||||
bubble.append(metaNode);
|
||||
bubble.addEventListener('click', () => {
|
||||
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
||||
|
||||
const editedAtMs = resolveMessageEditedTimeMs(msg);
|
||||
if (editedAtMs > 0) {
|
||||
const editedNode = document.createElement('div');
|
||||
editedNode.className = 'bubble-meta bubble-meta-edited';
|
||||
editedNode.textContent = `изменено: ${formatMessageTime(editedAtMs)}`;
|
||||
bubble.append(editedNode);
|
||||
}
|
||||
|
||||
bubble.addEventListener('click', (event) => {
|
||||
if (typeof onOpenActions === 'function') onOpenActions(msg, event);
|
||||
});
|
||||
list.append(bubble);
|
||||
});
|
||||
@ -242,6 +345,20 @@ export function render({ navigate, route }) {
|
||||
screen.className = 'stack dm-screen dm-chat-screen';
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
|
||||
const handleReadAloud = async (msg) => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
};
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: `Чат с ${contact.name}`,
|
||||
@ -251,35 +368,13 @@ export function render({ navigate, route }) {
|
||||
onClick: async () => {
|
||||
try {
|
||||
await startOutgoingCall(chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
}
|
||||
},
|
||||
}],
|
||||
@ -325,15 +420,13 @@ export function render({ navigate, route }) {
|
||||
screen.append(card);
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap dm-chat-wrap';
|
||||
|
||||
const log = document.createElement('div');
|
||||
log.className = 'messages-log dm-messages-log';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'chat-input dm-chat-input';
|
||||
form.innerHTML = `
|
||||
<div class="dm-edit-banner" id="chat-edit-banner" hidden>
|
||||
<div class="dm-edit-banner__text" id="chat-edit-banner-text"></div>
|
||||
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование">✕</button>
|
||||
</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-voice-input" title="Голосовой ввод">🎤</button>
|
||||
@ -341,69 +434,155 @@ export function render({ navigate, route }) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const input = form.elements.message;
|
||||
const editBanner = form.querySelector('#chat-edit-banner');
|
||||
const editBannerText = form.querySelector('#chat-edit-banner-text');
|
||||
const editCancelBtn = form.querySelector('#chat-edit-cancel');
|
||||
let activeEdit = null;
|
||||
|
||||
const syncEditBanner = () => {
|
||||
if (!editBanner || !editBannerText) return;
|
||||
if (!activeEdit) {
|
||||
editBanner.hidden = true;
|
||||
editBannerText.textContent = '';
|
||||
return;
|
||||
}
|
||||
editBanner.hidden = false;
|
||||
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
|
||||
};
|
||||
|
||||
const focusInputToEnd = () => {
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
try {
|
||||
const nextPos = String(input.value || '').length;
|
||||
input.setSelectionRange(nextPos, nextPos);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEditMode = ({ restoreDraft = true } = {}) => {
|
||||
if (!activeEdit) return;
|
||||
const draftToRestore = activeEdit.draftBeforeEdit;
|
||||
activeEdit = null;
|
||||
syncEditBanner();
|
||||
if (restoreDraft && input) {
|
||||
input.value = draftToRestore || '';
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const startEditMode = (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base || msg?.from !== 'out') return;
|
||||
const currentDraft = String(input?.value || '');
|
||||
activeEdit = {
|
||||
messageKey: String(msg?.messageKey || ''),
|
||||
originalText: String(msg?.text || ''),
|
||||
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
|
||||
timeMs: base.timeMs,
|
||||
nonce: base.nonce,
|
||||
};
|
||||
syncEditBanner();
|
||||
if (input) {
|
||||
input.value = String(msg?.text || '');
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
};
|
||||
|
||||
const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
if (!localOutgoingBlobB64) return;
|
||||
try {
|
||||
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey: fallbackMessageKey || parsed?.messageKey || '',
|
||||
baseKey: fallbackBaseKey || parsed?.baseKey || '',
|
||||
from: 'out',
|
||||
text: parsed?.text || '',
|
||||
messageType: Number(parsed?.messageType || 2),
|
||||
unread: false,
|
||||
rawBlobB64: localOutgoingBlobB64,
|
||||
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
|
||||
deleted: Boolean(parsed?.deleted),
|
||||
});
|
||||
} catch {
|
||||
// ignore local parse failure; server backlog/realtime will reconcile later
|
||||
}
|
||||
};
|
||||
|
||||
const sendDeleteRevision = async (msg) => {
|
||||
const base = parseBaseKey(msg?.baseKey);
|
||||
if (!base) return;
|
||||
const result = await authService.deleteDirectMessage({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
timeMs: base.timeMs,
|
||||
nonce: base.nonce,
|
||||
revisionTimeMs: Date.now(),
|
||||
});
|
||||
applyLocalRevision({
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
});
|
||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
}
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
};
|
||||
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const text = String(rawText || '').trim();
|
||||
if (!text) return;
|
||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const editing = activeEdit;
|
||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
|
||||
try {
|
||||
const result = await authService.sendDirectMessage({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
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),
|
||||
deleted: Boolean(parsed?.deleted),
|
||||
});
|
||||
} catch {
|
||||
// ignore local parse failure; server backlog/realtime will reconcile later
|
||||
}
|
||||
let result;
|
||||
if (editing) {
|
||||
result = await authService.sendDirectMessageRevision({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
timeMs: editing.timeMs,
|
||||
nonce: editing.nonce,
|
||||
revisionTimeMs: Date.now(),
|
||||
});
|
||||
} else {
|
||||
result = await authService.sendDirectMessage({
|
||||
login: state.session.login,
|
||||
toLogin: chatId,
|
||||
text,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
markOutgoingSent(tempId, {
|
||||
messageKey: result?.outgoingKey || '',
|
||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
});
|
||||
}
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
|
||||
applyLocalRevision({
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
});
|
||||
|
||||
if (editing) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
}
|
||||
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'outgoing-dm',
|
||||
message: `Сообщение отправлено для ${chatId}`,
|
||||
message: editing ? `Сообщение изменено для ${chatId}` : `Сообщение отправлено для ${chatId}`,
|
||||
details: {
|
||||
toLogin: chatId,
|
||||
messageId: result?.outgoingKey || '',
|
||||
@ -412,32 +591,54 @@ export function render({ navigate, route }) {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
|
||||
if (input) {
|
||||
input.value = text;
|
||||
autoResizeComposer(input);
|
||||
focusInputToEnd();
|
||||
}
|
||||
addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`);
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'outgoing-dm',
|
||||
message: 'Ошибка отправки личного сообщения',
|
||||
message: activeEdit ? 'Ошибка редактирования личного сообщения' : 'Ошибка отправки личного сообщения',
|
||||
details: {
|
||||
toLogin: chatId,
|
||||
error: e?.message || 'unknown',
|
||||
},
|
||||
});
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
}
|
||||
};
|
||||
|
||||
const input = form.elements.message;
|
||||
const handleOpenActions = (msg, event) => {
|
||||
openMessageActionsMenu({
|
||||
anchorX: Number(event?.clientX || 0),
|
||||
anchorY: Number(event?.clientY || 0),
|
||||
messageText: msg?.text || '',
|
||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||
onReadAloud: async () => handleReadAloud(msg),
|
||||
onEdit: async () => {
|
||||
startEditMode(msg);
|
||||
},
|
||||
onDelete: async () => {
|
||||
openDeleteMessageConfirmModal({
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await sendDeleteRevision(msg);
|
||||
} catch (error) {
|
||||
showToast(`Не удалось удалить сообщение: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1500 });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
editCancelBtn?.addEventListener('click', () => {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
});
|
||||
|
||||
autoResizeComposer(input);
|
||||
input?.addEventListener('input', () => autoResizeComposer(input));
|
||||
input?.addEventListener('focus', () => {
|
||||
@ -485,7 +686,7 @@ export function render({ navigate, route }) {
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const text = input.value.trim();
|
||||
const text = String(input.value || '').trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
autoResizeComposer(input);
|
||||
@ -494,23 +695,13 @@ export function render({ navigate, route }) {
|
||||
|
||||
wrap.append(log, form);
|
||||
screen.append(wrap);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
return screen;
|
||||
}
|
||||
|
||||
async function sendReadReceiptsForVisible(chatId) {
|
||||
const pending = getChatMessages(chatId)
|
||||
.filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent)
|
||||
|
||||
@ -2006,32 +2006,45 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
||||
async sendDirectMessageRevision({
|
||||
login,
|
||||
toLogin,
|
||||
text = '',
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
revisionTimeMs = 0,
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanText = String(text || '');
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
if (!cleanText) throw new Error('Пустое сообщение');
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
const normalizedNonce = Number(nonce);
|
||||
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
||||
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
||||
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
||||
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
|
||||
const encryptedBodyBytes = utf8Bytes(cleanText);
|
||||
|
||||
const incomingBlock = await this.buildSignedDmV1Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: DM2_TYPE_INCOMING,
|
||||
revisionTimeMs: normalizedRevisionTimeMs,
|
||||
encryptedBodyBytes,
|
||||
});
|
||||
const outgoingBlock = await this.buildSignedDmV1Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: DM2_TYPE_OUTGOING_COPY,
|
||||
revisionTimeMs: normalizedRevisionTimeMs,
|
||||
encryptedBodyBytes,
|
||||
});
|
||||
|
||||
@ -2043,10 +2056,36 @@ export class AuthService {
|
||||
...payload,
|
||||
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
||||
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
|
||||
localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs, nonce }),
|
||||
localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
|
||||
};
|
||||
}
|
||||
|
||||
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
||||
const cleanText = String(text || '');
|
||||
if (!cleanText) throw new Error('Пустое сообщение');
|
||||
return this.sendDirectMessageRevision({
|
||||
login,
|
||||
toLogin,
|
||||
text: cleanText,
|
||||
storagePwd,
|
||||
timeMs: Date.now(),
|
||||
nonce: Math.floor(Math.random() * 0x100000000),
|
||||
revisionTimeMs: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs }) {
|
||||
return this.sendDirectMessageRevision({
|
||||
login,
|
||||
toLogin,
|
||||
text: '',
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
revisionTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
|
||||
@ -580,6 +580,12 @@ export function addSignedMessageToChat({
|
||||
const list = getChatMessages(chatId);
|
||||
const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id);
|
||||
const existing = existingIndex >= 0 ? list[existingIndex] : null;
|
||||
const nextRevision = Number(revisionTimeMs || 0);
|
||||
const currentRevision = Number(existing?.revisionTimeMs || 0);
|
||||
|
||||
if (existing && Number.isFinite(currentRevision) && nextRevision < currentRevision) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
if (existingIndex >= 0) {
|
||||
@ -599,7 +605,7 @@ export function addSignedMessageToChat({
|
||||
row.baseKey = String(baseKey || '');
|
||||
row.messageType = Number(messageType || 0);
|
||||
row.rawBlobB64 = String(rawBlobB64 || '');
|
||||
row.revisionTimeMs = Number(revisionTimeMs || 0);
|
||||
row.revisionTimeMs = nextRevision;
|
||||
row.unread = row.from === 'in' ? Boolean(unread) : false;
|
||||
row.refBaseKey = String(refBaseKey || '');
|
||||
row.firstTick = row.from === 'out';
|
||||
|
||||
@ -936,6 +936,12 @@
|
||||
color: rgba(198, 214, 247, 0.78);
|
||||
}
|
||||
|
||||
.bubble-meta-edited {
|
||||
margin-top: 3px;
|
||||
justify-content: flex-end;
|
||||
color: rgba(235, 208, 137, 0.88);
|
||||
}
|
||||
|
||||
.bubble-time {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
@ -3694,6 +3700,41 @@ textarea.input {
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.dm-edit-banner {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.35);
|
||||
background: rgba(212, 175, 55, 0.12);
|
||||
color: rgba(255, 233, 176, 0.96);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.dm-edit-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dm-edit-banner__text {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dm-edit-banner__close {
|
||||
min-width: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dm-voice-btn {
|
||||
min-width: 42px;
|
||||
padding: 0 10px;
|
||||
@ -3765,11 +3806,41 @@ textarea.input {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dm-floating-menu-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dm-message-actions-popover {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
margin: 0;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.dm-message-actions-popover.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.dm-message-action-btn {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dm-message-action-btn--danger {
|
||||
color: rgba(255, 182, 182, 0.98);
|
||||
border-color: rgba(255, 120, 120, 0.3);
|
||||
background: rgba(112, 28, 28, 0.22);
|
||||
}
|
||||
|
||||
.speech-actions-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user