Исправить edit/delete сообщений, упростить вкладки каналов и улучшить автоскролл DM
This commit is contained in:
parent
7986184111
commit
f3262c2d64
@ -19,7 +19,14 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||||
- редактирование ответа;
|
- редактирование ответа;
|
||||||
- target на исходный REPLY + новый текст.
|
- target на исходный REPLY + новый текст.
|
||||||
|
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||||
|
|
||||||
|
## Пустой text в edit
|
||||||
|
|
||||||
|
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
|
||||||
|
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||||
|
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||||
|
|||||||
@ -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
|
## 2026-05-19 00:22:46 +0300
|
||||||
- Базовый коммит-ориентир: `c27da63a3e65`.
|
- Базовый коммит-ориентир: `c27da63a3e65`.
|
||||||
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
|
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
|
||||||
|
|||||||
@ -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 с пустым текстом, без физического удаления блока.
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Каналы: убрать кнопку тред, две вкладки, и автоскролл DM
|
||||||
|
|
||||||
|
Статус: `pending`
|
||||||
|
|
||||||
|
## Краткое описание
|
||||||
|
- В карточках сообщений канала и треда убрана кнопка `Тред` (`#`).
|
||||||
|
- В `channels-list` оставлены только две вкладки: `Каналы` и `Мои каналы` (вкладка `Чаты` удалена).
|
||||||
|
- Удалённые сообщения в канале и треде отображаются с красной пометкой `Сообщение удалено`.
|
||||||
|
- В личных сообщениях усилен автоскролл вниз: при открытии чата и после отправки нового сообщения.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
1. Открыть канал, проверить, что в действиях сообщения нет кнопки `Тред`.
|
||||||
|
2. Открыть тред, проверить, что в действиях сообщения также нет кнопки `Тред`.
|
||||||
|
3. В списке каналов сверху проверить только 2 вкладки: `Каналы` и `Мои каналы`.
|
||||||
|
4. Удалить сообщение edit-ом в канале и в треде, убедиться, что видно красную пометку `Сообщение удалено`.
|
||||||
|
5. Открыть любой личный чат, убедиться, что экран сразу внизу ленты.
|
||||||
|
6. Отправить новое сообщение в личке, убедиться, что лента остаётся прокрученной вниз и сообщение видно сразу.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
- Лишняя кнопка `Тред` отсутствует.
|
||||||
|
- Вкладка `Чаты` отсутствует, навигация в списке каналов состоит из 2 вкладок.
|
||||||
|
- Удалённые сообщения визуально выделены красным в канале и в треде.
|
||||||
|
- В DM нет ручной прокрутки после входа в чат и после отправки новых сообщений.
|
||||||
221
Dev_Docs/Personal_Messages/README.md
Normal file
221
Dev_Docs/Personal_Messages/README.md
Normal file
@ -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": "<base64 signed block type 1 or 3>",
|
||||||
|
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Успешный ответ:
|
||||||
|
|
||||||
|
```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`
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.74
|
client.version=1.2.75
|
||||||
server.version=1.2.68
|
server.version=1.2.69
|
||||||
|
|||||||
@ -246,12 +246,11 @@ function firstNonEmptyText(...candidates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function latestVersionText(versions) {
|
function latestVersionText(versions) {
|
||||||
if (!Array.isArray(versions)) return '';
|
if (!Array.isArray(versions) || !versions.length) return '';
|
||||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
const version = versions[versions.length - 1];
|
||||||
const version = versions[i];
|
if (typeof version?.text === 'string') return version.text;
|
||||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
if (typeof version?.message === 'string') return version.message;
|
||||||
if (value) return value;
|
if (typeof version?.body === 'string') return version.body;
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -333,15 +332,104 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
const rows = Array.isArray(versions) ? versions : [];
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="thread-history-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">${title}</h3>
|
||||||
|
<div class="stack" id="thread-history-list"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="thread-history-close" type="button">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<strong>Версия ${index + 1}</strong>
|
||||||
|
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
|
||||||
|
<p class="channel-message-body">${text}</p>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="modal" id="thread-edit-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">Редактировать сообщение</h3>
|
||||||
|
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
|
||||||
|
<div class="meta-muted inline-error" id="thread-edit-error"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
|
||||||
|
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
|
||||||
|
</div>
|
||||||
|
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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) {
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack thread-node-card channel-message-card';
|
card.className = 'card stack thread-node-card channel-message-card';
|
||||||
card.classList.add('is-counters-visible');
|
card.classList.add('is-counters-visible');
|
||||||
|
|
||||||
const author = node?.authorLogin || 'автор';
|
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 likes = Number(node?.likesCount || 0);
|
||||||
const replies = Number(node?.repliesCount || 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();
|
const headingText = String(heading || '').trim();
|
||||||
if (headingText) {
|
if (headingText) {
|
||||||
@ -369,16 +457,33 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const numberEl = document.createElement('span');
|
const numberEl = document.createElement('span');
|
||||||
numberEl.className = 'author-line-num';
|
numberEl.className = 'author-line-num';
|
||||||
numberEl.textContent = `· #${localNumber}`;
|
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');
|
const timestamp = document.createElement('div');
|
||||||
timestamp.className = 'channel-message-time';
|
timestamp.className = 'channel-message-time';
|
||||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
||||||
title.append(loginEl, numberEl);
|
|
||||||
authorBlock.append(title, timestamp);
|
authorBlock.append(title, timestamp);
|
||||||
authorTile.append(avatar, authorBlock);
|
authorTile.append(avatar, authorBlock);
|
||||||
|
|
||||||
|
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
|
||||||
const body = document.createElement('p');
|
const body = document.createElement('p');
|
||||||
body.className = 'channel-message-body';
|
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
||||||
body.textContent = text;
|
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
|
||||||
|
|
||||||
card.append(authorTile, body);
|
card.append(authorTile, body);
|
||||||
|
|
||||||
@ -460,20 +565,27 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
await handlers.onShare(target);
|
await handlers.onShare(target);
|
||||||
});
|
});
|
||||||
|
|
||||||
const openThreadButton = document.createElement('button');
|
actions.append(likeButton, replyButton, shareButton);
|
||||||
openThreadButton.type = 'button';
|
if (isOwnMessage) {
|
||||||
openThreadButton.className = 'channel-action-item thread-open-btn';
|
const editButton = document.createElement('button');
|
||||||
openThreadButton.innerHTML = `
|
editButton.type = 'button';
|
||||||
<span class="channel-action-icon" aria-hidden="true">#</span>
|
editButton.className = 'channel-action-item';
|
||||||
<span class="channel-action-label">Тред</span>
|
editButton.setAttribute('aria-label', 'Редактировать');
|
||||||
|
editButton.title = 'Редактировать';
|
||||||
|
editButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
||||||
`;
|
`;
|
||||||
openThreadButton.addEventListener('click', (event) => {
|
editButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
handlers.onOpenThread(target);
|
openEditMessageModal({
|
||||||
|
initialText: String(text || '').trim() === 'удалено' ? '' : text,
|
||||||
|
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
|
||||||
|
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
actions.append(likeButton, replyButton, openThreadButton, shareButton);
|
actions.append(editButton);
|
||||||
|
}
|
||||||
card.append(actions);
|
card.append(actions);
|
||||||
authorTile.addEventListener('click', (event) => {
|
authorTile.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -670,6 +782,21 @@ export function render({ navigate, route }) {
|
|||||||
: 'Не удалось поставить лайк.';
|
: 'Не удалось поставить лайк.';
|
||||||
showStatus(toUserMessage(error, fallback));
|
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);
|
screen.append(header, statusBox);
|
||||||
|
|||||||
@ -165,12 +165,11 @@ function firstNonEmptyText(...candidates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function latestVersionText(versions) {
|
function latestVersionText(versions) {
|
||||||
if (!Array.isArray(versions)) return '';
|
if (!Array.isArray(versions) || !versions.length) return '';
|
||||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
const version = versions[versions.length - 1];
|
||||||
const version = versions[i];
|
if (typeof version?.text === 'string') return version.text;
|
||||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
if (typeof version?.message === 'string') return version.message;
|
||||||
if (value) return value;
|
if (typeof version?.body === 'string') return version.body;
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,6 +376,92 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
const rows = Array.isArray(versions) ? versions : [];
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="message-history-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">${title}</h3>
|
||||||
|
<div class="stack" id="message-history-list"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="message-history-close" type="button">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<strong>Версия ${index + 1}</strong>
|
||||||
|
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '—'}</div>
|
||||||
|
<p class="channel-message-body">${text}</p>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="modal" id="edit-message-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">Редактировать сообщение</h3>
|
||||||
|
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
|
||||||
|
<div class="meta-muted inline-error" id="edit-message-error"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
|
||||||
|
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
|
||||||
|
</div>
|
||||||
|
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) {
|
function mapApiMessageToPost(message, selector, localNumber) {
|
||||||
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
||||||
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
||||||
@ -399,12 +484,15 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
return {
|
return {
|
||||||
localNumber,
|
localNumber,
|
||||||
authorLogin: message?.authorLogin || 'автор',
|
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),
|
likesCount: Number(message?.likesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
reactionState: messageRef ? getMessageReactionState(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,
|
onToggleLike,
|
||||||
onReply,
|
onReply,
|
||||||
onShare,
|
onShare,
|
||||||
|
onEdit,
|
||||||
}) {
|
}) {
|
||||||
|
const versionsTotal = Number(post?.versionsTotal || 1);
|
||||||
|
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack channel-message-card';
|
card.className = 'card stack channel-message-card';
|
||||||
|
|
||||||
@ -655,6 +746,22 @@ function renderPostCard(post, {
|
|||||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||||
|
|
||||||
title.append(loginEl, numberEl);
|
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);
|
authorBlock.append(title, timestamp);
|
||||||
authorTile.append(avatar, authorBlock);
|
authorTile.append(avatar, authorBlock);
|
||||||
authorTile.addEventListener('click', (event) => {
|
authorTile.addEventListener('click', (event) => {
|
||||||
@ -664,9 +771,10 @@ function renderPostCard(post, {
|
|||||||
navigate(`user/${encodeRoutePart(cleanLogin)}`);
|
navigate(`user/${encodeRoutePart(cleanLogin)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||||
const body = document.createElement('p');
|
const body = document.createElement('p');
|
||||||
body.className = 'channel-message-body';
|
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
||||||
body.textContent = post.body;
|
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
|
||||||
|
|
||||||
card.append(authorTile, body);
|
card.append(authorTile, body);
|
||||||
|
|
||||||
@ -728,20 +836,6 @@ function renderPostCard(post, {
|
|||||||
});
|
});
|
||||||
actions.append(likeButton, replyButton);
|
actions.append(likeButton, replyButton);
|
||||||
|
|
||||||
const openThreadButton = document.createElement('button');
|
|
||||||
openThreadButton.type = 'button';
|
|
||||||
openThreadButton.className = 'channel-action-item channel-action-thread';
|
|
||||||
openThreadButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">#</span>
|
|
||||||
<span class="channel-action-label">Тред</span>
|
|
||||||
`;
|
|
||||||
openThreadButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
const route = buildThreadRoute(post.messageRef, selector);
|
|
||||||
if (route) navigate(route);
|
|
||||||
});
|
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'channel-action-item channel-action-share';
|
shareButton.className = 'channel-action-item channel-action-share';
|
||||||
@ -756,7 +850,27 @@ function renderPostCard(post, {
|
|||||||
await onShare(route);
|
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 = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
||||||
|
`;
|
||||||
|
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.append(actions);
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
const route = buildThreadRoute(post.messageRef, selector);
|
const route = buildThreadRoute(post.messageRef, selector);
|
||||||
@ -791,6 +905,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
onToggleLike: handlers.onToggleLike,
|
onToggleLike: handlers.onToggleLike,
|
||||||
onReply: handlers.onReply,
|
onReply: handlers.onReply,
|
||||||
onShare: handlers.onShare,
|
onShare: handlers.onShare,
|
||||||
|
onEdit: handlers.onEdit,
|
||||||
});
|
});
|
||||||
const key = messageRefKey(post.messageRef);
|
const key = messageRefKey(post.messageRef);
|
||||||
if (key) {
|
if (key) {
|
||||||
@ -976,6 +1091,24 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
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(header);
|
||||||
screen.append(statusBox);
|
screen.append(statusBox);
|
||||||
|
|
||||||
@ -1023,6 +1156,14 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShare: onShare,
|
onShare: onShare,
|
||||||
|
onEdit: async (messageRef, text) => {
|
||||||
|
try {
|
||||||
|
await onEditPost(messageRef, text);
|
||||||
|
showStatus('');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
onSubscribeChannel: async (event) => {
|
onSubscribeChannel: async (event) => {
|
||||||
animatePress(event?.currentTarget);
|
animatePress(event?.currentTarget);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
|||||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||||
const CHANNEL_TYPE_STORIES = 0;
|
const CHANNEL_TYPE_STORIES = 0;
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
const TAB_ORDER = ['dialogs', 'feed', 'my'];
|
const TAB_ORDER = ['feed', 'my'];
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -583,10 +583,8 @@ function mapMockGroups() {
|
|||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...channel,
|
...channel,
|
||||||
route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`,
|
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'
|
? 'my'
|
||||||
: channel.kind === 'own-personal'
|
|
||||||
? 'dialogs'
|
|
||||||
: 'feed',
|
: 'feed',
|
||||||
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
||||||
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
isSubscribed: 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 channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||||
const isOwn = bucketKey === 'own';
|
const isOwn = bucketKey === 'own';
|
||||||
const tabCategory = isOwn
|
const tabCategory = isOwn ? 'my' : 'feed';
|
||||||
? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
|
|
||||||
: 'feed';
|
|
||||||
|
|
||||||
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
|
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
|
||||||
|
|
||||||
@ -691,8 +687,6 @@ function renderEmptyState(activeTab, navigate) {
|
|||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
if (activeTab === 'feed') {
|
if (activeTab === 'feed') {
|
||||||
text.textContent = 'Нет подписок и найденных каналов.';
|
text.textContent = 'Нет подписок и найденных каналов.';
|
||||||
} else if (activeTab === 'dialogs') {
|
|
||||||
text.textContent = 'Чаты пока не работают.';
|
|
||||||
} else if (activeTab === 'my') {
|
} else if (activeTab === 'my') {
|
||||||
text.textContent = 'У вас пока нет каналов.';
|
text.textContent = 'У вас пока нет каналов.';
|
||||||
} else {
|
} else {
|
||||||
@ -953,7 +947,7 @@ function renderChannelMain(channel, activeTab) {
|
|||||||
title.className = 'channel-row-title';
|
title.className = 'channel-row-title';
|
||||||
title.textContent = activeTab === 'my' ? channel.channelName : channel.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');
|
const desc = document.createElement('p');
|
||||||
desc.className = 'channel-row-description';
|
desc.className = 'channel-row-description';
|
||||||
desc.textContent = channel.channelDescription;
|
desc.textContent = channel.channelDescription;
|
||||||
@ -1062,13 +1056,6 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab === 'dialogs') {
|
|
||||||
button.textContent = 'Новый персональный публичный чат';
|
|
||||||
button.className = baseClass;
|
|
||||||
button.onclick = () => navigate('add-personal-public-chat-view');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === 'my') {
|
if (tab === 'my') {
|
||||||
button.textContent = 'Создать канал';
|
button.textContent = 'Создать канал';
|
||||||
button.className = baseClass;
|
button.className = baseClass;
|
||||||
@ -1140,18 +1127,13 @@ export function render({ navigate, route }) {
|
|||||||
tabsEl.className = 'channels-tabs';
|
tabsEl.className = 'channels-tabs';
|
||||||
const tabLabels = {
|
const tabLabels = {
|
||||||
feed: 'Каналы',
|
feed: 'Каналы',
|
||||||
dialogs: 'Чаты',
|
my: 'Мои каналы',
|
||||||
my: 'Мои',
|
|
||||||
};
|
};
|
||||||
TAB_ORDER.forEach((tabKey) => {
|
TAB_ORDER.forEach((tabKey) => {
|
||||||
const tabBtn = document.createElement('button');
|
const tabBtn = document.createElement('button');
|
||||||
tabBtn.type = 'button';
|
tabBtn.type = 'button';
|
||||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
||||||
tabBtn.textContent = tabLabels[tabKey] || tabKey;
|
tabBtn.textContent = tabLabels[tabKey] || tabKey;
|
||||||
if (tabKey === 'dialogs') {
|
|
||||||
tabBtn.classList.add('is-disabled');
|
|
||||||
tabBtn.title = 'Чаты пока не работают';
|
|
||||||
}
|
|
||||||
tabBtn.addEventListener('click', () => {
|
tabBtn.addEventListener('click', () => {
|
||||||
if (listState.activeTab === tabKey) return;
|
if (listState.activeTab === tabKey) return;
|
||||||
listState.activeTab = tabKey;
|
listState.activeTab = tabKey;
|
||||||
|
|||||||
@ -172,8 +172,11 @@ function scrollToLatestMessage(list) {
|
|||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
window.requestAnimationFrame(apply);
|
window.requestAnimationFrame(apply);
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
|
||||||
window.setTimeout(apply, 0);
|
window.setTimeout(apply, 0);
|
||||||
|
window.setTimeout(apply, 60);
|
||||||
window.setTimeout(apply, 120);
|
window.setTimeout(apply, 120);
|
||||||
|
window.setTimeout(apply, 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLog(list, chatId, { onOpenActions } = {}) {
|
function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||||
@ -416,6 +419,9 @@ export function render({ navigate, route }) {
|
|||||||
const input = form.elements.message;
|
const input = form.elements.message;
|
||||||
autoResizeComposer(input);
|
autoResizeComposer(input);
|
||||||
input?.addEventListener('input', () => autoResizeComposer(input));
|
input?.addEventListener('input', () => autoResizeComposer(input));
|
||||||
|
input?.addEventListener('focus', () => {
|
||||||
|
scrollToLatestMessage(log);
|
||||||
|
});
|
||||||
input?.addEventListener('keydown', async (event) => {
|
input?.addEventListener('keydown', async (event) => {
|
||||||
if (event.key !== 'Enter') return;
|
if (event.key !== 'Enter') return;
|
||||||
if (event.ctrlKey) {
|
if (event.ctrlKey) {
|
||||||
@ -480,6 +486,7 @@ export function render({ navigate, route }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
|
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||||||
void sendReadReceiptsForVisible(chatId);
|
void sendReadReceiptsForVisible(chatId);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,9 @@ const MSG_TYPE_CONNECTION = 3;
|
|||||||
|
|
||||||
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
||||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||||
|
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
||||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||||
|
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
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({
|
function makeConnectionBodyBytes({
|
||||||
lineCode = 0,
|
lineCode = 0,
|
||||||
prevLineNumber = -1,
|
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 }) {
|
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
||||||
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
||||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||||
|
|||||||
@ -1204,6 +1204,10 @@ textarea.input {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-danger-action {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.small-btn {
|
.small-btn {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -2238,6 +2242,14 @@ textarea.input {
|
|||||||
border: 0;
|
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 {
|
.channel-message-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.48);
|
color: rgba(255, 255, 255, 0.48);
|
||||||
@ -2319,6 +2331,25 @@ textarea.input {
|
|||||||
letter-spacing: 0.01em;
|
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 {
|
.channel-action-counter {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
|||||||
@ -105,7 +105,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text");
|
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
||||||
|
|
||||||
ensureNoTail(bb, "TextLineBody");
|
ensureNoTail(bb, "TextLineBody");
|
||||||
}
|
}
|
||||||
@ -129,7 +129,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
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.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
@ -165,15 +167,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
||||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
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 (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
|
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||||
} else {
|
} else {
|
||||||
|
if (message == null || message.isBlank())
|
||||||
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
if (toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST must not contain target fields");
|
throw new IllegalArgumentException("POST must not contain target fields");
|
||||||
}
|
}
|
||||||
@ -184,10 +186,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
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)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
|
||||||
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
|
}
|
||||||
|
|
||||||
int cap;
|
int cap;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
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);
|
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());
|
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 + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
@ -248,7 +255,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
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;
|
return s;
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
|||||||
@ -96,7 +96,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
bb.get(this.toBlockHash32);
|
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");
|
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)) {
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
|
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 (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
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))
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||||
|
|
||||||
if (message == null || message.isBlank())
|
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
|
if (message == null || message.isBlank())
|
||||||
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
||||||
} else {
|
} else {
|
||||||
|
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||||
if (toBlockchainName != null)
|
if (toBlockchainName != null)
|
||||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
||||||
}
|
}
|
||||||
@ -164,10 +166,12 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
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)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
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 (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||||
@ -213,9 +217,12 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
/* ====================== helpers ====================== */
|
/* ====================== 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());
|
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 + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
@ -227,7 +234,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
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;
|
return s;
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
|||||||
@ -410,10 +410,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
// target columns (optional)
|
// target columns (optional)
|
||||||
if (block.body instanceof BodyHasTarget t) {
|
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.setToLogin(t.toLogin());
|
||||||
be.setToBchName(t.toBchName());
|
be.setToBchName(targetBchName);
|
||||||
be.setToBlockNumber(t.toBlockGlobalNumber());
|
be.setToBlockNumber(t.toBlockGlobalNumber());
|
||||||
be.setToBlockHash(t.toBlockHashBytes());
|
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_* — это "редактирование блока цели"
|
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
||||||
|
|||||||
@ -218,16 +218,17 @@ final class ChannelsReadSupport {
|
|||||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
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
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||||
ps.setString(4, ownerBch);
|
ps.setInt(4, originalBlock);
|
||||||
ps.setInt(5, originalBlock);
|
ps.setBytes(5, originalHash);
|
||||||
ps.setBytes(6, originalHash);
|
ps.setString(6, ownerBch);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
@ -111,7 +111,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
v1.setCreatedAtMs(postText.createdAtMs);
|
v1.setCreatedAtMs(postText.createdAtMs);
|
||||||
versionsOut.add(v1);
|
versionsOut.add(v1);
|
||||||
|
|
||||||
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, ownerBch, post.blockNumber, post.blockHash);
|
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
||||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||||
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
|
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
|
||||||
|
|||||||
@ -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
|
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
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
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
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bch);
|
ps.setString(1, bch);
|
||||||
ps.setInt(2, subType);
|
ps.setInt(2, subType);
|
||||||
ps.setString(3, bch);
|
ps.setInt(3, targetBlock);
|
||||||
ps.setInt(4, targetBlock);
|
ps.setBytes(4, targetHash);
|
||||||
ps.setBytes(5, targetHash);
|
ps.setString(5, bch);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostRow> out = new ArrayList<>();
|
List<PostRow> out = new ArrayList<>();
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user