From f3262c2d64114a233cb6f1af9cf10a22618cdcae48e7fc96a1be2cc6f0357a2b Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 19 May 2026 21:00:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20edit/delete=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9,=20=D1=83=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D1=82=D1=8C=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=20DM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/Blockchain/11_TEXT_Blocks.md | 7 + Dev_Docs/Blockchain/CHANGELOG.md | 5 + ...ктирование-сообщений-история-и-delete-empty.md | 24 ++ ...лы-убрать-тред-две-вкладки-и-dm-автоскролл.md | 23 ++ Dev_Docs/Personal_Messages/README.md | 221 ++++++++++++++++++ VERSION.properties | 4 +- shine-UI/js/pages/channel-thread-view.js | 175 ++++++++++++-- shine-UI/js/pages/channel-view.js | 189 +++++++++++++-- shine-UI/js/pages/channels-list.js | 30 +-- shine-UI/js/pages/chat-view.js | 7 + shine-UI/js/services/auth-service.js | 144 ++++++++++++ shine-UI/styles/components.css | 31 +++ .../java/blockchain/body/TextLineBody.java | 27 ++- .../java/blockchain/body/TextReplyBody.java | 27 ++- .../blockchain/Net_AddBlock_Handler.java | 15 +- .../channels/ChannelsReadSupport.java | 9 +- .../Net_GetChannelMessages_Handler.java | 2 +- .../Net_GetMessageThread_Handler.java | 9 +- 18 files changed, 845 insertions(+), 104 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md create mode 100644 Dev_Docs/Pending_Features/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md create mode 100644 Dev_Docs/Personal_Messages/README.md diff --git a/Dev_Docs/Blockchain/11_TEXT_Blocks.md b/Dev_Docs/Blockchain/11_TEXT_Blocks.md index 440497d..2afad12 100644 --- a/Dev_Docs/Blockchain/11_TEXT_Blocks.md +++ b/Dev_Docs/Blockchain/11_TEXT_Blocks.md @@ -19,7 +19,14 @@ TEXT-тип хранит сообщения и редактирования. 4. `subType=21` — `TEXT_EDIT_REPLY` - редактирование ответа; - target на исходный REPLY + новый текст. + - допускается пустой `text` для логического удаления сообщения (без физического удаления блока). ## Правило для edit `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. + +## Пустой text в edit + +- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`. +- Такой edit трактуется как логическое удаление содержимого сообщения. +- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет. diff --git a/Dev_Docs/Blockchain/CHANGELOG.md b/Dev_Docs/Blockchain/CHANGELOG.md index 6800a9a..c168468 100644 --- a/Dev_Docs/Blockchain/CHANGELOG.md +++ b/Dev_Docs/Blockchain/CHANGELOG.md @@ -1,5 +1,10 @@ # История изменений документации блокчейна +## 2026-05-19 20:30:21 +0300 +- Базовый коммит-ориентир: `7986184`. +- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения. +- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком. + ## 2026-05-19 00:22:46 +0300 - Базовый коммит-ориентир: `c27da63a3e65`. - Актуализирован `README.md` как точка входа для MVP-документации по протоколу. diff --git a/Dev_Docs/Pending_Features/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md b/Dev_Docs/Pending_Features/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md new file mode 100644 index 0000000..de66ae9 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md @@ -0,0 +1,24 @@ +# Редактирование сообщений: история и delete через пустой edit + +Статус: `pending` + +## Краткое описание +- Исправлено применение edit-блоков в чтении канала/треда (актуальный текст и версии). +- Для удаления сообщения используется edit с пустым `text` (`textLen=0`). +- В UI добавлена метка `изменено N`, по нажатию открывается история версий. +- Кнопка редактирования оставлена как иконка карандаша без текста. +- В модалке редактирования: сверху `Отмена` и `ОК`, снизу отдельная `Удалить`. + +## Что проверять +1. В канале отредактировать свой пост обычным текстом. +2. Убедиться, что текст сообщения сразу обновился и появилась метка `изменено 1`. +3. Нажать на метку `изменено 1` и проверить историю: сверху оригинал, ниже изменения, последнее внизу. +4. Нажать `Удалить` в модалке редактирования, убедиться, что сообщение отображается как `удалено`. +5. Повторно отредактировать удалённое сообщение непустым текстом и проверить, что текст снова отображается. +6. Повторить пп.1-5 в экране треда. +7. Проверить личный канал (пара A↔B), что edit и история корректно видны для сообщений владельца. + +## Ожидаемый результат +- Edit всегда влияет на отображаемый текст сообщения. +- История версий открывается из метки `изменено N` и содержит полный хронологический список версий. +- Удаление работает как edit с пустым текстом, без физического удаления блока. diff --git a/Dev_Docs/Pending_Features/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md b/Dev_Docs/Pending_Features/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md new file mode 100644 index 0000000..9058005 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md @@ -0,0 +1,23 @@ +# Каналы: убрать кнопку тред, две вкладки, и автоскролл DM + +Статус: `pending` + +## Краткое описание +- В карточках сообщений канала и треда убрана кнопка `Тред` (`#`). +- В `channels-list` оставлены только две вкладки: `Каналы` и `Мои каналы` (вкладка `Чаты` удалена). +- Удалённые сообщения в канале и треде отображаются с красной пометкой `Сообщение удалено`. +- В личных сообщениях усилен автоскролл вниз: при открытии чата и после отправки нового сообщения. + +## Что проверять +1. Открыть канал, проверить, что в действиях сообщения нет кнопки `Тред`. +2. Открыть тред, проверить, что в действиях сообщения также нет кнопки `Тред`. +3. В списке каналов сверху проверить только 2 вкладки: `Каналы` и `Мои каналы`. +4. Удалить сообщение edit-ом в канале и в треде, убедиться, что видно красную пометку `Сообщение удалено`. +5. Открыть любой личный чат, убедиться, что экран сразу внизу ленты. +6. Отправить новое сообщение в личке, убедиться, что лента остаётся прокрученной вниз и сообщение видно сразу. + +## Ожидаемый результат +- Лишняя кнопка `Тред` отсутствует. +- Вкладка `Чаты` отсутствует, навигация в списке каналов состоит из 2 вкладок. +- Удалённые сообщения визуально выделены красным в канале и в треде. +- В DM нет ручной прокрутки после входа в чат и после отправки новых сообщений. diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md new file mode 100644 index 0000000..1879112 --- /dev/null +++ b/Dev_Docs/Personal_Messages/README.md @@ -0,0 +1,221 @@ +# Личные сообщения (DM): как это устроено + +## Коротко (для быстрого понимания) + +Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`: + +- тип `1` — входящее сообщение для собеседника; +- тип `2` — исходящая копия того же сообщения для автора. + +Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе. +Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`. + +Подтверждение прочтения также идёт парой блоков: + +- тип `3` — «прочитано» для исходящего сообщения автора; +- тип `4` — зеркальная копия для второй стороны. + +UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история. + +--- + +## Подробно + +## 1) Общая схема потока + +1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`. +2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`). +3. Сервер: + - парсит оба блока; + - валидирует пару; + - проверяет существование `from/to` пользователей и подписи; + - атомарно сохраняет пару в `signed_messages_v2`. +4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`. +5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`. +6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих. + +## 2) Формат signed DM-блока (`SHiNE_dm2`) + +Префикс: `SHiNE_dm2` (ASCII). + +Далее поля (big-endian): + +1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60); +2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60); +3. `timeMs` (`u64`); +4. `nonce` (`u32`); +5. `messageType` (`u16`); +6. `payloadLen` (`u16`); +7. `payloadBytes` (`1..4096`); +8. `signature` (`64 bytes`, Ed25519). + +Ограничения: + +- полный пакет: до `8192` байт; +- `messageType` сейчас допустим только `1..4`. + +## 3) Типы DM-сообщений + +- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя. +- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора. +- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции). +- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt. + +Правило пары: + +- первый блок должен быть нечётным (`1` или `3`); +- второй должен быть ровно `+1` (`2` или `4`); +- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`. + +## 4) Ключи сообщений + +- `baseKey = from|to|timeMs|nonce` +- `messageKey = baseKey|messageType` + +Эти ключи используются: + +- для дедупликации; +- для связи read-receipt с исходным сообщением; +- для ACK доставки по сессии. + +## 5) RPC и события + +## `SendMessagePair` (алиас `ReceiveOutcomingMessage`) + +Запрос: + +```json +{ + "op": "SendMessagePair", + "requestId": "req-1", + "payload": { + "incomingBlobB64": "", + "outgoingBlobB64": "" + } +} +``` + +Успешный ответ: + +```json +{ + "op": "SendMessagePair", + "requestId": "req-1", + "status": 200, + "ok": true, + "payload": { + "baseKey": "from|to|time|nonce", + "incomingKey": "from|to|time|nonce|1", + "outgoingKey": "from|to|time|nonce|2", + "deliveredWsSessions": 2, + "deliveredWebPushSessions": 1 + } +} +``` + +## `SignedMessageArrived` (server event) + +Событие в сессию получателя содержит: + +- `messageKey`, `baseKey`; +- `fromLogin`, `toLogin`, `targetLogin`; +- `messageType`, `timeMs`, `nonce`; +- `blobB64`; +- `backlog` (признак догрузки из очереди). + +## `AckSessionDelivery` + +Запрос: + +```json +{ + "op": "AckSessionDelivery", + "requestId": "ack-1", + "payload": { + "messageKey": "from|to|time|nonce|1" + } +} +``` + +Ответ: `status=200`, echo `messageKey`. + +## 6) Хранение на сервере (SQLite) + +Основные таблицы: + +1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`: + - `message_key` (PK), + - `base_key`, + - `target_login`, + - `from_login`, `to_login`, + - `time_ms`, `nonce`, `message_type`, + - `raw_block`, + - `source_api`, `origin_session_id`, + - `receipt_ref_base_key`, `receipt_ref_type`. +2. `signed_message_session_delivery` — доставка по сессиям: + - составной PK `(message_key, session_id)`, + - `delivered` (0/1), + - `delivered_at_ms`, `created_at_ms`. + +Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`. + +## 7) Доставка и backlog + +- При сохранении пары сервер пытается сразу доставить в онлайн-сессии. +- Для офлайн/недоступных сессий остаётся pending-запись доставки. +- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`. +- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`. + +## 8) Read-receipt логика + +Когда клиент открывает чат: + +1. ищет входящие `messageType=1` без `readReceiptSent`; +2. для каждого отправляет read-receipt как пару `type=3/4`; +3. после успешной отправки помечает `readReceiptSent`. + +Сервер для read-receipt хранит ссылку на исходное сообщение: + +- `receipt_ref_base_key`; +- `receipt_ref_type`. + +Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`. + +## 9) Логика UI-клиента + +В UI: + +- чат хранится в `state.chats[chatId]`; +- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`; +- непрочитанные считаются по `from='in' && unread=true`; +- доставка/прочтение исходящих: + - `firstTick` — сообщение принято в парный поток, + - `secondTick` — пришло подтверждение прочтения; +- при открытии диалога UI автопрокручивает ленту в самый низ; +- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости; +- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`). + +## 10) Инварианты (обязательно соблюдать при доработках) + +1. Пара блоков (1/2 или 3/4) должна оставаться атомарной. +2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt. +3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`. +4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`. +5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе. + +## 11) Ключевые файлы реализации + +- UI: + - `shine-UI/js/services/auth-service.js` + - `shine-UI/js/app.js` + - `shine-UI/js/state.js` + - `shine-UI/js/pages/chat-view.js` +- Сервер: + - `shine-server-net-protocol/.../messages/SignedMessageBlock.java` + - `shine-server-net-protocol/.../messages/SignedMessagesCore.java` + - `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java` + - `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java` + - `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java` +- БД: + - `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java` + - `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java` diff --git a/VERSION.properties b/VERSION.properties index a478dbd..42ac682 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.74 -server.version=1.2.68 +client.version=1.2.75 +server.version=1.2.69 diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 74f2f46..3dbe9b1 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -246,12 +246,11 @@ function firstNonEmptyText(...candidates) { } function latestVersionText(versions) { - if (!Array.isArray(versions)) return ''; - for (let i = versions.length - 1; i >= 0; i -= 1) { - const version = versions[i]; - const value = firstNonEmptyText(version?.text, version?.message, version?.body); - if (value) return value; - } + if (!Array.isArray(versions) || !versions.length) return ''; + const version = versions[versions.length - 1]; + if (typeof version?.text === 'string') return version.text; + if (typeof version?.message === 'string') return version.message; + if (typeof version?.body === 'string') return version.body; return ''; } @@ -333,15 +332,104 @@ function openReplyModal({ onSubmit, navigate }) { if (textEl) textEl.focus(); } +function openMessageHistoryModal({ versions = [], title = 'История изменений' }) { + const root = document.getElementById('modal-root'); + const rows = Array.isArray(versions) ? versions : []; + root.innerHTML = ` + + `; + + const list = root.querySelector('#thread-history-list'); + if (list) { + rows.forEach((item, index) => { + const row = document.createElement('div'); + row.className = 'card stack'; + const ts = Number(item?.createdAtMs || 0); + const text = String(item?.text || '').trim() || 'удалено'; + row.innerHTML = ` + Версия ${index + 1} +
${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}
+

