Compare commits
2 Commits
21413268f3
...
fd99250882
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
fd99250882 | ||
|
|
5344c42ceb |
@ -96,6 +96,7 @@
|
||||
- `TEXT_EDIT_POST (11)`
|
||||
- `TEXT_REPLY (20)`
|
||||
- `TEXT_EDIT_REPLY (21)`
|
||||
- `TEXT_REPOST (30)`
|
||||
|
||||
3. **REACTION (type=2)**
|
||||
- `REACTION_LIKE (1)`
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
2. Персональный публичный чат хранится как канал типа `100`:
|
||||
- у `A` канал с `channelName = B`;
|
||||
- у `B` зеркальный канал с `channelName = A`.
|
||||
3. Сообщения канала — `TEXT_POST` в линии `line_code = rootBlockNumber` канала.
|
||||
3. Сообщения канала — `TEXT_POST` и `TEXT_REPOST` в линии `line_code = rootBlockNumber` канала.
|
||||
4. Запись блока возможна только при валидной подписи blockchain-ключом владельца цепочки.
|
||||
|
||||
## 2. Что должен уметь MCP-инструмент
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
## Быстрая карта типов
|
||||
|
||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY.
|
||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST.
|
||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
||||
|
||||
@ -21,6 +21,11 @@ TEXT-тип хранит сообщения и редактирования.
|
||||
- target на исходный REPLY + новый текст.
|
||||
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
|
||||
|
||||
5. `subType=30` — `TEXT_REPOST`
|
||||
- репост сообщения в линию канала;
|
||||
- содержит line-поля + target на оригинальное сообщение + текст комментария;
|
||||
- на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются).
|
||||
|
||||
## Правило для edit
|
||||
|
||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-05-21 19:05:00 +0300
|
||||
- Базовый коммит-ориентир: `5344c42`.
|
||||
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
|
||||
- обновлён перечень типов в `11_TEXT_Blocks.md`;
|
||||
- обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`.
|
||||
- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
|
||||
- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`.
|
||||
|
||||
## 2026-05-20 11:34:17 +0300
|
||||
- Базовый коммит-ориентир: `a53444b`.
|
||||
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# Репосты в каналах и тредах
|
||||
|
||||
- Краткое описание:
|
||||
Добавлен подтип `TEXT_REPOST (30)` и UI-режим репоста с комментарием. Репост можно делать как из сообщения канала, так и из сообщения в треде. Для репоста выбирается один из своих каналов.
|
||||
|
||||
- Что проверять:
|
||||
1. В канале открыть любое сообщение и нажать `Репост`.
|
||||
2. Выбрать свой канал, ввести комментарий, отправить.
|
||||
3. Убедиться, что в целевом канале появился новый пост-репост.
|
||||
4. Нажать `Оригинал` у репоста и подтвердить переход.
|
||||
5. Проверить, что переход открывает исходное сообщение.
|
||||
6. Повторить сценарий из треда (для сообщения-ответа).
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Репост успешно записывается в блокчейн как `TEXT_REPOST`.
|
||||
- В выдаче `GetChannelMessages`/`GetMessageThread` возвращаются поля target (`targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`) для репоста.
|
||||
- Кнопка `Оригинал` открывает нужное исходное сообщение.
|
||||
- Для репоста не отображается история редактирования (одна версия).
|
||||
|
||||
- Статус:
|
||||
`pending`
|
||||
@ -1,25 +0,0 @@
|
||||
# Thread: стабильная нижняя панель действий со счётчиками
|
||||
|
||||
- краткое описание фичи:
|
||||
- На карточках сообщений во вкладке thread нижняя панель действий теперь всегда стабильная и содержит:
|
||||
- сердечко + количество лайков;
|
||||
- иконка ответа + количество ответов;
|
||||
- иконка изменений + количество изменений (только если изменений больше 0);
|
||||
- справа кнопку отправки (`↗ Отправить`).
|
||||
- Логика изменений: `изменения = versionsTotal - 1`.
|
||||
- Если `versionsTotal = 1`, поле изменений не показывается.
|
||||
- Убрано поведение с появлением дополнительной верхней надписи/статистики после первого взаимодействия.
|
||||
|
||||
- что именно проверять:
|
||||
- Открыть любой thread и убедиться, что у каждой карточки внизу всегда видны кнопки/счётчики.
|
||||
- Проверить, что лайк и ответ отображают корректные числа сразу, без дополнительного клика.
|
||||
- Для сообщения с `versionsTotal = 1` убедиться, что поле изменений отсутствует.
|
||||
- Для сообщения с `versionsTotal > 1` убедиться, что показывается `✏️ N`, где `N = versionsTotal - 1`.
|
||||
- Проверить, что справа всегда есть `↗ Отправить`.
|
||||
|
||||
- ожидаемый результат:
|
||||
- Нижняя панель действий во thread ведёт себя одинаково и не меняет структуру после кликов/ответов.
|
||||
- Счётчики соответствуют данным API.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -1,34 +0,0 @@
|
||||
# Голосовой ввод и озвучка (STT/TTS)
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Добавлен голосовой ввод в:
|
||||
- личный чат;
|
||||
- форму ответа в канале;
|
||||
- форму нового сообщения в канале;
|
||||
- форму ответа в треде.
|
||||
- Добавлен инструмент «Прочесть вслух» для текста в чате.
|
||||
- Добавлен экран `Настройки инструментов ввода`:
|
||||
- STT через OpenAI (base URL, API key, качество, модель);
|
||||
- TTS через Browser / Piper HTTP / OpenAI TTS.
|
||||
- Для TTS добавлена кнопка «Проверить озвучку».
|
||||
- Если инструмент не настроен, показывается предложение перейти в настройки.
|
||||
- Настройки сохраняются локально и сохраняются между сессиями.
|
||||
|
||||
## Как проверять
|
||||
|
||||
1. Открыть `Настройки -> Настройки инструментов ввода`.
|
||||
2. В блоке STT заполнить OpenAI API key (и при необходимости URL/модель).
|
||||
3. В блоке TTS выбрать OpenAI, заполнить API key, при необходимости модель/голос.
|
||||
4. Нажать «Проверить озвучку» и убедиться, что звук воспроизводится.
|
||||
5. Открыть чат/канал/тред, нажать кнопку `🎤`, записать голос, нажать `OK`.
|
||||
6. Убедиться, что распознанный текст подставился в поле ввода.
|
||||
7. Отправить сообщение и проверить, что оно дошло.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Голосовой ввод работает во всех указанных формах.
|
||||
- Озвучка через OpenAI TTS работает с тем же ключом, что и STT (если ключ имеет нужные права).
|
||||
- При пустых настройках показывается понятный переход в настройки.
|
||||
@ -1,36 +0,0 @@
|
||||
# Argon2id для входа/регистрации + блок «Расширенные»
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- При непустом пароле derivation ключей переведён на Argon2id.
|
||||
- В derivation участвуют и логин, и пароль.
|
||||
- Для пустого пароля оставлен прежний тестовый режим (старый детерминированный вариант), чтобы сохранить текущий тестовый сценарий.
|
||||
- На экранах входа и регистрации добавлен блок `Расширенные` с кратким описанием схемы и параметров.
|
||||
|
||||
## Параметры Argon2id (текущий профиль)
|
||||
|
||||
- `t = 3`
|
||||
- `m = 262144 KiB` (256 MB)
|
||||
- `p = 1`
|
||||
- `dkLen = 32`
|
||||
|
||||
Формат salt:
|
||||
|
||||
- `saltSource = "shine-auth-v2|login=<lowercaseLogin>|suffix=<keySuffix>"`
|
||||
- `salt = first16bytes( SHA-256(saltSource) )`
|
||||
- `keySuffix` = `root.key` / `bch.key` / `dev.key`
|
||||
|
||||
## Как проверять
|
||||
|
||||
1. На входе/регистрации открыть `Расширенные` и проверить отображение описания.
|
||||
2. Проверить тестовый режим: оставить пароль пустым и убедиться, что вход работает по старому сценарию.
|
||||
3. Проверить новый режим: ввести непустой пароль и выполнить вход/регистрацию.
|
||||
4. Проверить, что одинаковый пароль при разных логинах даёт разные ключи (например, вход под двумя логинами с тем же паролем и проверка несовпадения производных ключей/сессий).
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Непустой пароль использует Argon2id.
|
||||
- Пустой пароль остаётся тестовым legacy-вариантом.
|
||||
- UI показывает пользователю, как сейчас считается секрет.
|
||||
@ -1,26 +0,0 @@
|
||||
# Каналы: явные вкладки + fallback CreateChannel для legacy-сервера
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- На экране каналов добавлены явные вкладки:
|
||||
- `Каналы`
|
||||
- `Чаты`
|
||||
- `Мои`
|
||||
- Переключение теперь работает по обычному тапу, без необходимости long-press на кнопке toolbar.
|
||||
- В `addBlockCreateChannel` добавлен fallback:
|
||||
- сначала отправляется текущий формат CreateChannel (с description/type/version),
|
||||
- если сервер возвращает `bad_block_format`, выполняется повтор с legacy-форматом тела (без description/type/version) для совместимости со старым сервером.
|
||||
|
||||
## Как проверять
|
||||
|
||||
1. Открыть экран каналов и проверить переключение всех трёх вкладок по тапу.
|
||||
2. Нажимать на строки каналов и убедиться, что переход в канал работает.
|
||||
3. Создать новый канал и убедиться, что при старом сервере создание не падает с `Некорректный формат блока`.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Вкладки `Каналы/Чаты/Мои` переключаются стабильно.
|
||||
- Каналы открываются по тапу.
|
||||
- Создание канала устойчиво к legacy-формату сервера.
|
||||
@ -1,22 +0,0 @@
|
||||
# Fix: CreateChannel версия формата (v3)
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Что исправлено
|
||||
|
||||
- Найдена причина `bad_block_format` при создании канала:
|
||||
UI отправлял тело CreateChannel нового формата (с description/type/version), но с `msgVersion=1`.
|
||||
- Исправлено на корректный `msgVersion=3` для нового формата.
|
||||
- Оставлен fallback для старого сервера:
|
||||
- при `bad_block_format` повтор с legacy-телом и `msgVersion=1`.
|
||||
|
||||
## Как проверять
|
||||
|
||||
1. Открыть `Каналы` и создать новый канал.
|
||||
2. Убедиться, что создание проходит без ошибки `Некорректный формат блока`.
|
||||
3. Проверить, что канал появляется в списке и открывается.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Основной путь работает в едином корректном формате `CreateChannel v3`.
|
||||
- На старом сервере сохраняется совместимость через fallback.
|
||||
@ -1,24 +0,0 @@
|
||||
# Каналы: новые табы + поиск/просмотр + подписка в канале
|
||||
|
||||
- краткое описание фичи:
|
||||
- На вкладке «Каналы» верхние табы переставлены в порядок: `Чаты`, `Каналы`, `Мои`.
|
||||
- По умолчанию открывается вкладка `Каналы` (центральная).
|
||||
- Нижняя кнопка на вкладке `Каналы` переименована: `Найти канал` (вместо `Подписаться на канал`).
|
||||
- В модальном поиске канала оставлен сценарий выбора по `user/channel` (и по имени канала через существующие подсказки), без использования формата `blockchain:number:hash`.
|
||||
- В результатах поиска канала добавлена явная кнопка `Просмотреть` для перехода в канал.
|
||||
- На экране канала кнопка `Подписаться на канал` показывается только если пользователь ещё не подписан.
|
||||
- После подтверждённой подписки кнопка исчезает (повторный ререндер с обновлённым feed).
|
||||
|
||||
- что именно проверять:
|
||||
- Открыть `Каналы`: убедиться, что порядок табов `Чаты | Каналы | Мои`, активна по умолчанию `Каналы`.
|
||||
- На `Каналы` проверить нижнюю кнопку `Найти канал`.
|
||||
- В `Найти канал` выбрать канал и нажать `Просмотреть`: должен открыться экран канала.
|
||||
- На экране чужого канала (без подписки) нажать `Подписаться на канал`, подтвердить `Ок`.
|
||||
- Убедиться, что после успешной подписки кнопка `Подписаться на канал` исчезает.
|
||||
|
||||
- ожидаемый результат:
|
||||
- Пользователь находит и открывает канал через `Найти канал` → `Просмотреть`.
|
||||
- Подписка выполняется на экране канала и не предлагается повторно сразу после успеха.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -1,31 +0,0 @@
|
||||
# UI-ошибки в сервер + новый сценарий персонального публичного чата
|
||||
|
||||
- краткое описание фичи:
|
||||
- Добавлена настройка разработчика «Отправлять ошибки на сервер» (по умолчанию выключена), с локальным сохранением.
|
||||
- При включенной настройке UI-ошибки отправляются в `CallDeliveryReport` с `type=ui_error` и отдельным кодом `UI_RUNTIME_ERROR`.
|
||||
- После успешной отправки показывается toast: «Ошибка отправлена на сервер · <login> · <время>».
|
||||
- Для вкладки `Чаты` кнопка переименована в «Новый персональный публичный чат».
|
||||
- Добавлен отдельный экран создания персонального публичного чата:
|
||||
- фиксированный `channelType=100`;
|
||||
- ввод логина второго пользователя;
|
||||
- поиск/подсказки пользователей;
|
||||
- создание канала с каноническим логином из сервера;
|
||||
- опциональное описание;
|
||||
- предупреждение про публичность и хранение в блокчейне.
|
||||
- Обновлены правила документации: имена pending-файлов и описания новых фич рекомендованы на русском.
|
||||
|
||||
- что именно проверять:
|
||||
- В `Настройки разработчика` открыть «Отправлять ошибки на сервер», включить и сохранить.
|
||||
- Сгенерировать UI-ошибку и проверить:
|
||||
- появляется toast об отправке;
|
||||
- запись появляется в `logs/call-delivery-events.log` с `type=ui_error`.
|
||||
- На вкладке `Каналы -> Чаты` проверить новую кнопку «Новый персональный публичный чат».
|
||||
- Проверить форму создания: подсказки логинов, создание с правильным регистром логина, описание и инфоблок.
|
||||
|
||||
- ожидаемый результат:
|
||||
- UI-ошибки начинают отправляться только при включенной настройке.
|
||||
- В логах сервера UI-ошибки отделяются по типу `ui_error`.
|
||||
- Персональный публичный чат создается через отдельный, более понятный пользовательский сценарий.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -1,32 +0,0 @@
|
||||
# Мультиаккаунты + улучшенный поиск каналов/чатов
|
||||
|
||||
- краткое описание фичи:
|
||||
- Добавлен long-press на кнопку `Профиль` в нижнем тулбаре.
|
||||
- По удержанию открывается меню с кнопкой `Сменить профиль`.
|
||||
- Добавлен экран `Сменить профиль`:
|
||||
- список уже добавленных аккаунтов;
|
||||
- пометка активного аккаунта;
|
||||
- переключение на другой аккаунт;
|
||||
- кнопки `Добавить аккаунт (Войти)` и `Добавить аккаунт (Регистрация)`.
|
||||
- Сессии нескольких аккаунтов сохраняются локально; при `authorizeSession` аккаунт добавляется/обновляется в списке.
|
||||
- Выход из текущей сессии теперь удаляет только текущий аккаунт из списка аккаунтов.
|
||||
- В `Новый персональный публичный чат` разрешён логин длиной 1 символ (тип канала `100`).
|
||||
- В `Найти канал` улучшен UX:
|
||||
- кнопка `Найти`;
|
||||
- поиск пользователей по началу логина;
|
||||
- понятные сообщения при отсутствии совпадений/каналов.
|
||||
|
||||
- что именно проверять:
|
||||
- Удержать кнопку `Профиль` и открыть `Сменить профиль`.
|
||||
- Проверить отображение активного аккаунта и переключение на другой.
|
||||
- Проверить сценарий `Добавить аккаунт` (войти/зарегистрироваться) без вылета из текущего аккаунта.
|
||||
- Проверить создание персонального публичного чата с логином из 1 символа.
|
||||
- Проверить поиск каналов по префиксу логина и работу кнопки `Найти`.
|
||||
|
||||
- ожидаемый результат:
|
||||
- Переключение между несколькими аккаунтами работает из UI.
|
||||
- Поиск каналов стал управляемым и понятным.
|
||||
- Ограничение 3+ символов для персонального чата снято.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -1,38 +0,0 @@
|
||||
# Персональный публичный чат: исправление формата блока и обратный канал
|
||||
|
||||
- краткое описание фичи:
|
||||
- Исправлен формат отправки `CreateChannel` из UI: для создания канала теперь используется версия body `1`, совместимая с серверным парсером.
|
||||
- Убрана ошибка `AddBlock: Некорректный формат блока (BAD_BLOCK_FORMAT)` при создании персонального публичного чата (тип `100`) на актуальном сервере.
|
||||
- В `channel-view` для персонального чата добавлена клиентская склейка диалога:
|
||||
- основной канал `A -> B` (владелец `A`, имя канала `B`, тип `100`);
|
||||
- зеркальный канал `B -> A` (владелец `B`, имя канала `A`, тип `100`);
|
||||
- сообщения обоих каналов показываются в одном диалоге, отсортированном по времени.
|
||||
- Если зеркальный канал не найден, показывается уведомление в шапке канала о том, что у собеседника пока не создан ответный чат.
|
||||
- Исправлена ошибка `Идентификатор канала не готов` при добавлении сообщения в ряде сценариев (например, «мои сторис»): отправка теперь использует фактически загруженный селектор канала, а не только параметры маршрута.
|
||||
- Улучшен резолв канала при открытии из поиска/прямой ссылки:
|
||||
- сначала попытка по `ownerBlockchainName + channelName`;
|
||||
- fallback по `ownerLogin + channelName`;
|
||||
- дополнительный fallback через `GetUser(owner)` с сопоставлением `blockchainName`.
|
||||
Это снижает число ложных `Канал не найден` при открытии сторис/каналов других пользователей.
|
||||
- В форме «Создать канал» (вкладка «Мои») удалён выбор типа канала: создаётся только публичный канал `type=1` с полями «название + описание».
|
||||
- Минимальная длина названия канала изменена с `3` на `1` (новый диапазон: `1..32`).
|
||||
- Перед записью сообщения в канал UI теперь получает актуальное состояние линии канала (последний блок в линии) и строит `TEXT_POST` от свежего `prevLine`, что убирает постоянные конфликты состояния (`bad_prev_line_hash` / `line_err_prev_hash_mismatch`) при добавлении в свои сторис/каналы.
|
||||
|
||||
- что именно проверять:
|
||||
- Создать персональный публичный чат через UI (`Каналы -> Чаты -> Новый персональный публичный чат`) и убедиться, что ошибка `BAD_BLOCK_FORMAT` больше не появляется.
|
||||
- Открыть созданный персональный чат `A -> B`, написать сообщение.
|
||||
- С аккаунта `B` создать зеркальный чат `B -> A`, отправить ответ.
|
||||
- Снова открыть чат у `A` и проверить, что в одном экране видны и исходящие, и входящие сообщения из зеркального канала.
|
||||
- Проверить, что при отсутствии зеркального канала в шапке отображается предупреждение.
|
||||
- Вкладка «Мои сторис»: открыть канал и отправить сообщение кнопкой «Добавить сообщение» — ошибка про неготовый идентификатор не должна появляться.
|
||||
- Вкладка «Найти канал»: открыть чужой сторис/канал по формату `user/channel` и убедиться, что канал открывается (если реально существует и доступен в выдаче).
|
||||
|
||||
- ожидаемый результат:
|
||||
- Персональный публичный чат создаётся без ошибки формата блока.
|
||||
- При наличии зеркального канала переписка отображается единым диалогом.
|
||||
- При отсутствии зеркального канала пользователь видит явное уведомление.
|
||||
- В «мои сторис» сообщение добавляется без ошибки `Идентификатор канала не готов`.
|
||||
- Открытие чужих каналов из поиска/ссылки работает стабильнее без ложного `Канал не найден`.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -1,36 +0,0 @@
|
||||
## Краткое описание
|
||||
|
||||
На экране `Кошелёк -> Solana кошелёк` добавлен блок создания нового Solana-кошелька:
|
||||
- генерация случайного кошелька;
|
||||
- генерация публичного ключа из введённого приватного ключа Base58 (32 байта).
|
||||
|
||||
Добавлены:
|
||||
- валидация формата Base58;
|
||||
- проверка точной длины приватного ключа (ровно 32 байта после декодирования);
|
||||
- запрет ввода слишком длинного значения (`maxlength=44`);
|
||||
- статус `Подходит` для валидного ввода;
|
||||
- нередактируемое поле публичного ключа с возможностью копирования.
|
||||
|
||||
## Что проверять
|
||||
|
||||
1. Открыть `Кошелёк -> Solana кошелёк`.
|
||||
2. В блоке создания кошелька нажать `Сгенерировать случайный кошелёк`.
|
||||
3. Проверить, что появились:
|
||||
- приватный ключ Base58;
|
||||
- публичный ключ Base58 (в нередактируемом поле).
|
||||
4. Нажать `Копировать приватный` и `Копировать публичный` — убедиться, что значения копируются.
|
||||
5. Ввести невалидный приватный ключ (символы не из Base58) — увидеть ошибку формата.
|
||||
6. Ввести слишком короткий ключ — увидеть сообщение, что значение слишком короткое.
|
||||
7. Ввести валидный Base58-ключ на 32 байта — увидеть статус `Подходит`.
|
||||
8. Нажать `Сгенерировать из приватного ключа` — публичный ключ должен сгенерироваться.
|
||||
9. Проверить, что в поле ввода приватного ключа нельзя вставить/ввести более 44 символов.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Оба сценария генерации работают стабильно.
|
||||
- Для невалидного ввода показываются корректные сообщения.
|
||||
- Поле публичного ключа не редактируется, но значение можно скопировать.
|
||||
|
||||
## Статус
|
||||
|
||||
`pending`
|
||||
@ -1,19 +0,0 @@
|
||||
# Навигация по тредам и история сообщения
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Краткое описание
|
||||
В экране треда добавлен явный переход `🧵 В тред` для каждого сообщения (включая ответы), чтобы можно было углубляться в любую ветку обсуждения.
|
||||
Также уточнены заголовки блоков: сверху история сообщений, отдельно текущее сообщение.
|
||||
|
||||
## Что проверять
|
||||
1. Открыть любой канал и перейти в тред сообщения.
|
||||
2. Нажать `🧵 В тред` у одного из ответов.
|
||||
3. Убедиться, что открывается тред выбранного ответа, а не исходного сообщения.
|
||||
4. Проверить, что в новом треде сверху показывается блок истории (`История выше...`), затем блок `Текущее сообщение`, затем `Ответы`.
|
||||
5. Проверить на мобильной ширине, что кнопки действий в карточке не ломают верстку.
|
||||
|
||||
## Ожидаемый результат
|
||||
- Переход в тред ответа работает стабильно для всех узлов дерева.
|
||||
- Пользователь видит структуру треда в логичном порядке: предки → текущее сообщение → потомки.
|
||||
- UI остаётся читаемым на мобильных экранах.
|
||||
@ -1,19 +0,0 @@
|
||||
# Короткая ссылка на сообщение `#/m/{blockchainName}/{blockNumber}`
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Краткое описание
|
||||
Добавлен короткий роут сообщения `#/m/{blockchainName}/{blockNumber}` (поддерживает и вариант с hash).
|
||||
Переходы в тред из канала и из треда теперь формируются через `#/m/...`, а не через длинный путь канала.
|
||||
|
||||
## Что проверять
|
||||
1. Открыть сообщение в канале и перейти в тред — адрес должен быть формата `#/m/...`.
|
||||
2. Скопировать ссылку на тред сообщения и открыть в новой вкладке.
|
||||
3. Для ответа (reply) нажать `🧵 В тред` и убедиться, что тред открывается без ошибок `BAD_FIELDS`/`Не удалось определить hash`.
|
||||
4. Проверить шапку треда: UI должен попытаться восстановить красивый заголовок канала (`owner/channel`).
|
||||
5. Проверить, что старый маршрут `#/channel-thread-view/...` тоже продолжает работать.
|
||||
|
||||
## Ожидаемый результат
|
||||
- Короткий роут работает стабильно для постов и ответов.
|
||||
- Тред открывается даже если в URL нет hash (опциональный случай).
|
||||
- Ошибка про невозможность определить hash для открытия треда не воспроизводится.
|
||||
@ -1,21 +0,0 @@
|
||||
# Переход на history-router без `#` в URL
|
||||
|
||||
- Краткое описание:
|
||||
- UI переведён с hash-router на history API роутинг.
|
||||
- Ссылки на треды переведены в формат без hash сообщения: `/m/{blockchainName}/{blockNumber}`.
|
||||
- Навигация и шаринг-ссылки обновлены под `pathname`.
|
||||
|
||||
- Что проверять:
|
||||
- Открытие UI с корня (`/`) и переход на стартовую страницу без тёмного экрана.
|
||||
- Навигация между основными экранами (сообщения, каналы, профиль, настройки).
|
||||
- Переход в канал, открытие треда, ответ/лайк, шаринг ссылки.
|
||||
- Прямое открытие URL формата `/m/{blockchain}/{number}`.
|
||||
- Поведение после refresh (F5) при настроенном серверном fallback на `index.html`.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Приложение работает без `#` в адресе.
|
||||
- Треды открываются и действия по сообщению (reply/like/share) работают корректно.
|
||||
- Нет зависания на пустом/тёмном экране при входе.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,25 +0,0 @@
|
||||
# Карточка автора в сообщении канала и стрелка «назад» по истории
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` в карточке сообщения добавлена вложенная плитка автора (аватар, логин, номер сообщения, дата/время).
|
||||
- Клик по плитке автора открывает профиль пользователя.
|
||||
- Клик по области сообщения (вне плитки автора и вне action-кнопок) открывает тред, как кнопка `Тред`.
|
||||
- Стрелка `назад` в `channel-view`, `channel-thread-view` и профиле переведена на реальную навигацию `history.back()`.
|
||||
- Маршрут профиля переименован с `user-profile-view` на `user`.
|
||||
|
||||
- Что проверять:
|
||||
- В канале у каждого сообщения сверху есть вложенная плитка автора.
|
||||
- Клик по вложенной плитке открывает профиль автора.
|
||||
- Клик по тексту/телу сообщения открывает тред.
|
||||
- Кнопки `Лайк`, `Ответить`, `Тред`, `Отправить` работают отдельно и не конфликтуют с кликом по карточке.
|
||||
- Стрелка `назад` возвращает на предыдущий экран по реальной истории переходов.
|
||||
- При отсутствии истории стрелка `назад` не делает переход.
|
||||
- Переходы на профиль работают по новому маршруту `user/{login}/...`.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Навигация в каналах и тредах соответствует ожидаемому UX.
|
||||
- Переходы в профиль и назад по истории работают стабильно.
|
||||
- Старый маршрут `user-profile-view` больше не используется.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,27 +0,0 @@
|
||||
# Шапка канала и унификация карточек в треде
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` убрана отдельная кнопка `О канале` из тела экрана.
|
||||
- В шапке добавлена одна кнопка-лейбл формата `owner/channel` на уровне стрелки назад.
|
||||
- Нажатие по кнопке `owner/channel` открывает тот же модал «О канале».
|
||||
- В `channel-thread-view` карточки сообщений приведены к виду, аналогичному карточкам в канале:
|
||||
- верхняя плитка автора (аватар, логин, номер, время),
|
||||
- действия `Лайк`, `Ответить`, `Тред`, `Отправить`.
|
||||
- Клик по телу карточки в треде теперь открывает вложенный (более глубокий) тред этого сообщения.
|
||||
- Уменьшены отступы между карточками/блоками в треде.
|
||||
|
||||
- Что проверять:
|
||||
- В канале в шапке справа отображается единая кнопка `owner/channel`.
|
||||
- Кнопка `owner/channel` открывает модал «О канале».
|
||||
- Старой кнопки `О канале` в контенте экрана нет.
|
||||
- В треде визуал карточек совпадает по паттерну с каналом.
|
||||
- В треде клик по телу сообщения ведёт глубже в тред.
|
||||
- Клик по плитке автора в треде ведёт в профиль пользователя.
|
||||
- Межкарточные отступы в треде компактнее.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Шапка канала и карточки треда выглядят и работают единообразно.
|
||||
- Навигация по вложенным тредам выполняется кликом по сообщению.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,18 +0,0 @@
|
||||
# Поднятие верхней фиксированной шапки (канал и тред)
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` и `channel-thread-view` верхняя фиксированная шапка (стрелка назад + центральная кнопка с названием) поднята выше к верхней границе экрана.
|
||||
- Центральная кнопка и стрелка дополнительно подняты внутри шапки для более плотного позиционирования.
|
||||
- Поведение hover/focus сохранено без визуального «прыжка» центральной кнопки.
|
||||
|
||||
- Что проверять:
|
||||
- В канале и в треде верхняя шапка визуально выше, чем до правки.
|
||||
- Кнопка по центру и стрелка назад подняты и находятся на одной линии.
|
||||
- При наведении курсора центральная кнопка не смещается.
|
||||
- Шапка остаётся фиксированной при прокрутке.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Верхняя навигационная область выглядит компактнее и стабильнее.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,26 +0,0 @@
|
||||
# Профиль: упрощение + чат: UX меню и голосовой ввод
|
||||
|
||||
- Краткое описание:
|
||||
- В `profile-view` убрана кнопка `Обновить` и статусная строка (`Профиль обновлён`/ошибки).
|
||||
- Кнопка `Изменить профиль` переименована в `Редактировать профиль`.
|
||||
- В личном чате обновлены UX-сценарии:
|
||||
- контекстное меню на сообщении (`Копировать`, `Прочесть`) с закрытием кликом вне меню;
|
||||
- тост `Сообщение скопированно` при копировании;
|
||||
- обновлённый модал голосового ввода (`Отмена`, `OK`, `Распознать и сразу отправить сообщение`);
|
||||
- фоновое распознавание и авто-отправка для сценария «сразу отправить»;
|
||||
- для `OK` отображается режим ожидания распознавания с последующей вставкой текста в поле ввода.
|
||||
|
||||
- Что проверять:
|
||||
- На вкладке профиля отсутствуют кнопка `Обновить` и зелёный статус `Профиль обновлён`.
|
||||
- Кнопка вверху профиля называется `Редактировать профиль`.
|
||||
- В чате по клику на сообщение открывается компактное меню с двумя пунктами.
|
||||
- Копирование текста сообщения работает и показывает короткий тост.
|
||||
- Прочтение сообщения вслух запускается сразу.
|
||||
- Голосовой ввод корректно работает в двух режимах: вставка текста и авто-отправка после распознавания.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Профиль выглядит чище, без лишних статусов и ручной перезагрузки.
|
||||
- В личных сообщениях управление сообщениями и голосовым вводом работает стабильно и предсказуемо.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,28 +0,0 @@
|
||||
# DM: Ctrl+Enter, автоскролл и время в списке
|
||||
|
||||
- Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Исправлено поведение ввода в чате:
|
||||
- `Enter` отправляет сообщение;
|
||||
- `Ctrl+Enter` добавляет перенос строки в поле ввода.
|
||||
- В списке личных сообщений время последнего сообщения всегда отображается в правой колонке снизу.
|
||||
- Бейдж непрочитанных сообщений (если есть) отображается над временем, не заменяя его.
|
||||
- Обновлены стили карточки диалога для компактного и стабильного выравнивания.
|
||||
|
||||
## Что проверять
|
||||
|
||||
- В чате:
|
||||
- нажать `Ctrl+Enter` в середине текста и убедиться, что вставляется новая строка;
|
||||
- нажать `Enter` и убедиться, что сообщение отправляется.
|
||||
- В списке диалогов:
|
||||
- при `unread=0` справа снизу показывается время;
|
||||
- при `unread>0` сверху бейдж, снизу всё равно показывается время;
|
||||
- длинный текст последнего сообщения обрезается многоточием и не наезжает на время.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Управление вводом работает как в постановке.
|
||||
- Время в карточке диалога не исчезает при наличии непрочитанных сообщений.
|
||||
- Верстка карточки остаётся компактной и без сдвигов.
|
||||
@ -1,24 +0,0 @@
|
||||
# Личные сообщения: правая мета-колонка и Enter/Ctrl+Enter
|
||||
|
||||
- Краткое описание:
|
||||
- В списке `Личные сообщения` обновлена правая колонка карточки диалога:
|
||||
- сверху отображается бейдж количества непрочитанных (если есть);
|
||||
- снизу маленьким шрифтом отображается дата/время последнего сообщения;
|
||||
- если сообщений нет, вместо времени отображается `-`.
|
||||
- В экране чата нижний блок ввода закреплён (sticky) и остаётся на месте при прокрутке.
|
||||
- В поле ввода чата изменено поведение клавиш:
|
||||
- `Enter` отправляет сообщение;
|
||||
- `Ctrl+Enter` добавляет перенос строки и не отправляет сообщение.
|
||||
|
||||
- Что проверять:
|
||||
- В карточках диалогов справа корректно показываются непрочитанные/время/прочерк.
|
||||
- В чате нижний блок ввода не уезжает при прокрутке истории.
|
||||
- `Enter` отправляет сообщение из textarea.
|
||||
- `Ctrl+Enter` вставляет новую строку в textarea.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Список диалогов показывает полезную мета-информацию в стабильном формате.
|
||||
- Ввод сообщений в чате работает в привычной схеме Enter/многострочность.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,33 +0,0 @@
|
||||
# Деплой на `93.170.12.154`: Caddy + systemd
|
||||
|
||||
- Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Выполнен деплой UI и серверной части на `player@93.170.12.154`.
|
||||
- Создана структура:
|
||||
- `/home/player/SHiNE/caddy`
|
||||
- `/home/player/SHiNE/SHiNE-server`
|
||||
- `/home/player/SHiNE/SHiNE-UI`
|
||||
- Перенесены локальные данные:
|
||||
- `data/shine.sqlite`
|
||||
- `data/*.bch`
|
||||
- Настроен `shine-server.service` через `systemd`.
|
||||
- Настроен `Caddy`:
|
||||
- no-cache заголовки;
|
||||
- SPA fallback на `index.html`;
|
||||
- проксирование `/ws` на `127.0.0.1:7070`.
|
||||
- Добавлена документация в `Dev_Docs/deploy/` и файл по legacy-серверу `45.136.124.227`.
|
||||
|
||||
## Что проверять
|
||||
|
||||
- Открыть `https://shineup.me/start-view`.
|
||||
- Обновить страницу (`Ctrl+F5`) на роуте вида `/start-view` и убедиться, что нет 404.
|
||||
- Проверить авторизацию и базовые действия в UI.
|
||||
- Проверить, что вебсокет соединение устанавливается.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- UI и сервер доступны на новом хосте.
|
||||
- Сервисы `shine-server` и `caddy` в статусе `active`.
|
||||
- Маршруты SPA и no-cache работают как ожидается.
|
||||
@ -1,24 +0,0 @@
|
||||
# Редактирование сообщений: история и 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 с пустым текстом, без физического удаления блока.
|
||||
@ -1,23 +0,0 @@
|
||||
# Каналы: убрать кнопку тред, две вкладки, и автоскролл DM
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Краткое описание
|
||||
- В карточках сообщений канала и треда убрана кнопка `Тред` (`#`).
|
||||
- В `channels-list` оставлены только две вкладки: `Каналы` и `Мои каналы` (вкладка `Чаты` удалена).
|
||||
- Удалённые сообщения в канале и треде отображаются с красной пометкой `Сообщение удалено`.
|
||||
- В личных сообщениях усилен автоскролл вниз: при открытии чата и после отправки нового сообщения.
|
||||
|
||||
## Что проверять
|
||||
1. Открыть канал, проверить, что в действиях сообщения нет кнопки `Тред`.
|
||||
2. Открыть тред, проверить, что в действиях сообщения также нет кнопки `Тред`.
|
||||
3. В списке каналов сверху проверить только 2 вкладки: `Каналы` и `Мои каналы`.
|
||||
4. Удалить сообщение edit-ом в канале и в треде, убедиться, что видно красную пометку `Сообщение удалено`.
|
||||
5. Открыть любой личный чат, убедиться, что экран сразу внизу ленты.
|
||||
6. Отправить новое сообщение в личке, убедиться, что лента остаётся прокрученной вниз и сообщение видно сразу.
|
||||
|
||||
## Ожидаемый результат
|
||||
- Лишняя кнопка `Тред` отсутствует.
|
||||
- Вкладка `Чаты` отсутствует, навигация в списке каналов состоит из 2 вкладок.
|
||||
- Удалённые сообщения визуально выделены красным в канале и в треде.
|
||||
- В DM нет ручной прокрутки после входа в чат и после отправки новых сообщений.
|
||||
@ -1,15 +0,0 @@
|
||||
## Краткое описание
|
||||
Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений.
|
||||
|
||||
## Что проверять
|
||||
1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
||||
2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
||||
3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан).
|
||||
4. Если аватар не задан или недоступен, корректно остаётся fallback (буква).
|
||||
5. Форма и размер остаются круглыми и визуально не ломают карточки.
|
||||
|
||||
## Ожидаемый результат
|
||||
Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI.
|
||||
|
||||
## Статус
|
||||
`pending`
|
||||
@ -1,23 +0,0 @@
|
||||
## Краткое описание
|
||||
Добавлены новые типы connection-связей в блокчейне и API:
|
||||
- `known_person` (`60/61`)
|
||||
- `shine_confirmed` (`70/71`)
|
||||
- `shine_seen` (`74/75`)
|
||||
|
||||
## Что проверять
|
||||
1. `AddBlock` принимает новые `msg_sub_type` для `type=3`.
|
||||
2. Связи корректно попадают в `connections_state`:
|
||||
- ON создаёт/обновляет запись;
|
||||
- OFF удаляет запись соответствующего ON-типа.
|
||||
3. `GetUserConnectionsGraph` возвращает новые поля:
|
||||
- `outKnownPersons`, `inKnownPersons`
|
||||
- `outShineConfirmed`, `inShineConfirmed`
|
||||
- `outShineSeen`, `inShineSeen`
|
||||
4. Клиент `setUserRelation` принимает `kind`:
|
||||
- `known_person`, `shine_confirmed`, `shine_seen`.
|
||||
|
||||
## Ожидаемый результат
|
||||
Новые связи работают как обычные ON/OFF relation-типы, но не ломают текущие friend/contact/follow и остальные существующие связи.
|
||||
|
||||
## Статус
|
||||
`pending`
|
||||
@ -1,25 +0,0 @@
|
||||
## Краткое описание
|
||||
Перестроен блок связей в профиле чужого пользователя и добавлен UI для одностороннего "мнения" (`known_person` / `shine_confirmed` / `shine_seen`) с взаимным исключением на уровне UI.
|
||||
|
||||
## Что проверять
|
||||
1. Порядок базовых строк в профиле:
|
||||
- Контакт
|
||||
- Близкий друг
|
||||
- Подписка
|
||||
2. Под этими строками отображается блок мнений:
|
||||
- при отсутствии мнения кнопка `Добавить связь`;
|
||||
- при наличии мнения кнопка `Изменить связи`;
|
||||
- показываются текстовые формулировки для активного мнения.
|
||||
3. В модальном меню:
|
||||
- варианты добавления (синие);
|
||||
- `Убрать мнение` (красная).
|
||||
4. При смене мнения отправляется последовательность:
|
||||
- OFF старой связи,
|
||||
- ON новой связи.
|
||||
5. Для новых мнений показываются только исходящие (`out*`) оценки текущего пользователя (односторонняя логика).
|
||||
|
||||
## Ожидаемый результат
|
||||
Пользователь управляет одним активным мнением через UI, состояние читается корректно и не ломает существующие friend/contact/follow кнопки.
|
||||
|
||||
## Статус
|
||||
`pending`
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.80
|
||||
server.version=1.2.74
|
||||
client.version=1.2.82
|
||||
server.version=1.2.76
|
||||
|
||||
@ -283,6 +283,14 @@ function buildTargetFromNode(node) {
|
||||
return { blockchainName, blockNumber, blockHash };
|
||||
}
|
||||
|
||||
function buildRepostTargetFromNode(node) {
|
||||
const blockchainName = String(node?.targetBlockchainName || '').trim();
|
||||
const blockNumber = Number(node?.targetBlockNumber);
|
||||
const blockHash = normalizeMessageHash(node?.targetBlockHash);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||
return { blockchainName, blockNumber, blockHash };
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
@ -379,6 +387,95 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
||||
.map((item, index) => {
|
||||
const owner = String(item?.ownerLogin || '').trim();
|
||||
const name = String(item?.channelName || '').trim();
|
||||
const label = `${owner || 'my'} / ${name || 'stories'}`;
|
||||
return `<option value="${index}">${label}</option>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-repost-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Репост</h3>
|
||||
<label class="meta-muted" for="thread-repost-channel-select">Канал</label>
|
||||
<select id="thread-repost-channel-select" class="input">${options}</select>
|
||||
<label class="meta-muted" for="thread-repost-comment">Комментарий</label>
|
||||
<textarea id="thread-repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="thread-repost-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="thread-repost-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-repost-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-repost-submit" type="button">Опубликовать репост</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const selectEl = root.querySelector('#thread-repost-channel-select');
|
||||
const textEl = root.querySelector('#thread-repost-comment');
|
||||
const errorEl = root.querySelector('#thread-repost-error');
|
||||
const submitEl = root.querySelector('#thread-repost-submit');
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
if (selectEl) selectEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (submitEl) {
|
||||
submitEl.disabled = inFlight;
|
||||
submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост';
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-repost-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#thread-repost-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
const idx = Number(selectEl?.value ?? -1);
|
||||
if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) {
|
||||
errorEl.textContent = 'Выберите канал для репоста.';
|
||||
return;
|
||||
}
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите комментарий к репосту.';
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit({ channel: channels[idx].selector, text });
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||||
}
|
||||
});
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rows = Array.isArray(versions) ? versions : [];
|
||||
@ -477,6 +574,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
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 msgSubType = Number(node?.msgSubType || 0);
|
||||
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
|
||||
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
@ -610,7 +709,46 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
await handlers.onShare(target);
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
const repostButton = document.createElement('button');
|
||||
repostButton.type = 'button';
|
||||
repostButton.className = 'channel-action-item thread-reply-btn';
|
||||
repostButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">🔁</span>
|
||||
<span class="channel-action-label">Репост</span>
|
||||
`;
|
||||
repostButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
try {
|
||||
await handlers.onRepost(target);
|
||||
} catch (error) {
|
||||
handlers?.onActionError?.(error, 'repost');
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, repostButton, shareButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
originalButton.className = 'channel-action-item';
|
||||
originalButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
`;
|
||||
originalButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
handlers.navigate(makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: repostTarget.blockchainName,
|
||||
messageBlockNumber: repostTarget.blockNumber,
|
||||
}));
|
||||
});
|
||||
actions.append(originalButton);
|
||||
}
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@ -797,6 +935,45 @@ export function render({ navigate, route }) {
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onRepost: async (target) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
|
||||
.map((row) => {
|
||||
const selectorRow = {
|
||||
ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(),
|
||||
channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber),
|
||||
channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash),
|
||||
};
|
||||
if (!selectorRow.ownerBlockchainName || !Number.isFinite(selectorRow.channelRootBlockNumber) || selectorRow.channelRootBlockNumber < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ownerLogin: String(row?.channel?.ownerLogin || '').trim(),
|
||||
channelName: String(row?.channel?.channelName || '').trim(),
|
||||
selector: selectorRow,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
|
||||
|
||||
openRepostModal({
|
||||
navigate,
|
||||
channels,
|
||||
onSubmit: async ({ channel, text }) => {
|
||||
await authService.addBlockRepost({
|
||||
login,
|
||||
storagePwd,
|
||||
channel,
|
||||
message: target,
|
||||
text,
|
||||
});
|
||||
softHaptic(12);
|
||||
showToast('Репост опубликован');
|
||||
showStatus('');
|
||||
},
|
||||
});
|
||||
},
|
||||
onShare: async (target) => {
|
||||
try {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
@ -824,7 +1001,9 @@ export function render({ navigate, route }) {
|
||||
onActionError: (error, action) => {
|
||||
const fallback = action === 'unlike'
|
||||
? 'Не удалось убрать лайк.'
|
||||
: 'Не удалось поставить лайк.';
|
||||
: action === 'repost'
|
||||
? 'Не удалось сделать репост.'
|
||||
: 'Не удалось поставить лайк.';
|
||||
showStatus(toUserMessage(error, fallback));
|
||||
},
|
||||
onEdit: async (target, textValue, meta = {}) => {
|
||||
|
||||
@ -359,6 +359,90 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openRepostModal({ navigate, channels = [], onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const options = (Array.isArray(channels) ? channels : [])
|
||||
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
|
||||
.map((item, index) => {
|
||||
const owner = String(item?.ownerLogin || '').trim();
|
||||
const name = String(item?.channelName || '').trim();
|
||||
const label = `${owner || 'my'} / ${name || 'stories'}`;
|
||||
return `<option value="${index}">${label}</option>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="repost-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Репост</h3>
|
||||
<label class="meta-muted" for="repost-channel-select">Канал</label>
|
||||
<select id="repost-channel-select" class="input">${options}</select>
|
||||
<label class="meta-muted" for="repost-comment">Комментарий</label>
|
||||
<textarea id="repost-comment" class="input" rows="5" maxlength="2000" placeholder="Комментарий к репосту"></textarea>
|
||||
<div class="row wrap-row">
|
||||
<button class="ghost-btn" id="repost-voice" type="button">🎤 Голосом</button>
|
||||
</div>
|
||||
<div class="meta-muted inline-error" id="repost-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="repost-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="repost-submit" type="button">Опубликовать репост</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const selectEl = root.querySelector('#repost-channel-select');
|
||||
const textEl = root.querySelector('#repost-comment');
|
||||
const errorEl = root.querySelector('#repost-error');
|
||||
const submitEl = root.querySelector('#repost-submit');
|
||||
let inFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
inFlight = !!busy;
|
||||
if (selectEl) selectEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (submitEl) {
|
||||
submitEl.disabled = inFlight;
|
||||
submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост';
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#repost-cancel')?.addEventListener('click', close);
|
||||
root.querySelector('#repost-voice')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(textEl?.value || '').trim();
|
||||
if (textEl) textEl.value = prev ? `${prev} ${text}` : text;
|
||||
},
|
||||
});
|
||||
});
|
||||
submitEl?.addEventListener('click', async () => {
|
||||
if (inFlight) return;
|
||||
const idx = Number(selectEl?.value ?? -1);
|
||||
if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) {
|
||||
errorEl.textContent = 'Выберите канал для репоста.';
|
||||
return;
|
||||
}
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите комментарий к репосту.';
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
await onSubmit({ channel: channels[idx].selector, text });
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
|
||||
}
|
||||
});
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
@ -544,6 +628,14 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
repliesCount: Number(message?.repliesCount || 0),
|
||||
timestampMs: resolveMessageTimestampMs(message),
|
||||
messageRef,
|
||||
msgSubType: Number(message?.msgSubType || 0),
|
||||
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
|
||||
? {
|
||||
blockchainName: String(message.targetBlockchainName).trim(),
|
||||
blockNumber: Number(message.targetBlockNumber),
|
||||
blockHash: normalizeMessageHash(message?.targetBlockHash),
|
||||
}
|
||||
: null,
|
||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
|
||||
};
|
||||
@ -791,6 +883,7 @@ function renderPostCard(post, {
|
||||
selector,
|
||||
onToggleLike,
|
||||
onReply,
|
||||
onRepost,
|
||||
onShare,
|
||||
onEdit,
|
||||
}) {
|
||||
@ -911,7 +1004,19 @@ function renderPostCard(post, {
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
actions.append(likeButton, replyButton);
|
||||
const repostButton = document.createElement('button');
|
||||
repostButton.type = 'button';
|
||||
repostButton.className = 'channel-action-item channel-action-reply';
|
||||
repostButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">🔁</span>
|
||||
<span class="channel-action-label">Репост</span>
|
||||
`;
|
||||
repostButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
onRepost(post.messageRef);
|
||||
});
|
||||
actions.append(likeButton, replyButton, repostButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
@ -928,6 +1033,28 @@ function renderPostCard(post, {
|
||||
});
|
||||
|
||||
actions.append(shareButton);
|
||||
if (post.msgSubType === 30 && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
originalBtn.className = 'channel-action-item';
|
||||
originalBtn.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↪</span>
|
||||
<span class="channel-action-label">Оригинал</span>
|
||||
`;
|
||||
originalBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
navigate(makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: post.targetRef.blockchainName,
|
||||
messageBlockNumber: post.targetRef.blockNumber,
|
||||
}));
|
||||
});
|
||||
actions.append(originalBtn);
|
||||
}
|
||||
if (post.isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
@ -979,6 +1106,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
selector: channelData.selector,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
onReply: handlers.onReply,
|
||||
onRepost: handlers.onRepost,
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
});
|
||||
@ -1123,6 +1251,59 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
const loadOwnedChannelsForRepost = async (login) => {
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
|
||||
return rows
|
||||
.map((row) => {
|
||||
const selector = {
|
||||
ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(),
|
||||
channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber),
|
||||
channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash),
|
||||
};
|
||||
if (!selector.ownerBlockchainName || !Number.isFinite(selector.channelRootBlockNumber) || selector.channelRootBlockNumber < 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ownerLogin: String(row?.channel?.ownerLogin || '').trim(),
|
||||
channelName: String(row?.channel?.channelName || '').trim(),
|
||||
selector,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const isSameChannelSelector = (a, b) => (
|
||||
String(a?.ownerBlockchainName || '').trim() === String(b?.ownerBlockchainName || '').trim()
|
||||
&& Number(a?.channelRootBlockNumber) === Number(b?.channelRootBlockNumber)
|
||||
&& normalizeRouteHash(a?.channelRootBlockHash) === normalizeRouteHash(b?.channelRootBlockHash)
|
||||
);
|
||||
|
||||
const onRepost = async (messageRef) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const channels = await loadOwnedChannelsForRepost(login);
|
||||
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
|
||||
openRepostModal({
|
||||
navigate,
|
||||
channels,
|
||||
onSubmit: async ({ channel, text }) => {
|
||||
await authService.addBlockRepost({
|
||||
login,
|
||||
storagePwd,
|
||||
channel,
|
||||
message: messageRef,
|
||||
text,
|
||||
});
|
||||
if (isSameChannelSelector(channel, activeSelector)) {
|
||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||
rerender();
|
||||
}
|
||||
softHaptic(12);
|
||||
showToast('Репост опубликован');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onShare = async (routePath) => {
|
||||
try {
|
||||
const routeToShare = String(routePath || '').trim();
|
||||
@ -1241,6 +1422,14 @@ export function render({ navigate, route }) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
||||
}
|
||||
},
|
||||
onRepost: async (messageRef) => {
|
||||
try {
|
||||
await onRepost(messageRef);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, 'Не удалось сделать репост.'));
|
||||
}
|
||||
},
|
||||
onAddPost: async (bodyText) => {
|
||||
try {
|
||||
await onAddPost(bodyText);
|
||||
|
||||
@ -40,6 +40,7 @@ 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_TEXT_REPOST = 30;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
@ -371,6 +372,50 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextRepostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
text,
|
||||
}) {
|
||||
const message = String(text || '').trim();
|
||||
if (!message) throw new Error('Комментарий к репосту обязателен');
|
||||
|
||||
const bch = String(toBlockchainName || '').trim();
|
||||
if (!bch) throw new Error('toBlockchainName is required for repost');
|
||||
const bchBytes = utf8Bytes(bch);
|
||||
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||
}
|
||||
|
||||
const blockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||
throw new Error('Invalid toBlockNumber for repost');
|
||||
}
|
||||
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||||
throw new Error('Repost comment must be 1..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int8Byte(bchBytes.length),
|
||||
bchBytes,
|
||||
int32Bytes(blockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextEditPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
@ -1010,6 +1055,77 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockRepost({ login, channel, message, text, storagePwd }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
const cleanText = String(text || '').trim();
|
||||
if (!cleanText) throw new Error('Комментарий к репосту обязателен');
|
||||
const target = normalizeMessageRefTarget(message, 'repost');
|
||||
const selector = channel || {};
|
||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
const root = Number(selector?.channelRootBlockNumber);
|
||||
const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
||||
|
||||
return this.runWriteLocked(key, async () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector');
|
||||
if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels');
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root);
|
||||
if (!rootChannel) throw new Error('Channel root not found');
|
||||
rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = root;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 0;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName: owner,
|
||||
channelRootBlockNumber: root,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', cleanLogin);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(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(0, latestVersionsTotal)
|
||||
: 1;
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextRepostBodyBytes({
|
||||
lineCode: root,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockchainName: target.blockchainName,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_REPOST,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockEditMessage({
|
||||
login,
|
||||
message,
|
||||
|
||||
@ -35,7 +35,8 @@ public final class BodyRecordParser {
|
||||
BodyRecord r = switch (key) {
|
||||
case TextBody.KEY -> {
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
|| st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
yield new TextLineBody(subType, version, bodyBytes);
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,12 @@ public final class MsgSubType {
|
||||
*/
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/**
|
||||
* REPOST — репост сообщения в линии канала.
|
||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||
*/
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
|
||||
@ -16,6 +16,7 @@ import java.util.Objects;
|
||||
* subType:
|
||||
* - POST (10)
|
||||
* - EDIT_POST (11)
|
||||
* - REPOST (30)
|
||||
*
|
||||
* Формат bodyBytes (BigEndian):
|
||||
*
|
||||
@ -36,6 +37,18 @@ import java.util.Objects;
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*
|
||||
* REPOST:
|
||||
* [4] lineCode
|
||||
* [4] prevLineNumber
|
||||
* [32] prevLineHash32
|
||||
* [4] thisLineNumber
|
||||
* [1] toBlockchainNameLen (uint8)
|
||||
* [N] toBlockchainName UTF-8
|
||||
* [4] toBlockGlobalNumber (int32)
|
||||
* [32] toBlockHash32
|
||||
* [2] textLenBytes (uint16)
|
||||
* [N] text UTF-8
|
||||
*/
|
||||
public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget {
|
||||
|
||||
@ -53,7 +66,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
public final byte[] prevLineHash32; // 32 (может быть нули)
|
||||
public final int thisLineNumber;
|
||||
|
||||
// target (только для EDIT_POST)
|
||||
// target (для EDIT_POST / REPOST)
|
||||
public final String toBlockchainName; // nullable для POST/EDIT_POST
|
||||
public final Integer toBlockGlobalNumber; // nullable для POST
|
||||
public final byte[] toBlockHash32; // nullable для POST
|
||||
|
||||
@ -73,8 +87,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
}
|
||||
|
||||
int st = this.subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st);
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
@ -97,10 +113,22 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
byte[] tgtHash = new byte[32];
|
||||
bb.get(tgtHash);
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = tgtNum;
|
||||
this.toBlockHash32 = tgtHash;
|
||||
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
|
||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
|
||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
|
||||
byte[] nameBytes = new byte[nameLen];
|
||||
bb.get(nameBytes);
|
||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||
this.toBlockGlobalNumber = bb.getInt();
|
||||
this.toBlockHash32 = new byte[32];
|
||||
bb.get(this.toBlockHash32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
@ -119,13 +147,16 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
short subType,
|
||||
Integer toBlockGlobalNumber,
|
||||
byte[] toBlockHash32,
|
||||
String toBlockchainName,
|
||||
String message) {
|
||||
|
||||
Objects.requireNonNull(message, "message == null");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST");
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST");
|
||||
}
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
@ -147,9 +178,22 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
this.toBlockchainName = toBlockchainName;
|
||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||
} else {
|
||||
this.toBlockchainName = null;
|
||||
this.toBlockGlobalNumber = null;
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
@ -160,7 +204,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
@Override
|
||||
public TextLineBody check() {
|
||||
int st = subType & 0xFFFF;
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF))
|
||||
if (st != (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)
|
||||
&& st != (MsgSubType.TEXT_REPOST & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextLineBody subType: " + st);
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
@ -173,10 +219,21 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("REPOST message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("REPOST toBlockHash32 invalid");
|
||||
} else {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST must not contain target fields");
|
||||
}
|
||||
|
||||
@ -196,11 +253,20 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
int cap;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
||||
} else {
|
||||
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
// EDIT_POST
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
|
||||
} else {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
|
||||
}
|
||||
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
||||
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||
@ -213,6 +279,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
||||
bb.put((byte) nameUtf8.length);
|
||||
bb.put(nameUtf8);
|
||||
bb.putInt(toBlockGlobalNumber);
|
||||
bb.put(toBlockHash32);
|
||||
}
|
||||
|
||||
bb.putShort((short) msgUtf8.length);
|
||||
@ -228,7 +300,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
@Override public int lineSeq() { return thisLineNumber; }
|
||||
|
||||
/* ====================== BodyHasTarget ===================== */
|
||||
@Override public String toBchName() { return null; } // по ТЗ: не хранить
|
||||
@Override public String toBchName() { return toBlockchainName; }
|
||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ public final class DatabaseInitializer {
|
||||
public static final short TEXT_EDIT_POST = 11;
|
||||
public static final short TEXT_REPLY = 20;
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
|
||||
@ -30,6 +30,9 @@ public final class MsgSubType {
|
||||
/** EDIT_REPLY — редактирование исходного ответа. */
|
||||
public static final short TEXT_EDIT_REPLY = 21;
|
||||
|
||||
/** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */
|
||||
public static final short TEXT_REPOST = 30;
|
||||
|
||||
/* ===================== REACTION (msg_type=2) ===================== */
|
||||
|
||||
/** Лайк (LIKE). */
|
||||
|
||||
@ -131,7 +131,7 @@ public final class SubscriptionsDAO {
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub AS (
|
||||
@ -144,7 +144,7 @@ public final class SubscriptionsDAO {
|
||||
ON s.channel_login = b.login
|
||||
AND s.channel_bch_name = b.bch_name
|
||||
WHERE b.msg_type = ?
|
||||
AND b.msg_sub_type = ?
|
||||
AND b.msg_sub_type IN (?, ?)
|
||||
GROUP BY b.login, b.bch_name
|
||||
),
|
||||
last_pub_block AS (
|
||||
@ -208,10 +208,12 @@ public final class SubscriptionsDAO {
|
||||
// pub_counts
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||
|
||||
// last_pub
|
||||
ps.setInt(i++, MSG_TYPE_TEXT);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_POST);
|
||||
ps.setInt(i++, (int) MsgSubType.TEXT_REPOST);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
|
||||
@ -89,12 +89,13 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
|
||||
static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?";
|
||||
String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getInt("cnt") : 0;
|
||||
}
|
||||
@ -105,7 +106,7 @@ final class ChannelsReadSupport {
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
@ -113,7 +114,8 @@ final class ChannelsReadSupport {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
PostBlock pb = new PostBlock();
|
||||
@ -177,17 +179,18 @@ final class ChannelsReadSupport {
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
ps.setInt(5, limit);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
ps.setInt(6, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
@ -197,6 +200,10 @@ final class ChannelsReadSupport {
|
||||
pb.blockNumber = rs.getInt("block_number");
|
||||
pb.blockHash = rs.getBytes("block_hash");
|
||||
pb.blockBytes = rs.getBytes("block_bytes");
|
||||
pb.toBchName = rs.getString("to_bch_name");
|
||||
pb.toBlockNumber = (Integer) rs.getObject("to_block_number");
|
||||
pb.toBlockHash = rs.getBytes("to_block_hash");
|
||||
pb.msgSubType = rs.getInt("msg_sub_type");
|
||||
out.add(pb);
|
||||
}
|
||||
return out;
|
||||
@ -357,7 +364,7 @@ final class ChannelsReadSupport {
|
||||
String sql = """
|
||||
SELECT block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?
|
||||
ORDER BY block_number DESC
|
||||
LIMIT 300
|
||||
""";
|
||||
@ -365,7 +372,8 @@ final class ChannelsReadSupport {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_POST);
|
||||
ps.setInt(4, lineCode);
|
||||
ps.setInt(4, MsgSubType.TEXT_REPOST);
|
||||
ps.setInt(5, lineCode);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
|
||||
@ -501,6 +509,10 @@ final class ChannelsReadSupport {
|
||||
int blockNumber;
|
||||
byte[] blockHash;
|
||||
byte[] blockBytes;
|
||||
String toBchName;
|
||||
Integer toBlockNumber;
|
||||
byte[] toBlockHash;
|
||||
int msgSubType;
|
||||
}
|
||||
|
||||
static final class TextInfo {
|
||||
|
||||
@ -11,6 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMe
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.SqliteDbController;
|
||||
import shine.db.MsgSubType;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
import blockchain.body.CreateChannelBody;
|
||||
|
||||
@ -96,8 +97,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
msgRef.setBlockNumber(post.blockNumber);
|
||||
msgRef.setBlockHash(ChannelsReadSupport.toHex(post.blockHash));
|
||||
item.setMessageRef(msgRef);
|
||||
item.setMsgSubType(post.msgSubType);
|
||||
item.setAuthorLogin(post.login);
|
||||
item.setAuthorBlockchainName(post.bchName);
|
||||
item.setTargetBlockchainName(post.toBchName);
|
||||
item.setTargetBlockNumber(post.toBlockNumber);
|
||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(post.toBlockHash));
|
||||
|
||||
List<Net_GetChannelMessages_Response.VersionItem> versionsOut = new ArrayList<>();
|
||||
int index = 1;
|
||||
@ -111,16 +116,18 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
v1.setCreatedAtMs(postText.createdAtMs);
|
||||
versionsOut.add(v1);
|
||||
|
||||
List<ChannelsReadSupport.PostBlock> 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();
|
||||
ve.setVersionIndex(index++);
|
||||
ve.setBlockNumber(edit.blockNumber);
|
||||
ve.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
|
||||
ve.setText(editText.text);
|
||||
ve.setCreatedAtMs(editText.createdAtMs);
|
||||
versionsOut.add(ve);
|
||||
if (post.msgSubType == MsgSubType.TEXT_POST) {
|
||||
List<ChannelsReadSupport.PostBlock> 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();
|
||||
ve.setVersionIndex(index++);
|
||||
ve.setBlockNumber(edit.blockNumber);
|
||||
ve.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
|
||||
ve.setText(editText.text);
|
||||
ve.setCreatedAtMs(editText.createdAtMs);
|
||||
versionsOut.add(ve);
|
||||
}
|
||||
}
|
||||
|
||||
item.setVersions(versionsOut);
|
||||
|
||||
@ -143,8 +143,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
ref.setBlockNumber(row.blockNumber);
|
||||
ref.setBlockHash(ChannelsReadSupport.toHex(row.blockHash));
|
||||
node.setMessageRef(ref);
|
||||
node.setMsgSubType(row.msgSubType);
|
||||
node.setAuthorLogin(row.login);
|
||||
node.setAuthorBlockchainName(row.bchName);
|
||||
node.setTargetBlockchainName(row.toBchName);
|
||||
node.setTargetBlockNumber(row.toBlockNumber);
|
||||
node.setTargetBlockHash(ChannelsReadSupport.toHex(row.toBlockHash));
|
||||
|
||||
ChannelsReadSupport.TextInfo base = ChannelsReadSupport.parseTextAndTime(row.blockBytes);
|
||||
node.setCreatedAtMs(base.createdAtMs);
|
||||
@ -158,16 +162,18 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
first.setCreatedAtMs(base.createdAtMs);
|
||||
versions.add(first);
|
||||
|
||||
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
|
||||
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
|
||||
v.setVersionIndex(versions.size() + 1);
|
||||
v.setBlockNumber(edit.blockNumber);
|
||||
v.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
|
||||
v.setText(et.text);
|
||||
v.setCreatedAtMs(et.createdAtMs);
|
||||
versions.add(v);
|
||||
if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) {
|
||||
short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST;
|
||||
for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) {
|
||||
ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem();
|
||||
v.setVersionIndex(versions.size() + 1);
|
||||
v.setBlockNumber(edit.blockNumber);
|
||||
v.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash));
|
||||
v.setText(et.text);
|
||||
v.setCreatedAtMs(et.createdAtMs);
|
||||
versions.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
node.setVersions(versions);
|
||||
|
||||
@ -49,8 +49,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
|
||||
public static class MessageItem {
|
||||
private BlockRef messageRef;
|
||||
private Integer msgSubType;
|
||||
private String authorLogin;
|
||||
private String authorBlockchainName;
|
||||
private String targetBlockchainName;
|
||||
private Integer targetBlockNumber;
|
||||
private String targetBlockHash;
|
||||
private long createdAtMs;
|
||||
private String text;
|
||||
private int likesCount;
|
||||
@ -61,6 +65,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
|
||||
public BlockRef getMessageRef() { return messageRef; }
|
||||
public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; }
|
||||
public Integer getMsgSubType() { return msgSubType; }
|
||||
public void setMsgSubType(Integer msgSubType) { this.msgSubType = msgSubType; }
|
||||
|
||||
public String getAuthorLogin() { return authorLogin; }
|
||||
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
|
||||
@ -68,6 +74,15 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
public String getAuthorBlockchainName() { return authorBlockchainName; }
|
||||
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
|
||||
|
||||
public String getTargetBlockchainName() { return targetBlockchainName; }
|
||||
public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; }
|
||||
|
||||
public Integer getTargetBlockNumber() { return targetBlockNumber; }
|
||||
public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; }
|
||||
|
||||
public String getTargetBlockHash() { return targetBlockHash; }
|
||||
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; }
|
||||
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user