SHA256
Обновить UI чата и закрыть проверенные DM задачи
This commit is contained in:
@@ -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`, если удаление инициировал отправитель;
|
- `5`, если удаление инициировал отправитель;
|
||||||
- `6`, если удаление инициировал получатель.
|
- `6`, если удаление инициировал получатель.
|
||||||
|
|
||||||
|
UI-следствие для клиента:
|
||||||
|
|
||||||
|
- пользователь может удалить как своё исходящее сообщение, так и входящее;
|
||||||
|
- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`).
|
||||||
|
|
||||||
### 10.4. `DeleteConversation`
|
### 10.4. `DeleteConversation`
|
||||||
|
|
||||||
Назначение:
|
Назначение:
|
||||||
@@ -469,6 +474,13 @@ Request:
|
|||||||
- `7`, если удаление инициировал отправитель;
|
- `7`, если удаление инициировал отправитель;
|
||||||
- `8`, если удаление инициировал получатель.
|
- `8`, если удаление инициировал получатель.
|
||||||
|
|
||||||
|
UI-следствие для клиента:
|
||||||
|
|
||||||
|
- действие `Очистить историю` должно отправлять служебное сообщение удаления переписки;
|
||||||
|
- для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (`type=8`);
|
||||||
|
- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
|
||||||
|
- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`.
|
||||||
|
|
||||||
## 11. Межсерверная доставка
|
## 11. Межсерверная доставка
|
||||||
|
|
||||||
### 11.1. Клиентская сторона
|
### 11.1. Клиентская сторона
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ ReadReceiptBody_v1_0
|
|||||||
- `1`, `2`, `5`, `7` подписывает отправитель сообщения;
|
- `1`, `2`, `5`, `7` подписывает отправитель сообщения;
|
||||||
- `6`, `8` подписывает получатель сообщения.
|
- `6`, `8` подписывает получатель сообщения.
|
||||||
|
|
||||||
|
Практическое UI-следствие:
|
||||||
|
|
||||||
|
- клиентский UI может инициировать удаление входящего сообщения через `type=6`;
|
||||||
|
- клиентский UI может инициировать очистку истории переписки через `type=8`;
|
||||||
|
- сами контейнеры и байтовый формат при этом не отличаются от уже описанных типов `5/6/7/8`.
|
||||||
|
|
||||||
## 12. Какие поля должны совпадать у пары `1/2`
|
## 12. Какие поля должны совпадать у пары `1/2`
|
||||||
|
|
||||||
При обычной парной отправке через `SendMessagePair` у двух копий должны совпадать:
|
При обычной парной отправке через `SendMessagePair` у двух копий должны совпадать:
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.304
|
client.version=1.2.305
|
||||||
server.version=1.2.283
|
server.version=1.2.284
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ import * as addPersonalPublicChatView from './pages/add-personal-public-chat-vie
|
|||||||
import * as networkView from './pages/network-view.js';
|
import * as networkView from './pages/network-view.js';
|
||||||
import * as notificationsView from './pages/notifications-view.js';
|
import * as notificationsView from './pages/notifications-view.js';
|
||||||
|
|
||||||
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
'start-view': startView,
|
'start-view': startView,
|
||||||
'entry-settings-view': entrySettingsView,
|
'entry-settings-view': entrySettingsView,
|
||||||
@@ -1182,6 +1184,16 @@ async function init() {
|
|||||||
});
|
});
|
||||||
} else if (messageType === 7 || messageType === 8) {
|
} else if (messageType === 7 || messageType === 8) {
|
||||||
const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0));
|
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;
|
if (removed > 0) shouldRefreshToolbarUnread = true;
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions =
|
|||||||
right.className = 'header-actions';
|
right.className = 'header-actions';
|
||||||
rightActions.forEach((action) => {
|
rightActions.forEach((action) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'icon-btn';
|
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
|
||||||
btn.textContent = action.label;
|
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);
|
btn.addEventListener('click', action.onClick);
|
||||||
right.append(btn);
|
right.append(btn);
|
||||||
});
|
});
|
||||||
|
|||||||
+249
-15
@@ -6,6 +6,7 @@ import {
|
|||||||
addSignedMessageToChat,
|
addSignedMessageToChat,
|
||||||
addSystemChatMessage,
|
addSystemChatMessage,
|
||||||
addOutgoingPendingMessage,
|
addOutgoingPendingMessage,
|
||||||
|
deleteConversationMessagesBefore,
|
||||||
getChatMessages,
|
getChatMessages,
|
||||||
markChatRead,
|
markChatRead,
|
||||||
markOutgoingSent,
|
markOutgoingSent,
|
||||||
@@ -21,6 +22,7 @@ import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings
|
|||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
|
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||||
|
|
||||||
function truncatePreviewText(value, maxLen = 72) {
|
function truncatePreviewText(value, maxLen = 72) {
|
||||||
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
|
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({
|
function openMessageActionsMenu({
|
||||||
anchorX = 0,
|
anchorX = 0,
|
||||||
anchorY = 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) {
|
function showTtsMissingConfigDialog(navigate) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -443,19 +585,7 @@ export function render({ navigate, route }) {
|
|||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrap = document.createElement('div');
|
const handleStartCall = async () => {
|
||||||
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}`,
|
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
|
||||||
rightActions: [{
|
|
||||||
label: 'Позвонить',
|
|
||||||
onClick: async () => {
|
|
||||||
try {
|
try {
|
||||||
await startOutgoingCall(chatId);
|
await startOutgoingCall(chatId);
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||||
@@ -466,8 +596,109 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
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';
|
||||||
|
|
||||||
|
const log = document.createElement('div');
|
||||||
|
log.className = 'messages-log dm-messages-log';
|
||||||
|
|
||||||
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: `Чат с ${contact.name}`,
|
||||||
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
|
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 || '',
|
messageText: msg?.text || '',
|
||||||
showReadAloud: isTextToSpeechReady,
|
showReadAloud: isTextToSpeechReady,
|
||||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
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),
|
onReadAloud: async () => handleReadAloud(msg),
|
||||||
onEdit: async () => {
|
onEdit: async () => {
|
||||||
startEditMode(msg);
|
startEditMode(msg);
|
||||||
|
|||||||
@@ -44,6 +44,26 @@
|
|||||||
justify-content: flex-end;
|
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,
|
.icon-btn,
|
||||||
.text-btn,
|
.text-btn,
|
||||||
.primary-btn,
|
.primary-btn,
|
||||||
@@ -4062,6 +4082,22 @@ html, body { overflow-x: hidden; }
|
|||||||
background: rgba(112, 28, 28, 0.22);
|
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 {
|
.speech-actions-top {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user