${text}

+ `; + list.append(row); + }); + } + + root.querySelector('#thread-history-close')?.addEventListener('click', () => { + root.innerHTML = ''; + }); +} + +function openEditMessageModal({ initialText = '', onSave, onDelete }) { + const root = document.getElementById('modal-root'); + root.innerHTML = ` + + `; + const textEl = root.querySelector('#thread-edit-text'); + const errorEl = root.querySelector('#thread-edit-error'); + if (textEl) textEl.value = String(initialText || ''); + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#thread-edit-cancel')?.addEventListener('click', close); + root.querySelector('#thread-edit-save')?.addEventListener('click', async () => { + const value = String(textEl?.value || '').trim(); + if (!value) { + errorEl.textContent = 'Введите текст сообщения.'; + return; + } + try { + await onSave(value); + close(); + } catch (error) { + errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.'); + } + }); + root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => { + try { + await onDelete(); + close(); + } catch (error) { + errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.'); + } + }); + if (textEl) textEl.focus(); +} + function renderNodeCard(node, heading, handlers, localNumber) { const card = document.createElement('article'); card.className = 'card stack thread-node-card channel-message-card'; card.classList.add('is-counters-visible'); const author = node?.authorLogin || 'автор'; - const text = resolveNodeText(node) || '(пусто)'; + const versions = Array.isArray(node?.versions) ? node.versions : []; + const versionsTotal = Number(node?.versionsTotal || versions.length || 1); + const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)'); const likes = Number(node?.likesCount || 0); const replies = Number(node?.repliesCount || 0); + const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(); + const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0; const headingText = String(heading || '').trim(); if (headingText) { @@ -369,16 +457,33 @@ function renderNodeCard(node, heading, handlers, localNumber) { const numberEl = document.createElement('span'); numberEl.className = 'author-line-num'; numberEl.textContent = `· #${localNumber}`; + title.append(loginEl, numberEl); + if (versionsTotal > 1) { + const editedMarker = document.createElement('button'); + editedMarker.type = 'button'; + editedMarker.className = 'message-edited-marker'; + editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`; + editedMarker.title = 'Открыть историю редактирования'; + editedMarker.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + openMessageHistoryModal({ + title: `История #${localNumber}`, + versions, + }); + }); + title.append(editedMarker); + } const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; - title.append(loginEl, numberEl); authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); + const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено'; const body = document.createElement('p'); - body.className = 'channel-message-body'; - body.textContent = text; + body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`; + body.textContent = isDeletedMessage ? 'Сообщение удалено' : text; card.append(authorTile, body); @@ -460,20 +565,27 @@ function renderNodeCard(node, heading, handlers, localNumber) { await handlers.onShare(target); }); - const openThreadButton = document.createElement('button'); - openThreadButton.type = 'button'; - openThreadButton.className = 'channel-action-item thread-open-btn'; - openThreadButton.innerHTML = ` - - Тред - `; - openThreadButton.addEventListener('click', (event) => { - event.stopPropagation(); - animatePress(event.currentTarget); - handlers.onOpenThread(target); - }); - - actions.append(likeButton, replyButton, openThreadButton, shareButton); + actions.append(likeButton, replyButton, shareButton); + if (isOwnMessage) { + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.className = 'channel-action-item'; + editButton.setAttribute('aria-label', 'Редактировать'); + editButton.title = 'Редактировать'; + editButton.innerHTML = ` + + `; + editButton.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + openEditMessageModal({ + initialText: String(text || '').trim() === 'удалено' ? '' : text, + onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }), + onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }), + }); + }); + actions.append(editButton); + } card.append(actions); authorTile.addEventListener('click', (event) => { event.stopPropagation(); @@ -670,6 +782,21 @@ export function render({ navigate, route }) { : 'Не удалось поставить лайк.'; showStatus(toUserMessage(error, fallback)); }, + onEdit: async (target, textValue, meta = {}) => { + const { login, storagePwd } = requireSigningSession(); + await authService.addBlockEditMessage({ + login, + storagePwd, + message: target, + text: textValue, + isChannelPost: meta?.isChannelPost === true, + channel: selector?.channel || null, + }); + softHaptic(12); + showToast('Сообщение обновлено'); + showStatus(''); + rerender(); + }, }; screen.append(header, statusBox); diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index b6996d4..95f35f7 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -165,12 +165,11 @@ function firstNonEmptyText(...candidates) { } function latestVersionText(versions) { - if (!Array.isArray(versions)) return ''; - for (let i = versions.length - 1; i >= 0; i -= 1) { - const version = versions[i]; - const value = firstNonEmptyText(version?.text, version?.message, version?.body); - if (value) return value; - } + if (!Array.isArray(versions) || !versions.length) return ''; + const version = versions[versions.length - 1]; + if (typeof version?.text === 'string') return version.text; + if (typeof version?.message === 'string') return version.message; + if (typeof version?.body === 'string') return version.body; return ''; } @@ -377,6 +376,92 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) { if (textEl) textEl.focus(); } +function openMessageHistoryModal({ versions = [], title = 'История изменений' }) { + const root = document.getElementById('modal-root'); + const rows = Array.isArray(versions) ? versions : []; + root.innerHTML = ` + + `; + + const list = root.querySelector('#message-history-list'); + if (list) { + rows.forEach((item, index) => { + const row = document.createElement('div'); + row.className = 'card stack'; + const ts = toTimestampMs(item?.createdAtMs); + const text = String(item?.text || '').trim() || 'удалено'; + row.innerHTML = ` + Версия ${index + 1} +
${ts > 0 ? formatRelativeTime(ts) : '—'}
+

