НЕ ПРОВЕРЕНО: 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. Отправка обычного текста без вложений.
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

View File

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

View File

@ -1,2 +1,2 @@
client.version=1.2.209
server.version=1.2.198
client.version=1.2.211
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: 'Чат' };
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)

View File

@ -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);

View File

@ -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';

View File

@ -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;