Обновить UI чата и закрыть проверенные DM задачи

This commit is contained in:
AidarKC
2026-07-10 12:26:36 +04:00
parent 0fb1147eb7
commit 5a5e9c01ce
9 changed files with 320 additions and 57 deletions
@@ -1,23 +0,0 @@
# DM v1: E2EE, tombstone и fan-out
- краткое описание фичи:
Новый протокол личных сообщений `SHiNE_DM` v1: реальные зашифрованные тела `X25519 + HKDF-SHA256 + AES-256-GCM`, read-receipt в том же контейнере, удаление одного сообщения, удаление истории переписки и межсерверная доставка по `access_servers`.
- что именно проверять:
1. Отправка нового DM между двумя пользователями и чтение текста на стороне получателя.
2. Отправка/чтение своей исходящей копии у отправителя.
3. Редактирование сообщения с тем же `baseKey` и ростом `revisionTimeMs`.
4. Подтверждение прочтения `type=3/4`.
5. Удаление исходящего сообщения отправителем (`type=5`) и удаление входящего сообщения получателем (`type=6`).
6. Удаление истории переписки (`type=7/8`) и игнорирование более старых сообщений после tombstone.
7. Сценарий с несколькими `access_servers`, включая случай общего сервера у обеих сторон.
8. Первый DM пользователю, которого ещё нет в локальной `solana_users`, с lazy-import из Solana PDA.
9. Две версии одного сообщения с одинаковым `revisionTimeMs`, но разным `reencryptedAtMs`: должна победить версия с большим `reencryptedAtMs`.
10. После уже известного `DeleteConversation` отправка старого сообщения с другого сервера должна приводить к повторной рассылке tombstone удаления переписки.
11. Попытка вызвать legacy `SendDirectMessage` должна не работать, а новый UI должен использовать только `SendMessagePair`.
- ожидаемый результат:
Сообщения шифруются и расшифровываются только на клиенте, сервер не показывает plaintext из ciphertext, lazy-import из PDA позволяет отправить первый DM без ручного прогрева БД, версия с большим `reencryptedAtMs` корректно замещает старую при равном `revisionTimeMs`, старые ревизии не оживают после tombstone, общие серверы не получают лишних дублей пары, а удаление и read-receipt корректно доходят обеим сторонам.
- статус:
pending
@@ -1,16 +0,0 @@
Lf dc` hfm# TURN: только один живой узел для звонков
- краткое описание:
- в серверном конфиге оставлен только один TURN-узел `185.229.109.118:3478` для ICE-конфига звонков;
- мёртвый узел `93.170.12.154:3478` убран из локальной конфигурации и test-деплоя `t2`.
- что проверять:
- открыть два клиента на `t2.shineup.me`;
- начать аудиозвонок между ними;
- убедиться, что `GetCallIceConfig` отдаёт только TURN `185.229.109.118`;
- убедиться, что звонок выходит из стадии `connecting` и появляется `call_connected` либо как минимум `relay`-кандидаты в клиентской диагностике.
- ожидаемый результат:
- клиентам больше не предлагается `93.170.12.154`;
- звонок проходит стабильнее, чем до изменения;
- в серверных логах нет новых `incoming_failed/outgoing_failed` только из-за нерабочего TURN.
- статус:
- pending
@@ -446,6 +446,11 @@ Request:
- `5`, если удаление инициировал отправитель;
- `6`, если удаление инициировал получатель.
UI-следствие для клиента:
- пользователь может удалить как своё исходящее сообщение, так и входящее;
- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`).
### 10.4. `DeleteConversation`
Назначение:
@@ -469,6 +474,13 @@ Request:
- `7`, если удаление инициировал отправитель;
- `8`, если удаление инициировал получатель.
UI-следствие для клиента:
- действие `Очистить историю` должно отправлять служебное сообщение удаления переписки;
- для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (`type=8`);
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
## 11. Межсерверная доставка
### 11.1. Клиентская сторона
@@ -296,6 +296,12 @@ ReadReceiptBody_v1_0
- `1`, `2`, `5`, `7` подписывает отправитель сообщения;
- `6`, `8` подписывает получатель сообщения.
Практическое UI-следствие:
- клиентский UI может инициировать удаление входящего сообщения через `type=6`;
- клиентский UI может инициировать очистку истории переписки через `type=8`;
- сами контейнеры и байтовый формат при этом не отличаются от уже описанных типов `5/6/7/8`.
## 12. Какие поля должны совпадать у пары `1/2`
При обычной парной отправке через `SendMessagePair` у двух копий должны совпадать:
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.304
server.version=1.2.283
client.version=1.2.305
server.version=1.2.284
+12
View File
@@ -82,6 +82,8 @@ import * as addPersonalPublicChatView from './pages/add-personal-public-chat-vie
import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const routes = {
'start-view': startView,
'entry-settings-view': entrySettingsView,
@@ -1182,6 +1184,16 @@ async function init() {
});
} else if (messageType === 7 || messageType === 8) {
const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0));
addSignedMessageToChat({
chatId,
messageKey,
baseKey: parsed.baseKey,
from: messageType === 8 ? 'out' : 'in',
text: CONVERSATION_CLEAR_NOTICE_TEXT,
messageType,
unread: false,
rawBlobB64: blobB64,
});
if (removed > 0) shouldRefreshToolbarUnread = true;
addAppLogEntry({
level: 'info',
+3 -1
View File
@@ -26,8 +26,10 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
right.className = 'header-actions';
rightActions.forEach((action) => {
const btn = document.createElement('button');
btn.className = 'icon-btn';
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
btn.textContent = action.label;
if (action.title) btn.title = action.title;
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
btn.addEventListener('click', action.onClick);
right.append(btn);
});
+249 -15
View File
@@ -6,6 +6,7 @@ import {
addSignedMessageToChat,
addSystemChatMessage,
addOutgoingPendingMessage,
deleteConversationMessagesBefore,
getChatMessages,
markChatRead,
markOutgoingSent,
@@ -21,6 +22,7 @@ import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings
import { showToast } from '../services/channels-ux.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function truncatePreviewText(value, maxLen = 72) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
@@ -56,6 +58,74 @@ function openDeleteMessageConfirmModal({ onConfirm }) {
});
}
function openChatConfirmModal({
title = 'Подтвердить действие',
text = '',
confirmLabel = 'Да',
confirmKind = 'primary',
onConfirm,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn';
root.innerHTML = `
<div class="modal" id="chat-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">${title}</h3>
<p class="meta-muted">${text}</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-confirm-no">Нет</button>
<button class="${confirmClass}" type="button" id="chat-confirm-yes">${confirmLabel}</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-confirm-no')?.addEventListener('click', close);
root.querySelector('#chat-confirm-yes')?.addEventListener('click', async () => {
close();
if (typeof onConfirm === 'function') await onConfirm();
});
}
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="chat-delete-chat-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить чат?</h3>
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
<label class="dm-confirm-check">
<input type="checkbox" id="chat-delete-chat-history" checked />
<span>Также удалить всю историю переписки</span>
</label>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
close();
if (typeof onConfirm === 'function') {
await onConfirm({ deleteHistory });
}
});
}
function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
@@ -148,6 +218,78 @@ function openMessageActionsMenu({
});
}
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onClearHistory,
onDeleteChat,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-header-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Позвонить</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</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 + 8),
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('#chat-menu-call')?.addEventListener('click', async () => {
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
});
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
close();
if (typeof onDeleteChat === 'function') await onDeleteChat();
});
}
function showTtsMissingConfigDialog(navigate) {
const root = document.getElementById('modal-root');
if (!root) return;
@@ -443,6 +585,46 @@ export function render({ navigate, route }) {
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
};
const handleStartCall = async () => {
try {
await startOutgoingCall(chatId);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
}
};
const clearConversationHistory = async () => {
const result = await authService.deleteConversation({
login: state.session.login,
toLogin: chatId,
storagePwd: state.session.storagePwdInMemory,
deleteByRecipient: true,
});
const parsed = authService.parseSignedMessageBlob(String(result?.localBlobB64 || '').trim());
deleteConversationMessagesBefore(chatId, Number(parsed?.timeMs || 0));
addSignedMessageToChat({
chatId,
messageKey: String(parsed?.messageKey || ''),
baseKey: String(parsed?.baseKey || ''),
from: 'out',
text: CONVERSATION_CLEAR_NOTICE_TEXT,
messageType: Number(parsed?.messageType || 8),
unread: false,
rawBlobB64: String(result?.localBlobB64 || ''),
});
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
notifyUnreadStateUpdated();
};
const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
@@ -453,21 +635,70 @@ export function render({ navigate, route }) {
renderHeader({
title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{
label: 'Позвонить',
onClick: async () => {
try {
await startOutgoingCall(chatId);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
}
rightActions: [
{
label: '☎',
title: 'Позвонить',
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
onClick: handleStartCall,
},
}],
{
label: '⋯',
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
onClick: (event) => {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: handleStartCall,
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
confirmLabel: 'Очистить',
confirmKind: 'danger',
onConfirm: async () => {
try {
await clearConversationHistory();
showToast('История чата очищена', { timeoutMs: 1200 });
} catch (error) {
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
onDeleteChat: async () => {
openDeleteChatConfirmModal({
contactName: contact.name,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
}
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
notifyUnreadStateUpdated();
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
});
},
},
],
})
);
@@ -751,7 +982,10 @@ export function render({ navigate, route }) {
messageText: msg?.text || '',
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
),
onReadAloud: async () => handleReadAloud(msg),
onEdit: async () => {
startEditMode(msg);
+36
View File
@@ -44,6 +44,26 @@
justify-content: flex-end;
}
.chat-header-icon-btn {
min-width: 42px;
width: 42px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.chat-header-call-btn {
border-radius: 12px;
}
.chat-header-menu-btn {
border-radius: 12px;
font-size: 24px;
}
.icon-btn,
.text-btn,
.primary-btn,
@@ -4062,6 +4082,22 @@ html, body { overflow-x: hidden; }
background: rgba(112, 28, 28, 0.22);
}
.dm-confirm-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(17, 26, 46, 0.62);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #d9e6ff;
font-size: 14px;
}
.dm-confirm-check input {
margin-top: 2px;
}
.speech-actions-top {
display: grid;
grid-template-columns: 1fr 1fr;