${text}

+ `; + list.append(row); + }); + } + + root.querySelector('#message-history-close')?.addEventListener('click', () => { + root.innerHTML = ''; + }); +} + +function openEditMessageModal({ initialText = '', onSave, onDelete }) { + const root = document.getElementById('modal-root'); + root.innerHTML = ` + + `; + + const textEl = root.querySelector('#edit-message-text'); + const errorEl = root.querySelector('#edit-message-error'); + if (textEl) textEl.value = String(initialText || ''); + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#edit-message-cancel')?.addEventListener('click', close); + root.querySelector('#edit-message-save')?.addEventListener('click', async () => { + const value = String(textEl?.value || '').trim(); + if (!value) { + errorEl.textContent = 'Введите текст сообщения.'; + return; + } + try { + await onSave(value); + close(); + } catch (error) { + errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.'); + } + }); + root.querySelector('#edit-message-delete')?.addEventListener('click', async () => { + try { + await onDelete(); + close(); + } catch (error) { + errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.'); + } + }); + if (textEl) textEl.focus(); +} + function mapApiMessageToPost(message, selector, localNumber) { const blockNumber = toSafeInt(message?.messageRef?.blockNumber); const blockHash = normalizeMessageHash(message?.messageRef?.blockHash); @@ -399,12 +484,15 @@ function mapApiMessageToPost(message, selector, localNumber) { return { localNumber, authorLogin: message?.authorLogin || 'автор', - body: resolvedText || '(пусто)', + body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'), + versionsTotal: Number(message?.versionsTotal || 1), + versions: Array.isArray(message?.versions) ? message.versions : [], likesCount: Number(message?.likesCount || 0), repliesCount: Number(message?.repliesCount || 0), timestampMs: resolveMessageTimestampMs(message), messageRef, reactionState: messageRef ? getMessageReactionState(messageRef) : '', + isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), }; } @@ -625,7 +713,10 @@ function renderPostCard(post, { onToggleLike, onReply, onShare, + onEdit, }) { + const versionsTotal = Number(post?.versionsTotal || 1); + const card = document.createElement('article'); card.className = 'card stack channel-message-card'; @@ -655,6 +746,22 @@ function renderPostCard(post, { timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—'; title.append(loginEl, numberEl); + if (versionsTotal > 1) { + const editedMarker = document.createElement('button'); + editedMarker.type = 'button'; + editedMarker.className = 'message-edited-marker'; + editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`; + editedMarker.title = 'Открыть историю редактирования'; + editedMarker.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + openMessageHistoryModal({ + title: `История #${post.localNumber}`, + versions: post.versions, + }); + }); + title.append(editedMarker); + } authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); authorTile.addEventListener('click', (event) => { @@ -664,9 +771,10 @@ function renderPostCard(post, { navigate(`user/${encodeRoutePart(cleanLogin)}`); }); + const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено'; const body = document.createElement('p'); - body.className = 'channel-message-body'; - body.textContent = post.body; + body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`; + body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body; card.append(authorTile, body); @@ -728,20 +836,6 @@ function renderPostCard(post, { }); actions.append(likeButton, replyButton); - const openThreadButton = document.createElement('button'); - openThreadButton.type = 'button'; - openThreadButton.className = 'channel-action-item channel-action-thread'; - openThreadButton.innerHTML = ` - - Тред - `; - openThreadButton.addEventListener('click', (event) => { - event.stopPropagation(); - animatePress(event.currentTarget); - const route = buildThreadRoute(post.messageRef, selector); - if (route) navigate(route); - }); - const shareButton = document.createElement('button'); shareButton.type = 'button'; shareButton.className = 'channel-action-item channel-action-share'; @@ -756,7 +850,27 @@ function renderPostCard(post, { await onShare(route); }); - actions.append(openThreadButton, shareButton); + actions.append(shareButton); + if (post.isOwnMessage) { + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.className = 'channel-action-item'; + editButton.setAttribute('aria-label', 'Редактировать'); + editButton.title = 'Редактировать'; + editButton.innerHTML = ` + + `; + editButton.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + openEditMessageModal({ + initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body, + onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }), + onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }), + }); + }); + actions.append(editButton); + } card.append(actions); card.addEventListener('click', () => { const route = buildThreadRoute(post.messageRef, selector); @@ -791,6 +905,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onShare: handlers.onShare, + onEdit: handlers.onEdit, }); const key = messageRefKey(post.messageRef); if (key) { @@ -976,6 +1091,24 @@ export function render({ navigate, route }) { rerender(); }; + const onEditPost = async (messageRef, text) => { + const { login, storagePwd } = requireSigningSession(); + if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { + throw new Error('Идентификатор канала не готов.'); + } + await authService.addBlockEditMessage({ + login, + storagePwd, + message: messageRef, + text, + isChannelPost: true, + channel: activeSelector, + }); + softHaptic(12); + showToast('Сообщение обновлено'); + rerender(); + }; + screen.append(header); screen.append(statusBox); @@ -1023,6 +1156,14 @@ export function render({ navigate, route }) { } }, onShare: onShare, + onEdit: async (messageRef, text) => { + try { + await onEditPost(messageRef, text); + showStatus(''); + } catch (error) { + throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.')); + } + }, onSubscribeChannel: async (event) => { animatePress(event?.currentTarget); try { diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 20fa449..7f60638 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -17,7 +17,7 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const MENU_OVERLAY_ID = 'channels-context-menu-overlay'; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; -const TAB_ORDER = ['dialogs', 'feed', 'my']; +const TAB_ORDER = ['feed', 'my']; function isChannelsDemoMode() { try { @@ -583,11 +583,9 @@ function mapMockGroups() { const mapRow = (channel) => ({ ...channel, route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`, - tabCategory: channel.kind === 'own' + tabCategory: channel.kind === 'own' || channel.kind === 'own-personal' ? 'my' - : channel.kind === 'own-personal' - ? 'dialogs' - : 'feed', + : 'feed', messagePreview: channel.lastMessage || 'Ждем ваших начинаний', isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal', isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal', @@ -625,9 +623,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) { const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1); const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1); const isOwn = bucketKey === 'own'; - const tabCategory = isOwn - ? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my') - : 'feed'; + const tabCategory = isOwn ? 'my' : 'feed'; const title = isOwn ? channelName : `${ownerLogin}/${channelName}`; @@ -691,8 +687,6 @@ function renderEmptyState(activeTab, navigate) { text.className = 'meta-muted'; if (activeTab === 'feed') { text.textContent = 'Нет подписок и найденных каналов.'; - } else if (activeTab === 'dialogs') { - text.textContent = 'Чаты пока не работают.'; } else if (activeTab === 'my') { text.textContent = 'У вас пока нет каналов.'; } else { @@ -953,7 +947,7 @@ function renderChannelMain(channel, activeTab) { title.className = 'channel-row-title'; title.textContent = activeTab === 'my' ? channel.channelName : channel.title; - if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) { + if (activeTab === 'my' && channel.channelDescription) { const desc = document.createElement('p'); desc.className = 'channel-row-description'; desc.textContent = channel.channelDescription; @@ -1062,13 +1056,6 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) { return; } - if (tab === 'dialogs') { - button.textContent = 'Новый персональный публичный чат'; - button.className = baseClass; - button.onclick = () => navigate('add-personal-public-chat-view'); - return; - } - if (tab === 'my') { button.textContent = 'Создать канал'; button.className = baseClass; @@ -1140,18 +1127,13 @@ export function render({ navigate, route }) { tabsEl.className = 'channels-tabs'; const tabLabels = { feed: 'Каналы', - dialogs: 'Чаты', - my: 'Мои', + my: 'Мои каналы', }; TAB_ORDER.forEach((tabKey) => { const tabBtn = document.createElement('button'); tabBtn.type = 'button'; tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`; tabBtn.textContent = tabLabels[tabKey] || tabKey; - if (tabKey === 'dialogs') { - tabBtn.classList.add('is-disabled'); - tabBtn.title = 'Чаты пока не работают'; - } tabBtn.addEventListener('click', () => { if (listState.activeTab === tabKey) return; listState.activeTab = tabKey; diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index d6c3375..3298775 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -172,8 +172,11 @@ function scrollToLatestMessage(list) { }; apply(); window.requestAnimationFrame(apply); + window.requestAnimationFrame(() => window.requestAnimationFrame(apply)); window.setTimeout(apply, 0); + window.setTimeout(apply, 60); window.setTimeout(apply, 120); + window.setTimeout(apply, 260); } function renderLog(list, chatId, { onOpenActions } = {}) { @@ -416,6 +419,9 @@ export function render({ navigate, route }) { const input = form.elements.message; autoResizeComposer(input); input?.addEventListener('input', () => autoResizeComposer(input)); + input?.addEventListener('focus', () => { + scrollToLatestMessage(log); + }); input?.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') return; if (event.ctrlKey) { @@ -480,6 +486,7 @@ export function render({ navigate, route }) { }), }); window.requestAnimationFrame(() => scrollToLatestMessage(log)); + window.setTimeout(() => scrollToLatestMessage(log), 180); void sendReadReceiptsForVisible(chatId); return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index cfef505..ebbe710 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -37,7 +37,9 @@ const MSG_TYPE_CONNECTION = 3; const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1; const MSG_SUBTYPE_TEXT_POST = 10; +const MSG_SUBTYPE_TEXT_EDIT_POST = 11; const MSG_SUBTYPE_TEXT_REPLY = 20; +const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21; const MSG_SUBTYPE_REACTION_LIKE = 1; const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; @@ -366,6 +368,56 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe ); } +function makeTextEditPostBodyBytes({ + lineCode, + prevLineNumber, + prevLineHashHex, + thisLineNumber, + toBlockNumber, + toBlockHashHex, + text, +}) { + const message = String(text || '').trim(); + const targetBlockNumber = Number(toBlockNumber); + if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) { + throw new Error('Invalid target block number for edit post'); + } + const textBytes = utf8Bytes(message); + if (textBytes.length > 65535) { + throw new Error('Message text must be 0..65535 UTF-8 bytes'); + } + + return concatBytes( + int32Bytes(lineCode), + int32Bytes(prevLineNumber), + hexToBytes(normalizeHex32(prevLineHashHex)), + int32Bytes(thisLineNumber), + int32Bytes(targetBlockNumber), + hexToBytes(normalizeHex32(toBlockHashHex)), + int16Bytes(textBytes.length), + textBytes, + ); +} + +function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) { + const message = String(text || '').trim(); + const targetBlockNumber = Number(toBlockNumber); + if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) { + throw new Error('Invalid target block number for edit reply'); + } + const textBytes = utf8Bytes(message); + if (textBytes.length > 65535) { + throw new Error('Message text must be 0..65535 UTF-8 bytes'); + } + + return concatBytes( + int32Bytes(targetBlockNumber), + hexToBytes(normalizeHex32(toBlockHashHex)), + int16Bytes(textBytes.length), + textBytes, + ); +} + function makeConnectionBodyBytes({ lineCode = 0, prevLineNumber = -1, @@ -955,6 +1007,98 @@ export class AuthService { }); } + async addBlockEditMessage({ + login, + message, + text, + storagePwd, + isChannelPost = false, + channel = null, + }) { + const cleanLogin = String(login || '').trim(); + const cleanText = String(text || '').trim(); + const target = normalizeMessageRefTarget(message, 'edit'); + const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`; + + return this.runWriteLocked(lockKey, async () => { + if (isChannelPost) { + const selector = channel || {}; + const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim(); + const lineCode = Number(selector?.channelRootBlockNumber); + if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) { + throw new Error('Invalid channel selector for edit'); + } + + let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); + if (rootHashHex === ZERO64) { + const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName); + const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode); + if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64); + } + + let prevLineNumber = lineCode; + let prevLineHashHex = rootHashHex; + let thisLineNumber = 1; + try { + const latestPayload = await this.getChannelMessages({ + ownerBlockchainName, + channelRootBlockNumber: lineCode, + channelRootBlockHash: rootHashHex, + }, 1, 'desc', cleanLogin); + const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; + const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : []; + const latestVersion = latestVersions[latestVersions.length - 1] || null; + const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber); + const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, ''); + const latestVersionsTotal = Number(latestMessage?.versionsTotal); + if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) { + prevLineNumber = latestBlockNumber; + prevLineHashHex = latestBlockHash; + thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0 + ? Math.max(1, latestVersionsTotal) + : 1; + } + } catch { + // fallback to root anchor + } + + const bodyBytes = makeTextEditPostBodyBytes({ + lineCode, + prevLineNumber, + prevLineHashHex, + thisLineNumber, + toBlockNumber: target.blockNumber, + toBlockHashHex: target.blockHash, + text: cleanText, + }); + + return this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: MSG_TYPE_TEXT, + msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST, + msgVersion: 1, + bodyBytes, + }); + } + + const bodyBytes = makeTextEditReplyBodyBytes({ + toBlockNumber: target.blockNumber, + toBlockHashHex: target.blockHash, + text: cleanText, + }); + + return this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: MSG_TYPE_TEXT, + msgSubType: MSG_SUBTYPE_TEXT_EDIT_REPLY, + msgVersion: 1, + bodyBytes, + }); + }); + } + async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) { const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, ''); if (!cleanTargetLogin) throw new Error('Target login is required'); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 6d5cdb3..f3de5dd 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1204,6 +1204,10 @@ textarea.input { gap: 10px; } +.modal-danger-action { + width: 100%; +} + .small-btn { padding: 6px 10px; font-size: 13px; @@ -2238,6 +2242,14 @@ textarea.input { border: 0; } +.channel-message-body--deleted { + color: #ff9e9e; + border: 1px solid rgba(255, 126, 126, 0.5); + background: rgba(120, 18, 18, 0.28); + border-radius: 10px; + padding: 8px 10px; +} + .channel-message-time { font-size: 11px; color: rgba(255, 255, 255, 0.48); @@ -2319,6 +2331,25 @@ textarea.input { letter-spacing: 0.01em; } +.message-edited-marker { + appearance: none; + border: none; + background: transparent; + color: rgba(255, 220, 100, 0.85); + font-size: 11px; + line-height: 1; + padding: 0; + margin-left: 6px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.message-edited-marker:hover, +.message-edited-marker:focus-visible { + color: rgba(255, 232, 150, 0.95); +} + .channel-action-counter { font-size: 11px; color: rgba(255, 255, 255, 0.45); diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 9a24d00..58f1adc 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -105,7 +105,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge this.toBlockHash32 = null; } - this.message = readStrictUtf8Len16(bb, "TextLineBody text"); + this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)); ensureNoTail(bb, "TextLineBody"); } @@ -129,7 +129,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge } if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (message.isBlank()) throw new IllegalArgumentException("message is blank"); + if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) { + throw new IllegalArgumentException("message is blank"); + } this.subType = subType; this.version = VER; @@ -165,15 +167,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); - if (message == null || message.isBlank()) - throw new IllegalArgumentException("Text message is blank"); - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + if (message == null) throw new IllegalArgumentException("EDIT_POST message is null"); if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); } else { + if (message == null || message.isBlank()) + throw new IllegalArgumentException("Text message is blank"); if (toBlockGlobalNumber != null || toBlockHash32 != null) throw new IllegalArgumentException("POST must not contain target fields"); } @@ -184,10 +186,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge @Override public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); - if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; + if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) { + throw new IllegalArgumentException("Text payload is empty"); + } int cap; if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { @@ -234,9 +238,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); } - private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { int len = Short.toUnsignedInt(bb.getShort()); - if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (len == 0) { + if (allowEmpty) return ""; + throw new IllegalArgumentException(fieldName + " is empty"); + } if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); byte[] bytes = new byte[len]; @@ -248,7 +255,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge try { String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); - if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); return s; } catch (CharacterCodingException e) { throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); @@ -262,4 +269,4 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge private static void ensureNoTail(ByteBuffer bb, String ctx) { if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); } -} \ No newline at end of file +} diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java b/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java index b2131ff..ceeae92 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextReplyBody.java @@ -96,7 +96,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { bb.get(this.toBlockHash32); } - this.message = readStrictUtf8Len16(bb, "TextReplyBody text"); + this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)); ensureNoTail(bb, "TextReplyBody"); } @@ -113,8 +113,10 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY"); } + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) { + throw new IllegalArgumentException("message is blank"); + } - if (message.isBlank()) throw new IllegalArgumentException("message is blank"); if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); @@ -142,18 +144,18 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); - if (message == null || message.isBlank()) - throw new IllegalArgumentException("Text message is blank"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { + if (message == null || message.isBlank()) + throw new IllegalArgumentException("Text message is blank"); if (toBlockchainName == null || toBlockchainName.isBlank()) throw new IllegalArgumentException("REPLY toBlockchainName is blank"); } else { + if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null"); if (toBlockchainName != null) throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName"); } @@ -164,10 +166,12 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { @Override public byte[] toBytes() { byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); - if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); int st = subType & 0xFFFF; + if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) { + throw new IllegalArgumentException("Text payload is empty"); + } if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); @@ -213,9 +217,12 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { /* ====================== helpers ====================== */ - private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { + private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { int len = Short.toUnsignedInt(bb.getShort()); - if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); + if (len == 0) { + if (allowEmpty) return ""; + throw new IllegalArgumentException(fieldName + " is empty"); + } if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); byte[] bytes = new byte[len]; @@ -227,7 +234,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { try { String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); - if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); + if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); return s; } catch (CharacterCodingException e) { throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); @@ -241,4 +248,4 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget { private static void ensureNoTail(ByteBuffer bb, String ctx) { if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); } -} \ No newline at end of file +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index a1cfda4..eb3ef1f 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -410,10 +410,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { // target columns (optional) if (block.body instanceof BodyHasTarget t) { + String targetBchName = t.toBchName(); + int type = block.type & 0xFFFF; + int sub = block.subType & 0xFFFF; + boolean isTextEdit = type == 1 + && (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)); + if (isTextEdit && (targetBchName == null || targetBchName.isBlank())) { + targetBchName = blockchainName; + } + be.setToLogin(t.toLogin()); - be.setToBchName(t.toBchName()); + be.setToBchName(targetBchName); be.setToBlockNumber(t.toBlockGlobalNumber()); be.setToBlockHash(t.toBlockHashBytes()); + + if (isTextEdit && (be.getToLogin() == null || be.getToLogin().isBlank()) && targetBchName != null && !targetBchName.isBlank()) { + be.setToLogin(BlockchainNameUtil.loginFromBlockchainName(targetBchName)); + } } // edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели" diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 86ca0d5..26c7dfc 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -218,16 +218,17 @@ final class ChannelsReadSupport { SELECT login,bch_name,block_number,block_hash,block_bytes FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? - AND to_bch_name=? AND to_block_number=? AND to_block_hash=? + AND to_block_number=? AND to_block_hash=? + AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='') ORDER BY block_number ASC """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_EDIT_POST); - ps.setString(4, ownerBch); - ps.setInt(5, originalBlock); - ps.setBytes(6, originalHash); + ps.setInt(4, originalBlock); + ps.setBytes(5, originalHash); + ps.setString(6, ownerBch); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) { diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 52f8be2..9c501f9 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -111,7 +111,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { v1.setCreatedAtMs(postText.createdAtMs); versionsOut.add(v1); - List edits = ChannelsReadSupport.versionsForPost(c, ownerBch, post.blockNumber, post.blockHash); + List edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash); for (ChannelsReadSupport.PostBlock edit : edits) { ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem(); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index e6cce70..f58ad34 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -196,15 +196,16 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type FROM blocks WHERE bch_name=? AND msg_type=1 AND msg_sub_type=? - AND to_bch_name=? AND to_block_number=? AND to_block_hash=? + AND to_block_number=? AND to_block_hash=? + AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='') ORDER BY block_number ASC """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, bch); ps.setInt(2, subType); - ps.setString(3, bch); - ps.setInt(4, targetBlock); - ps.setBytes(5, targetHash); + ps.setInt(3, targetBlock); + ps.setBytes(4, targetHash); + ps.setString(5, bch); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) out.add(mapRow(rs));