НЕ ПРОВЕРЕНО: UI редактирования и удаления личных сообщений

This commit is contained in:
AidarKC 2026-06-18 13:04:06 +04:00
parent a95bd245cf
commit cf2152dcfc
7 changed files with 449 additions and 136 deletions

View File

@ -7,12 +7,15 @@
1. Отправка обычного текста без вложений. 1. Отправка обычного текста без вложений.
2. Повторная отправка того же логического сообщения с тем же `timeMs + nonce`, но большим `revisionTimeMs`. 2. Повторная отправка того же логического сообщения с тем же `timeMs + nonce`, но большим `revisionTimeMs`.
3. Обновление текста у уже существующего сообщения в UI без появления нового пузыря. 3. Обновление текста у уже существующего сообщения в UI без появления нового пузыря.
4. Игнорирование более старой ревизии на сервере. 4. Показ в UI метки `изменено: <дата время>` после редактирования.
5. Удаление сообщения пустой ревизией (`attachmentsCount = 0`, `encryptedBodyLen = 0`) и исчезновение из UI. 5. Игнорирование более старой ревизии на сервере и в клиентском state.
6. Доставка backlog после переподключения сессии для последней версии сообщения. 6. Удаление сообщения пустой ревизией (`attachmentsCount = 0`, `encryptedBodyLen = 0`) и исчезновение из UI.
7. Работа меню сообщения: `Скопировать как текст / Прочесть / Изменить / Удалить`.
8. Режим редактирования с возвратом предыдущего draft после отмены или завершения редактирования.
9. Доставка backlog после переподключения сессии для последней версии сообщения.
- ожидаемый результат: - ожидаемый результат:
Контентные сообщения `type=1/2` приходят в формате `SHiNE_DM`, сервер хранит только последнюю ревизию по `messageKey`, более старая ревизия не перетирает новую, а пустая ревизия убирает сообщение из интерфейса. Контентные сообщения `type=1/2` приходят в формате `SHiNE_DM`, сервер хранит только последнюю ревизию по `messageKey`, более старая ревизия не перетирает новую, редактирование обновляет существующий пузырь с пометкой `изменено`, а пустая ревизия убирает сообщение из интерфейса.
- статус: - статус:
pending pending

View File

@ -191,6 +191,9 @@ UI сейчас работает так:
- показывает только текст `encryptedBody`; - показывает только текст `encryptedBody`;
- умеет обновлять уже существующее сообщение по тому же `messageKey`; - умеет обновлять уже существующее сообщение по тому же `messageKey`;
- не показывает удалённые сообщения; - не показывает удалённые сообщения;
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
- не показывает и не принимает вложения. - не показывает и не принимает вложения.
## Что обязательно помнить ## Что обязательно помнить

View File

@ -1,2 +1,2 @@
client.version=1.2.209 client.version=1.2.211
server.version=1.2.198 server.version=1.2.199

View File

