diff --git a/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md b/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md deleted file mode 100644 index e581d8d..0000000 --- a/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md +++ /dev/null @@ -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 diff --git a/Dev_Docs/Pending_Features/2026-07-09_1415_turn_только_один_живой_узел.md b/Dev_Docs/Pending_Features/2026-07-09_1415_turn_только_один_живой_узел.md deleted file mode 100644 index e5eec33..0000000 --- a/Dev_Docs/Pending_Features/2026-07-09_1415_turn_только_один_живой_узел.md +++ /dev/null @@ -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 diff --git a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md index e7c4a9f..f8e42c9 100644 --- a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md @@ -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. Клиентская сторона diff --git a/Dev_Docs/Personal_Messages/Формат_DM_v1.md b/Dev_Docs/Personal_Messages/Формат_DM_v1.md index 9f36e40..26c2856 100644 --- a/Dev_Docs/Personal_Messages/Формат_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Формат_DM_v1.md @@ -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` у двух копий должны совпадать: diff --git a/VERSION.properties b/VERSION.properties index 9d3ea52..48b2dad 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.304 -server.version=1.2.283 +client.version=1.2.305 +server.version=1.2.284 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 0612131..2b23d73 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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', diff --git a/shine-UI/js/components/header.js b/shine-UI/js/components/header.js index 7a5b68e..ead1a75 100644 --- a/shine-UI/js/components/header.js +++ b/shine-UI/js/components/header.js @@ -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); }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 1dcd307..524ec3e 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -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 = ` + + `; + + 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 = ` + + `; + + 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 = ` +
+ +
+ `; + + 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); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 84a702c..8f69c9d 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -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;