@ -21,14 +21,25 @@ import { showToast } from '../services/channels-ux.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; 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'); const root = document.getElementById('modal-root');
if (!root) return; if (!root) return;
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="chat-message-actions-modal-overlay"> <div class="modal" id="chat-delete-message-modal">
<div class="modal-card stack dm-dialog-card dm-message-actions-menu" id="chat-message-actions-modal"> <div class="modal-card stack dm-dialog-card">
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Копировать</button> <h3 class="modal-title">Удалить сообщение?</h3>
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button> <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>
</div> </div>
`; `;
@ -37,9 +48,75 @@ function openMessageActionsModal({ messageText = '', onReadAloud }) {
root.innerHTML = ''; root.innerHTML = '';
}; };
root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => { root.querySelector('#chat-delete-message-no')?.addEventListener('click', close);
if (event.target?.id === 'chat-message-actions-modal-overlay') 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 () => { root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
try { try {
if (navigator?.clipboard?.writeText) { if (navigator?.clipboard?.writeText) {
@ -52,10 +129,21 @@ function openMessageActionsModal({ messageText = '', onReadAloud }) {
close(); close();
} }
}); });
root.querySelector('#msg-action-read')?.addEventListener('click', async () => { root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
close(); close();
if (typeof onReadAloud === 'function') await onReadAloud(); 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) { function showTtsMissingConfigDialog(navigate) {
@ -166,6 +254,12 @@ function resolveDeliveryStatus(msg) {
return '…'; return '…';
} }
function resolveMessageEditedTimeMs(msg) {
const revisionTimeMs = Number(msg?.revisionTimeMs || 0);
if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0;
return revisionTimeMs;
}
function scrollToLatestMessage(list) { function scrollToLatestMessage(list) {
if (!list) return; if (!list) return;
const apply = () => { const apply = () => {
@ -221,8 +315,17 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
} }
bubble.append(metaNode); 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); list.append(bubble);
}); });
@ -242,6 +345,20 @@ export function render({ navigate, route }) {
screen.className = 'stack dm-screen dm-chat-screen'; screen.className = 'stack dm-screen dm-chat-screen';
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); 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( screen.append(
renderHeader({ renderHeader({
title: `Чат с ${contact.name}`, title: `Чат с ${contact.name}`,
@ -251,35 +368,13 @@ export function render({ navigate, route }) {
onClick: async () => { onClick: async () => {
try { try {
await startOutgoingCall(chatId); await startOutgoingCall(chatId);
renderLog(log, chatId, { renderLog(log, chatId, { onOpenActions: handleOpenActions });
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
} catch (e) { } catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out', from: 'out',
kind: 'call-tech', kind: 'call-tech',
}); });
renderLog(log, chatId, { renderLog(log, chatId, { onOpenActions: handleOpenActions });
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
} }
}, },
}], }],
@ -325,15 +420,13 @@ export function render({ navigate, route }) {
screen.append(card); 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'); const form = document.createElement('form');
form.className = 'chat-input dm-chat-input'; form.className = 'chat-input dm-chat-input';
form.innerHTML = ` 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> <textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col"> <div class="dm-actions-col">
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button> <button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
@ -341,25 +434,129 @@ export function render({ navigate, route }) {
</div> </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 sendTextMessage = async (rawText) => {
const text = String(rawText || '').trim(); const text = String(rawText || '').trim();
if (!text) return; if (!text) return;
const tempId = addOutgoingPendingMessage(chatId, text); const editing = activeEdit;
renderLog(log, chatId, { const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
onOpenActions: (msg) => openMessageActionsModal({ renderLog(log, chatId, { onOpenActions: handleOpenActions });
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
try { try {
const result = await authService.sendDirectMessage({ 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, login: state.session.login,
toLogin: chatId, toLogin: chatId,
text, text,
@ -369,41 +566,23 @@ export function render({ navigate, route }) {
messageKey: result?.outgoingKey || '', messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '', baseKey: result?.baseKey || result?.localBaseKey || '',
}); });
if (result?.localOutgoingBlobB64) { }
try {
const parsed = authService.parseSignedMessageBlob(result.localOutgoingBlobB64); applyLocalRevision({
addSignedMessageToChat({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
chatId, fallbackMessageKey: result?.outgoingKey || '',
messageKey: result?.outgoingKey || parsed?.messageKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
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 if (editing) {
cancelEditMode({ restoreDraft: true });
} }
}
renderLog(log, chatId, { renderLog(log, chatId, { onOpenActions: handleOpenActions });
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'outgoing-dm', source: 'outgoing-dm',
message: `Сообщение отправлено для ${chatId}`, message: editing ? `Сообщение изменено для ${chatId}` : `Сообщение отправлено для ${chatId}`,
details: { details: {
toLogin: chatId, toLogin: chatId,
messageId: result?.outgoingKey || '', messageId: result?.outgoingKey || '',
@ -412,32 +591,54 @@ export function render({ navigate, route }) {
}, },
}); });
} catch (e) { } catch (e) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`); if (input) {
input.value = text;
autoResizeComposer(input);
focusInputToEnd();
}
addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`);
addAppLogEntry({ addAppLogEntry({
level: 'warn', level: 'warn',
source: 'outgoing-dm', source: 'outgoing-dm',
message: 'Ошибка отправки личного сообщения', message: activeEdit ? 'Ошибка редактирования личного сообщения' : 'Ошибка отправки личного сообщения',
details: { details: {
toLogin: chatId, toLogin: chatId,
error: e?.message || 'unknown', error: e?.message || 'unknown',
}, },
}); });
renderLog(log, chatId, { renderLog(log, chatId, { onOpenActions: handleOpenActions });
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
} }
}; };
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); autoResizeComposer(input);
input?.addEventListener('input', () => autoResizeComposer(input)); input?.addEventListener('input', () => autoResizeComposer(input));
input?.addEventListener('focus', () => { input?.addEventListener('focus', () => {
@ -485,7 +686,7 @@ export function render({ navigate, route }) {
form.addEventListener('submit', async (event) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const text = input.value.trim(); const text = String(input.value || '').trim();
if (!text) return; if (!text) return;
input.value = ''; input.value = '';
autoResizeComposer(input); autoResizeComposer(input);
@ -494,23 +695,13 @@ export function render({ navigate, route }) {
wrap.append(log, form); wrap.append(log, form);
screen.append(wrap); screen.append(wrap);
renderLog(log, chatId, { renderLog(log, chatId, { onOpenActions: handleOpenActions });
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180); window.setTimeout(() => scrollToLatestMessage(log), 180);
void sendReadReceiptsForVisible(chatId); void sendReadReceiptsForVisible(chatId);
return screen; return screen;
} }
async function sendReadReceiptsForVisible(chatId) { async function sendReadReceiptsForVisible(chatId) {
const pending = getChatMessages(chatId) const pending = getChatMessages(chatId)
.filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent) .filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent)

View File

@ -2006,32 +2006,45 @@ export class AuthService {
return response.payload || {}; 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 cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim(); const cleanToLogin = String(toLogin || '').trim();
const cleanText = String(text || ''); const cleanText = String(text || '');
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
if (!cleanText) throw new Error('Пустое сообщение'); const normalizedTimeMs = Number(timeMs);
const timeMs = Date.now(); const normalizedNonce = Number(nonce);
const nonce = Math.floor(Math.random() * 0x100000000); 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 encryptedBodyBytes = utf8Bytes(cleanText);
const incomingBlock = await this.buildSignedDmV1Block({ const incomingBlock = await this.buildSignedDmV1Block({
login: cleanFromLogin, login: cleanFromLogin,
toLogin: cleanToLogin, toLogin: cleanToLogin,
storagePwd, storagePwd,
timeMs, timeMs: normalizedTimeMs,
nonce, nonce: normalizedNonce,
messageType: DM2_TYPE_INCOMING, messageType: DM2_TYPE_INCOMING,
revisionTimeMs: normalizedRevisionTimeMs,
encryptedBodyBytes, encryptedBodyBytes,
}); });
const outgoingBlock = await this.buildSignedDmV1Block({ const outgoingBlock = await this.buildSignedDmV1Block({
login: cleanFromLogin, login: cleanFromLogin,
toLogin: cleanToLogin, toLogin: cleanToLogin,
storagePwd, storagePwd,
timeMs, timeMs: normalizedTimeMs,
nonce, nonce: normalizedNonce,
messageType: DM2_TYPE_OUTGOING_COPY, messageType: DM2_TYPE_OUTGOING_COPY,
revisionTimeMs: normalizedRevisionTimeMs,
encryptedBodyBytes, encryptedBodyBytes,
}); });
@ -2043,10 +2056,36 @@ export class AuthService {
...payload, ...payload,
localIncomingBlobB64: bytesToBase64(incomingBlock), localIncomingBlobB64: bytesToBase64(incomingBlock),
localOutgoingBlobB64: bytesToBase64(outgoingBlock), 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 }) { async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
const timeMs = Date.now(); const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000); const nonce = Math.floor(Math.random() * 0x100000000);

View File

@ -580,6 +580,12 @@ export function addSignedMessageToChat({
const list = getChatMessages(chatId); const list = getChatMessages(chatId);
const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id); const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id);
const existing = existingIndex >= 0 ? list[existingIndex] : null; 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 (deleted) {
if (existingIndex >= 0) { if (existingIndex >= 0) {
@ -599,7 +605,7 @@ export function addSignedMessageToChat({
row.baseKey = String(baseKey || ''); row.baseKey = String(baseKey || '');
row.messageType = Number(messageType || 0); row.messageType = Number(messageType || 0);
row.rawBlobB64 = String(rawBlobB64 || ''); row.rawBlobB64 = String(rawBlobB64 || '');
row.revisionTimeMs = Number(revisionTimeMs || 0); row.revisionTimeMs = nextRevision;
row.unread = row.from === 'in' ? Boolean(unread) : false; row.unread = row.from === 'in' ? Boolean(unread) : false;
row.refBaseKey = String(refBaseKey || ''); row.refBaseKey = String(refBaseKey || '');
row.firstTick = row.from === 'out'; row.firstTick = row.from === 'out';

View File

@ -936,6 +936,12 @@
color: rgba(198, 214, 247, 0.78); 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 { .bubble-time {
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
@ -3694,6 +3700,41 @@ textarea.input {
-webkit-backdrop-filter: blur(12px); -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 { .dm-voice-btn {
min-width: 42px; min-width: 42px;
padding: 0 10px; padding: 0 10px;
@ -3765,11 +3806,41 @@ textarea.input {
gap: 6px; 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 { .dm-message-action-btn {
width: 100%; width: 100%;
justify-content: flex-start; 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 { .speech-actions-top {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;