Compare commits

..

No commits in common. "21413268f3bf58c4a50b5a9f5fb638f9d48a112d01f29012508d2113f65d188d" and "ab31ccf6d8e373e2c19cee6d215d43ccb21e3baf5cfc410c25220c446ea9e9ff" have entirely different histories.

86 changed files with 885 additions and 3952 deletions

View File

@ -13,21 +13,6 @@
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям. - Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`. - При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение. - Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
## Документация личных сообщений (DM)
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/README.md`.
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять `Dev_Docs/Personal_Messages/README.md`.
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
## Известная проблема (временная пометка)
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя.
## Версионирование ## Версионирование
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория). - Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
@ -37,15 +22,16 @@
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное. - Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
## Deploy ## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`. - Все документы и заметки по деплою хранить в папке `Deploy Server/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`). - Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
- Базовый целевой хост для деплоя по умолчанию: `player@45.136.124.227`.
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`). - Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`. - Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`.
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`. - Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`. - Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`). - По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя. - При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
## Логи звонков (установка соединения) ## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер). - Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).

52
DOC/api/PWA_FCM_SETUP.md Normal file
View File

@ -0,0 +1,52 @@
# Настройка PWA + FCM для веб-клиента (Chrome/Edge/Firefox/Safari iOS)
## 1) Что нужно создать в Firebase
1. Создать проект Firebase.
2. Включить Cloud Messaging.
3. Создать Web App и получить конфиг:
- apiKey
- authDomain
- projectId
- messagingSenderId
- appId
4. В Cloud Messaging -> Web Push certificates сгенерировать VAPID key.
5. Для серверной отправки взять **Server key** (legacy) или настроить HTTP v1 (service account).
## 2) Куда вставить токены в клиенте
Файл: `shine-UI/index.html` и `shine-UI/firebase-messaging-sw.js`.
Заполнить:
- `window.__SHINE_FIREBASE_CONFIG__`
- `window.__SHINE_FIREBASE_VAPID_KEY__`
- `FIREBASE_CONFIG` (в service worker)
## 3) Куда вставить серверный ключ FCM
Файл: `src/main/resources/application.properties`
Добавить:
```
fcm.server.key=YOUR_FCM_SERVER_KEY
```
## 4) PWA требования
1. Открывать сайт только по HTTPS (или localhost).
2. Разрешить уведомления в браузере.
3. Убедиться, что `manifest.webmanifest` доступен.
4. Убедиться, что `firebase-messaging-sw.js` зарегистрирован.
## 5) Safari / iPhone (iOS)
- Нужен iOS 16.4+.
- Пользователь должен добавить сайт на Home Screen.
- После запуска PWA с Home Screen дать разрешение на уведомления.
- Без Home Screen web push в Safari iOS не работает.
## 6) Проверка
1. Логин в приложении.
2. Клиент вызывает `UpsertPushToken` и отправляет FCM токен на сервер.
3. Вызов `SendDirectMessage` пользователю без активной WS доставки.
4. Сервер шлет push через FCM.
## 7) Поддержка разных браузеров
- Chrome/Edge/Opera/Android Browser: FCM web push поддерживается нативно.
- Firefox: поддержка web push есть, но тестировать отдельно (поведение токенов отличается).
- Safari macOS/iOS: web push есть, но требуется PWA режим и Apple-ограничения.

View File

@ -107,20 +107,6 @@
- `CONNECTION_UNCONTACT (21)` - `CONNECTION_UNCONTACT (21)`
- `CONNECTION_FOLLOW (30)` - `CONNECTION_FOLLOW (30)`
- `CONNECTION_UNFOLLOW (31)` - `CONNECTION_UNFOLLOW (31)`
- `CONNECTION_SPOUSE (40)`
- `CONNECTION_UNSPOUSE (41)`
- `CONNECTION_PARENT (50)`
- `CONNECTION_UNPARENT (51)`
- `CONNECTION_CHILD (52)`
- `CONNECTION_UNCHILD (53)`
- `CONNECTION_SIBLING (54)`
- `CONNECTION_UNSIBLING (55)`
- `CONNECTION_KNOWN_PERSON (60)`
- `CONNECTION_UNKNOWN_PERSON (61)`
- `CONNECTION_SHINE_CONFIRMED (70)`
- `CONNECTION_SHINE_UNCONFIRMED (71)`
- `CONNECTION_SHINE_SEEN (74)`
- `CONNECTION_SHINE_UNSEEN (75)`
5. **USER_PARAM (type=4)** 5. **USER_PARAM (type=4)**
- `USER_PARAM_TEXT_TEXT (1)` - `USER_PARAM_TEXT_TEXT (1)`

View File

@ -16,8 +16,8 @@
- `type=0` — TECH: HEADER, CREATE_CHANNEL. - `type=0` — TECH: HEADER, CREATE_CHANNEL.
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY. - `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY.
- `type=2` — REACTION: LIKE/UNLIKE. - `type=2` — REACTION: LIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя. - `type=4` — USER_PARAM: key/value-параметры пользователя.
## Примечание ## Примечание

View File

@ -1,7 +1,7 @@
# Типы каналов и CreateChannel # Типы каналов и CreateChannel
## 1. Формат `CreateChannelBody` (`msg_type=0`, `subType=1`, `version=1`) ## 1. Формат `CreateChannelBody`
Payload включает: Формат `TECH_CREATE_CHANNEL` поддерживает единственный текущий `version=1` и включает:
1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`); 1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`);
2. `channelName`; 2. `channelName`;
@ -17,10 +17,6 @@ Payload включает:
Версия типа (`channelTypeVersion`) сейчас используется со значением `1`. Версия типа (`channelTypeVersion`) сейчас используется со значением `1`.
Важно для MVP:
- `100` и `200` в формате поддерживаются, но в текущем UI не используются.
- В MVP рабочий UI-флоу — каналы `0` и `1`.
## 3. Имя root-канала ## 3. Имя root-канала
- Root-канал (`line_code = 0`) в API/чтении отображается как `stories`. - Root-канал (`line_code = 0`) в API/чтении отображается как `stories`.
- Публикации в `stories` разрешены владельцу собственного блокчейна. - Публикации в `stories` разрешены владельцу собственного блокчейна.

View File

@ -24,21 +24,7 @@
- связей и подписок; - связей и подписок;
- пользовательских параметров. - пользовательских параметров.
## 3. Правила line-полей (фактическая серверная валидация) ## 3. Root-идея для каналов и подписок
Line-поля: `lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`.
- Line-поля разрешены только для `msg_type`: `0`, `1`, `3`, `4`.
- Если передано хотя бы одно line-поле, должны быть переданы все 4.
- `prevLineNumber/prevLineHash32` должны указывать на существующий блок этой же цепочки.
- Для первого шага после root (`prevLineNumber == lineCode`):
- `TEXT (msg_type=1)`: `thisLineNumber = 0`;
- `TECH/CONNECTION/USER_PARAM (0/3/4)`: `thisLineNumber = 1`.
- Для обычного шага:
- `TEXT`: `thisLineNumber` допускает `same` или `+1` от предыдущего блока линии;
- `TECH/CONNECTION/USER_PARAM`: строго `+1`.
## 4. Root-идея для каналов и подписок
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки: Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
- `HEADER` для базовой сущности пользователя/канала `0`; - `HEADER` для базовой сущности пользователя/канала `0`;

View File

@ -27,7 +27,3 @@
- Команды передаются как обычные `TEXT_POST` сообщения. - Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала. - Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации. - Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
## 4. Статус для MVP
- В текущем UI каналы `type=100` и `type=200` не используются.
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.

View File

@ -10,7 +10,7 @@ TECH-тип покрывает системные записи цепочки.
2. `subType=1``TECH_CREATE_CHANNEL` 2. `subType=1``TECH_CREATE_CHANNEL`
- создание нового канала; - создание нового канала;
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`. - хранит line-поля + `channelName`.
## Назначение ## Назначение

View File

@ -19,14 +19,7 @@ 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`-подтипа нет.

View File

@ -5,9 +5,6 @@
1. `subType=1``REACTION_LIKE` 1. `subType=1``REACTION_LIKE`
- лайк на целевой блок; - лайк на целевой блок;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`. - хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
2. `subType=2``REACTION_UNLIKE`
- снятие лайка с целевого блока;
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
## Назначение ## Назначение

View File

@ -4,16 +4,12 @@ CONNECTION-тип описывает социальные связи и подп
## Подтипы ## Подтипы
`10/11``close_friend / unclose_friend` (близкий друг) 1. `subType=10``CONNECTION_FRIEND`
`20/21``contact / uncontact` (контакт) 2. `subType=11``CONNECTION_UNFRIEND`
`30/31``follow / unfollow` (подписан) 3. `subType=20``CONNECTION_CONTACT`
`40/41``spouse / unspouse` (супруг/супруга) 4. `subType=21``CONNECTION_UNCONTACT`
`50/51``parent / unparent` (родитель) 5. `subType=30``CONNECTION_FOLLOW`
`52/53``child / unchild` (ребёнок) 6. `subType=31``CONNECTION_UNFOLLOW`
`54/55``sibling / unsibling` (брат/сестра)
`60/61``known_person / unknown_person` (знаю этого человека)
`70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий)
`74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим)
## Общий формат payload ## Общий формат payload
@ -26,4 +22,3 @@ CONNECTION-тип описывает социальные связи и подп
- FOLLOW указывает на root канала: - FOLLOW указывает на root канала:
- `HEADER` для канала `0`; - `HEADER` для канала `0`;
- `CREATE_CHANNEL` для пользовательского канала. - `CREATE_CHANNEL` для пользовательского канала.
- Для остальных типов связи (`SPOUSE/PARENT/CHILD/SIBLING`) используется тот же target-формат.

View File

@ -1,27 +1,5 @@
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-05-20 11:34:17 +0300
- Базовый коммит-ориентир: `a53444b`.
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
- `60/61``known_person / unknown_person` (знаю этого человека);
- `70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий);
- `74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим).
- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
## 2026-05-19 20:30:21 +0300
- Базовый коммит-ориентир: `7986184`.
- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения.
- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком.
## 2026-05-19 00:22:46 +0300
- Базовый коммит-ориентир: `c27da63a3e65`.
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI.
- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`.
- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции.
- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей.
- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением.
## 2026-05-13 00:02:32 +0300 ## 2026-05-13 00:02:32 +0300
- Базовый коммит-ориентир: `f63f40f1eb2f`. - Базовый коммит-ориентир: `f63f40f1eb2f`.
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`. - Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.

View File

@ -1,33 +1,16 @@
# Документация блокчейна SHiNE (MVP) # Blockchain Docs (Актуально)
Этот каталог описывает только текущий рабочий формат протокола для MVP. ## Назначение
Этот набор файлов — актуальная документация по текущему формату блокчейна SHiNE для каналов, их типов и командных сообщений.
## Основные документы ## Оглавление
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md) 1. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки. Текущий формат `CreateChannelBody`, типы каналов, уникальность имён и правила `stories`.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md) 2. [02_Channel_Commands.md](./02_Channel_Commands.md)
Виды цепочек и правила line-полей. Командные сообщения в каналах: `/.desc`, `/.add`, `/.remove`.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md) 3. [CHANGELOG.md](./CHANGELOG.md)
Системные блоки (`msg_type=0`). История изменений документации и блокчейн-правил.
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
Текстовые блоки (`msg_type=1`).
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
Реакции (`msg_type=2`).
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
Социальные связи (`msg_type=3`).
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
Параметры пользователя (`msg_type=4`).
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Типы каналов и формат `CreateChannelBody`.
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
Команды в текстовых сообщениях каналов.
10. [CHANGELOG.md](./CHANGELOG.md)
Журнал изменений документации.
## Важные ограничения MVP ## Обязательное правило сопровождения
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI. - Любое изменение блокчейн-кода (форматы, типы, правила чтения/записи, команды) должно сопровождаться обновлением файлов из этого каталога.
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`. - Изменение обязательно фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
## Обязательное сопровождение
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.

View File

@ -0,0 +1,22 @@
# Уведомления: продуктовые заглушки + правило intake в AGENTS
- краткое описание фичи:
- На вкладке `Уведомления` удалены демонстрационные карточки из разделов `Ответы` и `События`.
- В каждом табе добавлена отдельная продуктовая заглушка:
- `Ответы`: про ответы и комментарии на сообщения в публичных каналах;
- `События`: про подписки, добавления, лайки и прочие действия.
- В обоих табах добавлено явное сообщение, что раздел находится в разработке.
- В `AGENTS.md` добавлен обязательный блок: при новом задании сначала пересказ, вопросы, при необходимости идеи, оценка фичи и обязательное подтверждение перед началом реализации.
- что именно проверять:
- Открыть `Уведомления` и проверить, что в `Ответы` отображается только заглушка (без примеров карточек).
- Переключить на `События` и проверить отдельную заглушку с текстом про события.
- Убедиться, что в обоих табах присутствует сообщение о разработке и будущем добавлении функционала.
- Проверить наличие нового блока `Коммуникация по новым задачам (обязательно)` в `AGENTS.md`.
- ожидаемый результат:
- Вкладка уведомлений содержит только две заглушки по табам и не показывает тестовые данные.
- Правило работы с новыми задачами зафиксировано в `AGENTS.md`.
- статус:
- pending

View File

@ -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`

View File

@ -1,19 +0,0 @@
# Навигация по тредам и история сообщения
Статус: `pending`
## Краткое описание
В экране треда добавлен явный переход `🧵 В тред` для каждого сообщения (включая ответы), чтобы можно было углубляться в любую ветку обсуждения.
Также уточнены заголовки блоков: сверху история сообщений, отдельно текущее сообщение.
## Что проверять
1. Открыть любой канал и перейти в тред сообщения.
2. Нажать `🧵 В тред` у одного из ответов.
3. Убедиться, что открывается тред выбранного ответа, а не исходного сообщения.
4. Проверить, что в новом треде сверху показывается блок истории (`История выше...`), затем блок `Текущее сообщение`, затем `Ответы`.
5. Проверить на мобильной ширине, что кнопки действий в карточке не ломают верстку.
## Ожидаемый результат
- Переход в тред ответа работает стабильно для всех узлов дерева.
- Пользователь видит структуру треда в логичном порядке: предки → текущее сообщение → потомки.
- UI остаётся читаемым на мобильных экранах.

View File

@ -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 для открытия треда не воспроизводится.

View File

@ -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`

View File

@ -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`

View File

@ -1,27 +0,0 @@
# Шапка канала и унификация карточек в треде
- Краткое описание:
- В `channel-view` убрана отдельная кнопка `О канале` из тела экрана.
- В шапке добавлена одна кнопка-лейбл формата `owner/channel` на уровне стрелки назад.
- Нажатие по кнопке `owner/channel` открывает тот же модал «О канале».
- В `channel-thread-view` карточки сообщений приведены к виду, аналогичному карточкам в канале:
- верхняя плитка автора (аватар, логин, номер, время),
- действия `Лайк`, `Ответить`, `Тред`, `Отправить`.
- Клик по телу карточки в треде теперь открывает вложенный (более глубокий) тред этого сообщения.
- Уменьшены отступы между карточками/блоками в треде.
- Что проверять:
- В канале в шапке справа отображается единая кнопка `owner/channel`.
- Кнопка `owner/channel` открывает модал «О канале».
- Старой кнопки `О канале` в контенте экрана нет.
- В треде визуал карточек совпадает по паттерну с каналом.
- В треде клик по телу сообщения ведёт глубже в тред.
- Клик по плитке автора в треде ведёт в профиль пользователя.
- Межкарточные отступы в треде компактнее.
- Ожидаемый результат:
- Шапка канала и карточки треда выглядят и работают единообразно.
- Навигация по вложенным тредам выполняется кликом по сообщению.
- Статус:
- `pending`

View File

@ -1,18 +0,0 @@
# Поднятие верхней фиксированной шапки (канал и тред)
- Краткое описание:
- В `channel-view` и `channel-thread-view` верхняя фиксированная шапка (стрелка назад + центральная кнопка с названием) поднята выше к верхней границе экрана.
- Центральная кнопка и стрелка дополнительно подняты внутри шапки для более плотного позиционирования.
- Поведение hover/focus сохранено без визуального «прыжка» центральной кнопки.
- Что проверять:
- В канале и в треде верхняя шапка визуально выше, чем до правки.
- Кнопка по центру и стрелка назад подняты и находятся на одной линии.
- При наведении курсора центральная кнопка не смещается.
- Шапка остаётся фиксированной при прокрутке.
- Ожидаемый результат:
- Верхняя навигационная область выглядит компактнее и стабильнее.
- Статус:
- `pending`

View File

@ -1,26 +0,0 @@
# Профиль: упрощение + чат: UX меню и голосовой ввод
- Краткое описание:
- В `profile-view` убрана кнопка `Обновить` и статусная строка (`Профиль обновлён`/ошибки).
- Кнопка `Изменить профиль` переименована в `Редактировать профиль`.
- В личном чате обновлены UX-сценарии:
- контекстное меню на сообщении (`Копировать`, `Прочесть`) с закрытием кликом вне меню;
- тост `Сообщение скопированно` при копировании;
- обновлённый модал голосового ввода (`Отмена`, `OK`, `Распознать и сразу отправить сообщение`);
- фоновое распознавание и авто-отправка для сценария «сразу отправить»;
- для `OK` отображается режим ожидания распознавания с последующей вставкой текста в поле ввода.
- Что проверять:
- На вкладке профиля отсутствуют кнопка `Обновить` и зелёный статус `Профиль обновлён`.
- Кнопка вверху профиля называется `Редактировать профиль`.
- В чате по клику на сообщение открывается компактное меню с двумя пунктами.
- Копирование текста сообщения работает и показывает короткий тост.
- Прочтение сообщения вслух запускается сразу.
- Голосовой ввод корректно работает в двух режимах: вставка текста и авто-отправка после распознавания.
- Ожидаемый результат:
- Профиль выглядит чище, без лишних статусов и ручной перезагрузки.
- В личных сообщениях управление сообщениями и голосовым вводом работает стабильно и предсказуемо.
- Статус:
- `pending`

View File

@ -1,28 +0,0 @@
# DM: Ctrl+Enter, автоскролл и время в списке
- Статус: `pending`
## Что сделано
- Исправлено поведение ввода в чате:
- `Enter` отправляет сообщение;
- `Ctrl+Enter` добавляет перенос строки в поле ввода.
- В списке личных сообщений время последнего сообщения всегда отображается в правой колонке снизу.
- Бейдж непрочитанных сообщений (если есть) отображается над временем, не заменяя его.
- Обновлены стили карточки диалога для компактного и стабильного выравнивания.
## Что проверять
- В чате:
- нажать `Ctrl+Enter` в середине текста и убедиться, что вставляется новая строка;
- нажать `Enter` и убедиться, что сообщение отправляется.
- В списке диалогов:
- при `unread=0` справа снизу показывается время;
- при `unread>0` сверху бейдж, снизу всё равно показывается время;
- длинный текст последнего сообщения обрезается многоточием и не наезжает на время.
## Ожидаемый результат
- Управление вводом работает как в постановке.
- Время в карточке диалога не исчезает при наличии непрочитанных сообщений.
- Верстка карточки остаётся компактной и без сдвигов.

View File

@ -1,24 +0,0 @@
# Личные сообщения: правая мета-колонка и Enter/Ctrl+Enter
- Краткое описание:
- В списке `Личные сообщения` обновлена правая колонка карточки диалога:
- сверху отображается бейдж количества непрочитанных (если есть);
- снизу маленьким шрифтом отображается дата/время последнего сообщения;
- если сообщений нет, вместо времени отображается `-`.
- В экране чата нижний блок ввода закреплён (sticky) и остаётся на месте при прокрутке.
- В поле ввода чата изменено поведение клавиш:
- `Enter` отправляет сообщение;
- `Ctrl+Enter` добавляет перенос строки и не отправляет сообщение.
- Что проверять:
- В карточках диалогов справа корректно показываются непрочитанные/время/прочерк.
- В чате нижний блок ввода не уезжает при прокрутке истории.
- `Enter` отправляет сообщение из textarea.
- `Ctrl+Enter` вставляет новую строку в textarea.
- Ожидаемый результат:
- Список диалогов показывает полезную мета-информацию в стабильном формате.
- Ввод сообщений в чате работает в привычной схеме Enter/многострочность.
- Статус:
- `pending`

View File

@ -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 работают как ожидается.

View File

@ -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 с пустым текстом, без физического удаления блока.

View File

@ -1,23 +0,0 @@
# Каналы: убрать кнопку тред, две вкладки, и автоскролл DM
Статус: `pending`
## Краткое описание
- В карточках сообщений канала и треда убрана кнопка `Тред` (`#`).
- В `channels-list` оставлены только две вкладки: `Каналы` и `Мои каналы` (вкладка `Чаты` удалена).
- Удалённые сообщения в канале и треде отображаются с красной пометкой `Сообщение удалено`.
- В личных сообщениях усилен автоскролл вниз: при открытии чата и после отправки нового сообщения.
## Что проверять
1. Открыть канал, проверить, что в действиях сообщения нет кнопки `Тред`.
2. Открыть тред, проверить, что в действиях сообщения также нет кнопки `Тред`.
3. В списке каналов сверху проверить только 2 вкладки: `Каналы` и `Мои каналы`.
4. Удалить сообщение edit-ом в канале и в треде, убедиться, что видно красную пометку `Сообщение удалено`.
5. Открыть любой личный чат, убедиться, что экран сразу внизу ленты.
6. Отправить новое сообщение в личке, убедиться, что лента остаётся прокрученной вниз и сообщение видно сразу.
## Ожидаемый результат
- Лишняя кнопка `Тред` отсутствует.
- Вкладка `Чаты` отсутствует, навигация в списке каналов состоит из 2 вкладок.
- Удалённые сообщения визуально выделены красным в канале и в треде.
- В DM нет ручной прокрутки после входа в чат и после отправки новых сообщений.

View File

@ -1,15 +0,0 @@
## Краткое описание
Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений.
## Что проверять
1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан).
2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан).
3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан).
4. Если аватар не задан или недоступен, корректно остаётся fallback (буква).
5. Форма и размер остаются круглыми и визуально не ломают карточки.
## Ожидаемый результат
Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI.
## Статус
`pending`

View File

@ -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`

View File

@ -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`

View File

@ -1,221 +0,0 @@
# Личные сообщения (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`

View File

@ -1,42 +0,0 @@
# TODO: доработка персональных сообщений для агентов
Статус: отложено.
## Что хотели сделать
Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы:
- отличать сообщения человеку от сообщений агенту;
- отличать сообщения от человека и от агента;
- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`);
- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина.
## Базовая идея формата (черновик)
Пример префикса:
```text
@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"}
Текст сообщения...
```
Пример ответа агента:
```text
@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"}
Ответ агента...
```
## Почему отложено
- нужно отдельно согласовать финальный формат инструкции;
- нужно определить строгие правила UI-фильтрации и fallback;
- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий.
## Что сделать при возвращении к задаче
1. Зафиксировать окончательный формат префикса и JSON-полей.
2. Описать правила парсинга/валидации (включая битые/неполные префиксы).
3. Добавить UI-логику показа/скрытия agent-сообщений.
4. Добавить маркировку «ответ агента» в диалоге.
5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина.

View File

@ -1,38 +0,0 @@
# Деплой SHiNE (шаблон)
Этот раздел хранит актуальные инструкции по деплою.
## Базовый сервер
- SSH: `player@45.136.124.227`
- Домен: `shineup.me`
- Базовый путь: `/home/player`
## Локальные команды
- Деплой сервера: `./gradlew deployServer`
- Деплой UI: `./gradlew deployUI`
- Локальный запуск: `./gradlew startLocal`
## UI-деплой и Caddy (обязательно)
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
- `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`.
- В `deploy_shine-PWA.sh` добавлена проверка: если `root` в `Caddyfile` не совпадает, деплой прерывается с ошибкой.
- Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`.
- При необходимости можно явно переопределить путь деплоя:
- `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI`
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
### Важно для локального UI (history-router / Ctrl+F5)
- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`.
- Эта задача запускает `scripts/local_spa_server.py`, который делает SPA fallback: любой неизвестный путь (`/m/...`, `/channel/...`) возвращает `index.html`.
- Это обязательно для корректной работы `Ctrl+F5` на внутренних роутов без `404`.
- Рабочий URL выводится задачей в консоль в формате: `http://localhost:<WEB_PORT>/?localWsPort=<WS_PORT>`.
## Обязательные правила
1. Перед серверным деплоем проверить локально.
2. При нестандартном деплое (другой хост, другая структура, ручные шаги) обязательно уточнить у пользователя, нужно ли обновить этот шаблон.
3. Если деплой-процесс изменился, этот файл и файлы в `servers/` обновлять в том же коммите.

View File

@ -1,23 +0,0 @@
# Сервер `45.136.124.227` (`shineup.me`) — основной
- Пользователь: `player`
- Базовый путь: `/home/player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI публикация: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/shine-server/data/`
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Caddy
- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile`
- Для UI:
- `root * /home/player/SHiNE/shine-ui`
- `try_files {path} /index.html` (SPA fallback)
- no-cache заголовки
- `reverse_proxy /ws* -> 127.0.0.1:7070`

View File

@ -1,29 +0,0 @@
# Сервер `93.170.12.154` — резервный
- Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI`
- UI публикация для Caddy: `/var/www/shine-ui`
- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar`
- Данные: `/home/player/SHiNE/SHiNE-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Статус
- Резервный сервер для SHiNE.
- Основной прод-сервер: `45.136.124.227` (`shineup.me`).
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Настройки:
- `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback);
- `reverse_proxy /ws* -> 127.0.0.1:7070`.

View File

@ -1,2 +1,2 @@
client.version=1.2.80 client.version=1.2.57
server.version=1.2.74 server.version=1.2.51

View File

@ -182,21 +182,66 @@ tasks.register('deployServer', JavaExec) {
// можно переопределить при запуске: // можно переопределить при запуске:
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=... // ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
dependsOn shadowJar dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227") systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player") systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server") systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
systemProperty "it.service", System.getProperty("it.service", "shine-server") systemProperty "it.service", System.getProperty("it.service", "shine-server")
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar") systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
dependsOn testClasses dependsOn testClasses
} }
tasks.register('deployUI', Exec) { tasks.register('deployServerWithBackupCleanAndTests') {
group = "!!deployment" group = "!!deployment"
description = "Deploy WEB UI (production: shineup.me)" description = "BLOCKED: удаление БД на проде запрещено, используйте только миграции"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath doLast {
def msg = """
[BLOCKED] Удаление базы данных на продакшен-сервере отключено.
Причина: в базе уже есть пользовательские сообщения.
Дальше используйте только миграции схемы БД.
Задача остановлена намеренно.
""".stripIndent().trim()
println msg
throw new GradleException(msg)
} }
}
tasks.register('deployServerNoCleanNoTests', JavaExec) {
group = "!!deployment"
description = "Build → upload to server → restart service (no data clean, no IT tests)"
classpath = sourceSets.test.runtimeClasspath
mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain"
// можно переопределить при запуске:
// ./gradlew deployServerNoCleanNoTests -Dit.remoteHost=...
dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
systemProperty "it.service", System.getProperty("it.service", "shine-server")
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
dependsOn testClasses
}
def registerWebDeployTask = { String taskName, String target, String descriptionText ->
tasks.register(taskName, Exec) {
group = "!!deployment"
description = descriptionText
workingDir = rootDir
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath, target
}
}
registerWebDeployTask('deployWEB_Production', 'prod', 'Deploy WEB (production: shineup.me)')
registerWebDeployTask('deployWEB_ui_1', 'ui_1', 'Deploy WEB (ui-1.shineup.me)')
registerWebDeployTask('deployWEB_ui_2', 'ui_2', 'Deploy WEB (ui-2.shineup.me)')
registerWebDeployTask('deployWEB_ui_3', 'ui_3', 'Deploy WEB (ui-3.shineup.me)')
registerWebDeployTask('deployWEB_DrygMira', 'ui_drygmira', 'Deploy WEB (ui-drygmira.shineup.me)')
registerWebDeployTask('deployWEB_Milana', 'ui_milana', 'Deploy WEB (ui-milana.shineup.me)')
registerWebDeployTask('deployWEB_Aidar', 'ui_aidar', 'Deploy WEB (ui-aidar.shineup.me)')
tasks.register('startLocal', Exec) { tasks.register('startLocal', Exec) {
group = "!!run" group = "!!run"
@ -258,11 +303,10 @@ tasks.register('startLocal', Exec) {
echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL" echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL"
fi fi
SPA_SERVER_SCRIPT="${file('scripts/local_spa_server.py').absolutePath}"
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
SHINE_UI_PORT="\$WEB_PORT" python3 "\$SPA_SERVER_SCRIPT" (cd "\$UI_DIR" && python3 -m http.server "\$WEB_PORT")
else else
SHINE_UI_PORT="\$WEB_PORT" python "\$SPA_SERVER_SCRIPT" (cd "\$UI_DIR" && python -m http.server "\$WEB_PORT")
fi fi
""" """
} }

View File

@ -2,14 +2,13 @@
set -euo pipefail set -euo pipefail
SRC_DIR="shine-UI" SRC_DIR="shine-UI"
REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}" REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" REMOTE_BASE_DIR="${REMOTE_BASE_DIR:-/home/player/SHiNE}"
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)" BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
VERSION_FILE="VERSION.properties" VERSION_FILE="VERSION.properties"
export BUILD_VERSION export BUILD_VERSION
TMP_DIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
TARGET="${1:-prod}"
if [[ ! -f "$VERSION_FILE" ]]; then if [[ ! -f "$VERSION_FILE" ]]; then
echo "ERROR: version file not found: $VERSION_FILE" >&2 echo "ERROR: version file not found: $VERSION_FILE" >&2
@ -23,8 +22,44 @@ if [[ -z "$CLIENT_VERSION" ]]; then
fi fi
export CLIENT_VERSION export CLIENT_VERSION
TARGET_DIR="shine-UI"
TARGET_URL="https://shineup.me" TARGET_URL="https://shineup.me"
REMOTE_DIR="${REMOTE_UI_DIR}" case "$TARGET" in
prod|production|main|shineup|shineup.me|shine-UI)
TARGET_DIR="shine-UI"
TARGET_URL="https://shineup.me"
;;
ui_1|ui-1|1|shine-UI_1)
TARGET_DIR="test-UI/shine-UI_1"
TARGET_URL="https://ui-1.shineup.me"
;;
ui_2|ui-2|2|shine-UI_2)
TARGET_DIR="test-UI/shine-UI_2"
TARGET_URL="https://ui-2.shineup.me"
;;
ui_3|ui-3|3|shine-UI_3)
TARGET_DIR="test-UI/shine-UI_3"
TARGET_URL="https://ui-3.shineup.me"
;;
ui_drygmira|ui-drygmira|drygmira|shine-UI_drygmira)
TARGET_DIR="test-UI/shine-UI_drygmira"
TARGET_URL="https://ui-drygmira.shineup.me"
;;
ui_milana|ui-milana|milana|shine-UI_milana)
TARGET_DIR="test-UI/shine-UI_milana"
TARGET_URL="https://ui-milana.shineup.me"
;;
ui_aidar|ui-aidar|aidar|shine-UI_aidar)
TARGET_DIR="test-UI/shine-UI_aidar"
TARGET_URL="https://ui-aidar.shineup.me"
;;
*)
echo "ERROR: unknown target '$TARGET'" >&2
echo "Available targets: prod, ui_1, ui_2, ui_3, ui_drygmira, ui_milana, ui_aidar" >&2
exit 1
;;
esac
REMOTE_DIR="${REMOTE_BASE_DIR}/${TARGET_DIR}"
cleanup() { cleanup() {
rm -rf "$TMP_DIR" rm -rf "$TMP_DIR"
@ -38,7 +73,7 @@ fi
echo "==> Preparing staged UI copy with build version: $BUILD_VERSION" echo "==> Preparing staged UI copy with build version: $BUILD_VERSION"
echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION" echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION"
echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)" echo "==> Deploy target: $TARGET_URL ($TARGET_DIR)"
rsync -a "$SRC_DIR"/ "$TMP_DIR"/ rsync -a "$SRC_DIR"/ "$TMP_DIR"/
INDEX_FILE="$TMP_DIR/index.html" INDEX_FILE="$TMP_DIR/index.html"
@ -53,30 +88,10 @@ perl -0pi -e 's/window\.__SHINE_CLIENT_VERSION__\s*=\s*'\''[^'\'']*'\'';/window.
echo "==> Checking SSH connectivity to $REMOTE_HOST" echo "==> Checking SSH connectivity to $REMOTE_HOST"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null
echo "==> Validating Caddy UI root"
CADDY_CONFIG_PATH="$(ssh "$REMOTE_HOST" "set -e; \
exec_line=\$(systemctl show -p ExecStart caddy --value 2>/dev/null || true); \
cfg=\$(printf '%s' \"\$exec_line\" | sed -n 's/.*--config \\([^ ]*\\).*/\\1/p' | head -n 1); \
if [ -z \"\$cfg\" ]; then cfg=/etc/caddy/Caddyfile; fi; \
printf '%s' \"\$cfg\"")"
CADDY_ROOT_LINE="$(ssh "$REMOTE_HOST" "sudo grep -n 'root \\* ' '$CADDY_CONFIG_PATH' | head -n 1 || true")"
if [[ -n "$CADDY_ROOT_LINE" && "$CADDY_ROOT_LINE" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then
echo "ERROR: Caddy root mismatch. Found: $CADDY_ROOT_LINE" >&2
echo "Caddy config: $CADDY_CONFIG_PATH" >&2
echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then
echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2
exit 1
fi
echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1"
fi
echo "==> Preparing remote directory: $REMOTE_DIR" echo "==> Preparing remote directory: $REMOTE_DIR"
ssh "$REMOTE_HOST" "sudo mkdir -p '$REMOTE_DIR'" ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
echo "==> Syncing staged files to $REMOTE_DIR" echo "==> Syncing staged files to $REMOTE_DIR"
rsync -rlvz --delete --omit-dir-times --no-perms --no-owner --no-group \ rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
--rsync-path="sudo rsync" \
"$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
echo "Всё хорошо: $TARGET_URL" echo "Всё хорошо: $TARGET_URL"

View File

@ -1,19 +1,38 @@
# UI deploy # Деплой UI по окружениям (Caddy sites)
Актуальный UI-деплой выполняется одной командой: ## Куда деплоит скрипт
- Базовая директория на сервере: `/home/user/docker/caddyFile/sites`
- По умолчанию деплой идёт на production (`shineup.me`) в папку `shine-UI`.
```bash ## Gradle-команды
./gradlew deployUI - Продакшен (`shineup.me`): `./gradlew deployWEB_Production`
``` - `ui-1.shineup.me`: `./gradlew deployWEB_ui_1`
- `ui-2.shineup.me`: `./gradlew deployWEB_ui_2`
- `ui-3.shineup.me`: `./gradlew deployWEB_ui_3`
- `ui-drygmira.shineup.me`: `./gradlew deployWEB_DrygMira`
- `ui-milana.shineup.me`: `./gradlew deployWEB_Milana`
- `ui-aidar.shineup.me`: `./gradlew deployWEB_Aidar`
По умолчанию: ## Прямой запуск скрипта
- `bash deploy_shine-PWA.sh prod`
- `bash deploy_shine-PWA.sh ui_1`
- `bash deploy_shine-PWA.sh ui_2`
- `bash deploy_shine-PWA.sh ui_3`
- `bash deploy_shine-PWA.sh ui_drygmira`
- `bash deploy_shine-PWA.sh ui_milana`
- `bash deploy_shine-PWA.sh ui_aidar`
- хост: `player@93.170.12.154` Также поддерживаются алиасы с дефисом:
- домен: `https://shineup.me` - `bash deploy_shine-PWA.sh ui-1`
- путь: `/home/player/SHiNE/SHiNE-UI` - `bash deploy_shine-PWA.sh ui-2`
- `bash deploy_shine-PWA.sh ui-3`
- `bash deploy_shine-PWA.sh ui-drygmira`
- `bash deploy_shine-PWA.sh ui-milana`
- `bash deploy_shine-PWA.sh ui-aidar`
Переопределение при необходимости: ## Поддержка переопределения
- `REMOTE_HOST` (по умолчанию `user@194.87.0.247`)
- `REMOTE_BASE_DIR` (по умолчанию `/home/user/docker/caddyFile/sites`)
```bash Пример:
REMOTE_HOST=player@93.170.12.154 REMOTE_BASE_DIR=/home/player/SHiNE bash deploy_shine-PWA.sh `REMOTE_HOST=user@194.87.0.247 REMOTE_BASE_DIR=/home/user/docker/caddyFile/sites bash deploy_shine-PWA.sh ui_2`
```

View File

@ -1,33 +0,0 @@
#!/usr/bin/env python3
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import os
ROOT = Path(__file__).resolve().parents[1] / "shine-UI"
PORT = int(os.environ.get("SHINE_UI_PORT", "8088"))
class SpaHandler(SimpleHTTPRequestHandler):
def translate_path(self, path):
translated = super().translate_path(path)
rel = Path(translated).relative_to(Path.cwd())
return str(ROOT / rel)
def do_GET(self):
file_path = Path(self.translate_path(self.path.split("?", 1)[0]))
if file_path.exists() and file_path.is_file():
return super().do_GET()
self.path = "/index.html"
return super().do_GET()
def main():
os.chdir(ROOT)
server = ThreadingHTTPServer(("0.0.0.0", PORT), SpaHandler)
print(f"SHiNE SPA server: http://localhost:{PORT}")
server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -101,7 +101,7 @@ const routes = {
'messages-list': messagesList, 'messages-list': messagesList,
'contact-search-view': contactSearchView, 'contact-search-view': contactSearchView,
'chat-view': chatView, 'chat-view': chatView,
user: userProfileView, 'user-profile-view': userProfileView,
'channels-list': channelsList, 'channels-list': channelsList,
'channel-view': channelView, 'channel-view': channelView,
'channel-thread-view': channelThreadView, 'channel-thread-view': channelThreadView,
@ -136,16 +136,6 @@ let pwaUpdateCheckAttempted = false;
let uiVersionCheckInFlight = false; let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null; let uiVersionPeriodicIntervalId = null;
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
const GUEST_ALLOWED_PAGES = new Set([
'start-view',
'entry-settings-view',
'network-view',
'channels-list',
'channel-view',
'channel-thread-view',
'user',
'contact-search-view',
]);
setClientErrorTransport((payload) => authService.reportClientUiError(payload)); setClientErrorTransport((payload) => authService.reportClientUiError(payload));
setClientErrorSentNotifier((payload) => { setClientErrorSentNotifier((payload) => {
@ -299,7 +289,7 @@ function consumeCallPushActionFromUrlIfAny() {
params.delete('callPushAction'); params.delete('callPushAction');
params.delete('callPushPayload'); params.delete('callPushPayload');
const nextQuery = params.toString(); const nextQuery = params.toString();
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`; const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`;
window.history.replaceState({}, '', nextUrl); window.history.replaceState({}, '', nextUrl);
} catch { } catch {
// ignore URL parsing errors // ignore URL parsing errors
@ -644,7 +634,7 @@ function renderPageFailureFallback(pageId, error) {
stack: error?.stack || '', stack: error?.stack || '',
context: { context: {
pageId, pageId,
routeHash: window.location.pathname || '', routeHash: window.location.hash || '',
}, },
}); });
@ -681,7 +671,7 @@ function renderApp() {
const route = getRoute(); const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) { if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
navigate('start-view'); navigate('start-view');
return; return;
} }
@ -1035,26 +1025,19 @@ async function init() {
} }
}); });
// Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"),
// а сетевые/авторизационные шаги выполняем фоном.
if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
}
renderApp();
void (async () => {
try {
await tryAutoLogin(); await tryAutoLogin();
await hydrateMessagesFromStore(); await hydrateMessagesFromStore();
startConnectionMonitor(); startConnectionMonitor();
startPeriodicUiVersionCheck(); startPeriodicUiVersionCheck();
await ensureSessionRuntimeStarted(); await ensureSessionRuntimeStarted();
} finally {
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
} else {
renderApp(); renderApp();
} }
})();
window.addEventListener('popstate', renderApp); window.addEventListener('hashchange', renderApp);
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return; if (document.visibilityState !== 'visible') return;
void checkConnectionHealth(); void checkConnectionHealth();

View File

@ -19,39 +19,35 @@ function showSttMissingConfigDialog(navigate) {
if (goSettings) navigate('tools-settings-view'); if (goSettings) navigate('tools-settings-view');
} }
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) { export async function openSpeechInputModal({ navigate, onTextReady }) {
if (!isSpeechToTextConfigured(state.entrySettings)) { if (!isSpeechToTextConfigured(state.entrySettings)) {
showSttMissingConfigDialog(navigate); showSttMissingConfigDialog(navigate);
return; return;
} }
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
const host = document.createElement('div'); root.innerHTML = `
host.innerHTML = ` <div class="modal" id="speech-input-modal">
<div class="modal" id="speech-input-modal-layer">
<div class="modal-card stack"> <div class="modal-card stack">
<h3 class="modal-title">Голосовой ввод</h3> <h3 class="modal-title">Голосовой ввод</h3>
<p class="meta-muted" id="speech-input-status">Идёт запись...</p> <p class="meta-muted" id="speech-input-status">Идёт запись...</p>
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div> <div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
<p class="meta-muted" id="speech-input-time">00:00</p> <p class="meta-muted" id="speech-input-time">00:00</p>
<p class="inline-error" id="speech-input-error"></p> <p class="inline-error" id="speech-input-error"></p>
<div class="speech-actions-top"> <div class="form-actions-grid">
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button> <button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
<button class="primary-btn" type="button" id="speech-ok">OK</button> <button class="primary-btn" type="button" id="speech-ok">OK</button>
</div> </div>
<button class="primary-btn speech-send-now-btn" type="button" id="speech-send-now">Распознать и сразу отправить сообщение</button>
</div> </div>
</div> </div>
`; `;
root.append(host);
const statusEl = host.querySelector('#speech-input-status'); const statusEl = root.querySelector('#speech-input-status');
const timeEl = host.querySelector('#speech-input-time'); const timeEl = root.querySelector('#speech-input-time');
const levelEl = host.querySelector('#speech-level-fill'); const levelEl = root.querySelector('#speech-level-fill');
const errorEl = host.querySelector('#speech-input-error'); const errorEl = root.querySelector('#speech-input-error');
const cancelBtn = host.querySelector('#speech-cancel'); const cancelBtn = root.querySelector('#speech-cancel');
const sendNowBtn = host.querySelector('#speech-send-now'); const okBtn = root.querySelector('#speech-ok');
const okBtn = host.querySelector('#speech-ok');
const recorder = createMicrophoneRecorder(); const recorder = createMicrophoneRecorder();
let closed = false; let closed = false;
let busy = false; let busy = false;
@ -59,16 +55,14 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
const close = () => { const close = () => {
if (closed) return; if (closed) return;
closed = true; closed = true;
host.remove(); root.innerHTML = '';
}; };
const setBusy = (flag) => { const setBusy = (flag) => {
busy = !!flag; busy = !!flag;
cancelBtn.disabled = busy; cancelBtn.disabled = busy;
sendNowBtn.disabled = busy;
okBtn.disabled = busy; okBtn.disabled = busy;
okBtn.textContent = busy ? 'Распознаю...' : 'OK'; okBtn.textContent = busy ? 'Распознаю...' : 'OK';
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
}; };
try { try {
@ -90,16 +84,10 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
okBtn.addEventListener('click', async () => { okBtn.addEventListener('click', async () => {
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
errorEl.textContent = '';
statusEl.textContent = 'Распознаю речь...';
try { try {
const audioBlob = await recorder.stop(); const audioBlob = await recorder.stop();
host.innerHTML = `
<div class="modal" id="speech-input-modal-layer">
<div class="modal-card stack">
<h3 class="modal-title">Голосовой ввод</h3>
<p class="meta-muted">Идёт распознавание текста...</p>
</div>
</div>
`;
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings); const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
if (typeof onTextReady === 'function') onTextReady(text); if (typeof onTextReady === 'function') onTextReady(text);
close(); close();
@ -109,24 +97,4 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`; errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
} }
}); });
sendNowBtn.addEventListener('click', async () => {
if (busy) return;
setBusy(true);
try {
const audioBlob = await recorder.stop();
close();
if (typeof onSendQueued === 'function') onSendQueued();
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
if (typeof onSendText === 'function') {
await onSendText(text);
} else if (typeof onTextReady === 'function') {
onTextReady(text);
}
} catch (error) {
setBusy(false);
close();
window.alert(`Ошибка распознавания: ${error?.message || 'unknown'}`);
}
});
} }

View File

@ -1,6 +1,5 @@
import { resolveToolbarActive } from '../router.js'; import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const ITEMS = [ const ITEMS = [
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' }, { pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
@ -28,35 +27,6 @@ function getTotalUnreadMessages() {
return total; return total;
} }
function navigateWithGuestRules(pageId, navigate) {
if (state.session.isAuthorized) {
navigate(pageId);
return;
}
if (pageId === 'messages-list') {
openAuthRequiredModal({
title: 'Личные сообщения недоступны',
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
});
return;
}
if (pageId === 'profile-view') {
openAuthRequiredModal({
title: 'Профиль недоступен',
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
});
return;
}
if (pageId === 'notifications-view') {
openAuthRequiredModal({
title: 'Уведомления недоступны',
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
});
return;
}
navigate(pageId);
}
export function renderToolbar(currentPageId, navigate) { export function renderToolbar(currentPageId, navigate) {
const root = document.createElement('nav'); const root = document.createElement('nav');
root.className = 'toolbar'; root.className = 'toolbar';
@ -93,7 +63,7 @@ export function renderToolbar(currentPageId, navigate) {
if (item.pageId === 'channels-list') { if (item.pageId === 'channels-list') {
installChannelsHoldSwitcher(btn, navigate); installChannelsHoldSwitcher(btn, navigate);
} else { } else {
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate)); btn.addEventListener('click', () => navigate(item.pageId));
} }
root.append(btn); root.append(btn);
}); });
@ -106,7 +76,7 @@ function installChannelsHoldSwitcher(button, navigate) {
let pressed = false; let pressed = false;
let holdActive = false; let holdActive = false;
let overlay = null; let overlay = null;
let selectedMode = 'feed'; let selectedMode = 'dialogs';
const clearTimer = () => { const clearTimer = () => {
if (holdTimer) { if (holdTimer) {
@ -150,7 +120,7 @@ function installChannelsHoldSwitcher(button, navigate) {
button.addEventListener('pointerdown', (event) => { button.addEventListener('pointerdown', (event) => {
pressed = true; pressed = true;
holdActive = false; holdActive = false;
selectedMode = 'feed'; selectedMode = 'dialogs';
clearTimer(); clearTimer();
holdTimer = window.setTimeout(() => { holdTimer = window.setTimeout(() => {
if (!pressed) return; if (!pressed) return;
@ -173,7 +143,7 @@ function installChannelsHoldSwitcher(button, navigate) {
navigate(`channels-list/${mode}`); navigate(`channels-list/${mode}`);
return; return;
} }
navigate('channels-list/feed'); navigate('channels-list/dialogs');
}); });
button.addEventListener('pointercancel', () => { button.addEventListener('pointercancel', () => {
@ -186,4 +156,3 @@ function installChannelsHoldSwitcher(button, navigate) {
event.preventDefault(); event.preventDefault();
}); });
} }

View File

@ -11,67 +11,11 @@ import {
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const pendingReactionActions = new Set(); const pendingReactionActions = new Set();
const pendingThreadScroll = new Map(); const pendingThreadScroll = new Map();
const threadAvatarSnapshotCache = new Map();
const threadAvatarPendingByLogin = new Map();
async function loadThreadAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key);
if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
threadAvatarSnapshotCache.set(key, snapshot || null);
threadAvatarPendingByLogin.delete(key);
return snapshot || null;
})
.catch(() => {
threadAvatarSnapshotCache.set(key, null);
threadAvatarPendingByLogin.delete(key);
return null;
});
threadAvatarPendingByLogin.set(key, pending);
return pending;
}
function createThreadAvatar(login) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
className: 'channel-message-avatar',
title,
});
if (!cleanLogin) return avatarEl;
void loadThreadAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'channel-message-avatar',
title,
});
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
function logThreadRuntimeError(stage, error, context = {}) { function logThreadRuntimeError(stage, error, context = {}) {
const message = String(error?.message || error || 'thread runtime error'); const message = String(error?.message || error || 'thread runtime error');
@ -105,11 +49,6 @@ function toSafeInt(value) {
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }
function looksLikeBlockchainName(value) {
const raw = String(value || '').trim();
return /^[^-]+-\d+$/.test(raw);
}
function makeReactionActionKey(messageRef) { function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase(); const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim(); const blockchainName = String(messageRef?.blockchainName || '').trim();
@ -130,8 +69,7 @@ function messageRefKey(messageRef) {
function buildAbsoluteRouteUrl(routePath = '') { function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.pathname = `/${cleanRoute}`; url.hash = `#/${cleanRoute}`;
url.hash = '';
return url.toString(); return url.toString();
} }
@ -150,8 +88,8 @@ function parseThreadSelector(route) {
}, },
channel: { channel: {
ownerBlockchainName: '', ownerBlockchainName: '',
channelRootBlockNumber: null, rootBlockNumber: null,
channelRootBlockHash: '0', rootBlockHash: '0',
}, },
}; };
} }
@ -166,8 +104,8 @@ function parseThreadSelector(route) {
}, },
channel: { channel: {
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''), ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber), rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash), rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
}, },
}; };
} }
@ -182,12 +120,10 @@ function allFeedSummaries() {
} }
function resolveChannelDisplayName(channelSelector) { function resolveChannelDisplayName(channelSelector) {
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber; if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
if (!channelSelector?.ownerBlockchainName || rootNumber == null) return '';
const ownerBch = String(channelSelector.ownerBlockchainName); const ownerBch = String(channelSelector.ownerBlockchainName);
const rootNo = Number(rootNumber); const rootNo = Number(channelSelector.rootBlockNumber);
const rootHash = normalizeRouteHash(rootHashRaw); const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
const found = allFeedSummaries().find((summary) => ( const found = allFeedSummaries().find((summary) => (
String(summary?.channel?.ownerBlockchainName || '') === ownerBch String(summary?.channel?.ownerBlockchainName || '') === ownerBch
@ -198,81 +134,37 @@ function resolveChannelDisplayName(channelSelector) {
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`; return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
} }
function extractChannelContextFromThreadPayload(payload) { function buildBackRoute(selector) {
const focusInfo = payload?.focus?.channelInfo; if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) { return [
return { 'channel',
ownerBlockchainName: String(focusInfo.ownerBlockchainName || '').trim(), encodeRoutePart(selector.short.ownerBlockchainName),
channelRootBlockNumber: Number(focusInfo.channelRoot.blockNumber), encodeRoutePart(selector.short.channelName),
channelRootBlockHash: '0', ].join('/');
};
}
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
for (let i = ancestors.length - 1; i >= 0; i -= 1) {
const info = ancestors[i]?.channelInfo;
if (info?.ownerBlockchainName && info?.channelRoot?.blockNumber != null) {
return {
ownerBlockchainName: String(info.ownerBlockchainName || '').trim(),
channelRootBlockNumber: Number(info.channelRoot.blockNumber),
channelRootBlockHash: '0',
};
}
}
return null;
}
async function resolveChannelDisplayNameFromServer(channelSelector) {
const ownerBch = String(channelSelector?.ownerBlockchainName || '').trim();
const rootNo = Number(channelSelector?.channelRootBlockNumber);
if (!ownerBch || !Number.isFinite(rootNo) || rootNo < 0) return '';
const ownerLogin = extractLoginFromBlockchainName(ownerBch);
if (!ownerLogin) return '';
try {
const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
const row = rows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch.toLowerCase()
&& Number(item?.channel?.channelRoot?.blockNumber) === rootNo
));
if (!row?.channel?.channelName) return '';
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
} catch {
return '';
} }
return 'channels-list';
} }
function buildThreadRouteFromTarget(target, selector) { function buildThreadRouteFromTarget(target, selector) {
if (!target) return ''; if (!target) return '';
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
return makeShineMessageRoute({
ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName),
messageBlockchainName: target.blockchainName,
messageBlockNumber: target.blockNumber,
});
}
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) { if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
return makeShineChannelRoute({ return [
ownerLogin: extractLoginFromBlockchainName(ownerBch), 'channel',
ownerBlockchainName: ownerBch, encodeRoutePart(selector.short.ownerBlockchainName),
channelName: selector.short.channelName, encodeRoutePart(selector.short.channelName),
}); target.blockNumber,
].join('/');
} }
const label = String(resolvedChannelLabel || '').trim(); if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
const slashIndex = label.indexOf('/'); return [
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : ''; 'channel-thread-view',
return makeShineChannelRoute({ encodeRoutePart(target.blockchainName),
ownerLogin: extractLoginFromBlockchainName(ownerBch), target.blockNumber,
ownerBlockchainName: ownerBch, normalizeRouteHash(target.blockHash),
channelName, encodeRoutePart(selector.channel.ownerBlockchainName),
}); selector.channel.rootBlockNumber,
normalizeRouteHash(selector.channel.rootBlockHash),
].join('/');
} }
function buildTargetFromNode(node) { function buildTargetFromNode(node) {
@ -293,11 +185,12 @@ function firstNonEmptyText(...candidates) {
} }
function latestVersionText(versions) { function latestVersionText(versions) {
if (!Array.isArray(versions) || !versions.length) return ''; if (!Array.isArray(versions)) return '';
const version = versions[versions.length - 1]; for (let i = versions.length - 1; i >= 0; i -= 1) {
if (typeof version?.text === 'string') return version.text; const version = versions[i];
if (typeof version?.message === 'string') return version.message; const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (typeof version?.body === 'string') return version.body; if (value) return value;
}
return ''; return '';
} }
@ -379,104 +272,16 @@ 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';
card.classList.add('is-counters-visible');
const author = node?.authorLogin || 'автор'; const author = node?.authorLogin || 'автор';
const versions = Array.isArray(node?.versions) ? node.versions : []; const text = resolveNodeText(node) || '(пусто)';
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 versions = Number(node?.versionsTotal || 1);
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0; const changes = Math.max(0, versions - 1);
const headingText = String(heading || '').trim(); const headingText = String(heading || '').trim();
if (headingText) { if (headingText) {
@ -486,51 +291,18 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.append(headingEl); card.append(headingEl);
} }
const authorTile = document.createElement('button'); const meta = document.createElement('p');
authorTile.type = 'button'; meta.className = 'thread-node-meta';
authorTile.className = 'channel-message-author-tile'; meta.innerHTML = `
<span class="author-line-login">${author}</span>
<span class="author-line-num">· #${localNumber}</span>
`;
const avatar = createThreadAvatar(author);
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = author;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${localNumber}`;
title.append(loginEl, numberEl);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openMessageHistoryModal({
title: `История #${localNumber}`,
versions,
});
});
title.append(editedMarker);
}
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
authorBlock.append(title, timestamp);
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${isDeletedMessage ? ' channel-message-body--deleted' : ''}`; body.className = 'thread-node-body';
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text; body.textContent = text;
card.append(authorTile, body); card.append(meta, body);
const target = buildTargetFromNode(node); const target = buildTargetFromNode(node);
const refKey = messageRefKey(target); const refKey = messageRefKey(target);
@ -546,20 +318,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const isLiked = getMessageReactionState(target) === 'liked'; const isLiked = getMessageReactionState(target) === 'liked';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'thread-node-actions channel-message-actions'; actions.className = 'thread-node-actions';
const likeButton = document.createElement('button'); const likeButton = document.createElement('button');
likeButton.type = 'button'; likeButton.type = 'button';
likeButton.className = 'channel-action-item thread-like-btn'; likeButton.className = 'secondary-btn thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked'); if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = ` likeButton.textContent = isPending ? `❤️ ${likes}...` : `${isLiked ? '❤️' : '🤍'} ${likes}`;
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
`;
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
if (!isLiked) { if (!isLiked) {
@ -568,6 +335,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
} }
await longPressFeel(event.currentTarget, 130); await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true; likeButton.disabled = true;
likeButton.textContent = `❤️ ${likes}...`;
try { try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like'); await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) { } catch (error) {
@ -582,14 +350,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const replyButton = document.createElement('button'); const replyButton = document.createElement('button');
replyButton.type = 'button'; replyButton.type = 'button';
replyButton.className = 'channel-action-item thread-reply-btn'; replyButton.className = 'secondary-btn thread-reply-btn';
replyButton.innerHTML = ` replyButton.textContent = `💬 ${replies}`;
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openReplyModal({ openReplyModal({
navigate: handlers.navigate, navigate: handlers.navigate,
@ -597,50 +360,25 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}); });
}); });
const changedButton = document.createElement('button');
changedButton.type = 'button';
changedButton.className = 'secondary-btn thread-version-btn';
changedButton.textContent = `✏️ ${changes}`;
changedButton.disabled = true;
changedButton.style.display = changes > 0 ? '' : 'none';
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
shareButton.className = 'channel-action-item thread-share-btn'; shareButton.className = 'secondary-btn thread-share-btn';
shareButton.innerHTML = ` shareButton.textContent = '↗ Отправить';
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
`;
shareButton.addEventListener('click', async (event) => { shareButton.addEventListener('click', async (event) => {
event.stopPropagation(); event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
await handlers.onShare(target); await handlers.onShare(target);
}); });
actions.append(likeButton, replyButton, shareButton); actions.append(likeButton, replyButton, changedButton, shareButton);
if (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(text || '').trim() === 'удалено' ? '' : text,
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
});
});
actions.append(editButton);
}
card.append(actions); card.append(actions);
authorTile.addEventListener('click', (event) => {
event.stopPropagation();
const login = String(node?.authorLogin || '').trim();
if (!login) return;
handlers.navigate(makeProfileRoute(login));
});
card.addEventListener('click', () => {
handlers.onOpenThread(target);
});
return card; return card;
} }
@ -703,6 +441,7 @@ function renderSkeleton(screen) {
export function render({ navigate, route }) { export function render({ navigate, route }) {
const selector = parseThreadSelector(route); const selector = parseThreadSelector(route);
const backRoute = buildBackRoute(selector);
const channelDisplayName = resolveChannelDisplayName(selector?.channel); const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`; const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
@ -711,16 +450,9 @@ export function render({ navigate, route }) {
const appScreen = document.getElementById('app-screen'); const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean'); appScreen?.classList.add('channels-scroll-clean');
const header = renderHeader({ const channelIndicator = document.createElement('div');
title: '', channelIndicator.className = 'card channels-user-chip';
leftAction: { label: '<', onClick: () => navigateBack() }, channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
});
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
if (threadHeaderButton) {
threadHeaderButton.classList.add('channel-header-route-btn');
threadHeaderButton.disabled = true;
}
const statusBox = document.createElement('div'); const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status'; statusBox.className = 'card status-line is-unavailable channels-status';
@ -733,7 +465,7 @@ export function render({ navigate, route }) {
const next = render({ navigate, route }); const next = render({ navigate, route });
current.replaceWith(next); current.replaceWith(next);
} catch (error) { } catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname }); logThreadRuntimeError('rerender', error, { routeHash: window.location.hash });
} }
}; };
@ -751,7 +483,7 @@ export function render({ navigate, route }) {
const login = state.session.login; const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory; const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) { if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels-list'; state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view'); navigate('login-view');
throw new Error('Для этого действия нужно войти'); throw new Error('Для этого действия нужно войти');
} }
@ -813,38 +545,21 @@ export function render({ navigate, route }) {
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.')); showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
} }
}, },
onOpenThread: (target) => {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) {
showStatus('Не удалось определить путь до треда.');
return;
}
navigate(routePath);
},
onActionError: (error, action) => { onActionError: (error, action) => {
const fallback = action === 'unlike' const fallback = action === 'unlike'
? 'Не удалось убрать лайк.' ? 'Не удалось убрать лайк.'
: 'Не удалось поставить лайк.'; : 'Не удалось поставить лайк.';
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(
renderHeader({
title: 'Тред',
leftAction: { label: '<', onClick: () => navigate(backRoute) },
}),
);
screen.append(channelIndicator, statusBox);
if (!selector) { if (!selector) {
const invalid = document.createElement('div'); const invalid = document.createElement('div');
@ -866,46 +581,10 @@ export function render({ navigate, route }) {
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []), ...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []), ...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []),
]; ];
const ownerRaw = String(selector.short.ownerBlockchainName || '').trim(); const channel = allRows.find((item) => (
const ownerNormalized = ownerRaw.toLowerCase(); String(item?.channel?.ownerBlockchainName || '').trim() === selector.short.ownerBlockchainName
const ownerLoginFromBch = extractLoginFromBlockchainName(ownerRaw); && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
const channelNameNormalized = String(selector.short.channelName || '').trim().toLowerCase();
let channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
)); ));
if (!channel) {
channel = allRows.find((item) => (
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
));
}
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
try {
const ownerUser = await authService.getUser(ownerRaw);
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
if (ownerBch) {
channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
));
}
} catch {
// ignore fallback lookup errors
}
}
if (!channel && ownerLoginFromBch) {
try {
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
channel = ownerRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
));
} catch {
// ignore owner feed lookup errors
}
}
const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim(); const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
const rootNo = Number(channel?.channel?.channelRoot?.blockNumber); const rootNo = Number(channel?.channel?.channelRoot?.blockNumber);
const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash); const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash);
@ -914,14 +593,25 @@ export function render({ navigate, route }) {
} }
selector.channel = { selector.channel = {
ownerBlockchainName: ownerBch, ownerBlockchainName: ownerBch,
channelRootBlockNumber: rootNo, rootBlockNumber: rootNo,
channelRootBlockHash: rootHash, rootBlockHash: rootHash,
}; };
let resolvedHash = normalizeMessageHash(resolvedMessage?.blockHash);
if (!resolvedHash) {
const channelPayload = await authService.getChannelMessages(selector.channel, 400, 'asc', state.session.login);
const messages = Array.isArray(channelPayload?.messages) ? channelPayload.messages : [];
const foundMessage = messages.find((item) => Number(item?.messageRef?.blockNumber) === Number(resolvedMessage.blockNumber));
const foundHash = normalizeMessageHash(foundMessage?.messageRef?.blockHash);
if (!foundHash) {
throw new Error('Не удалось определить hash сообщения для открытия треда.');
}
resolvedHash = foundHash;
}
resolvedMessage = { resolvedMessage = {
blockchainName: ownerBch, blockchainName: ownerBch,
blockNumber: resolvedMessage.blockNumber, blockNumber: resolvedMessage.blockNumber,
blockHash: normalizeMessageHash(resolvedMessage?.blockHash), blockHash: resolvedHash,
}; };
} }
@ -932,68 +622,30 @@ export function render({ navigate, route }) {
const focus = payload?.focus || null; const focus = payload?.focus || null;
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : []; const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
const focusHash = normalizeMessageHash(focus?.messageRef?.blockHash);
if (focusHash && selector?.message) {
selector.message.blockHash = focusHash;
}
if ((!selector?.channel?.ownerBlockchainName || selector?.channel?.channelRootBlockNumber == null) && payload) {
const context = extractChannelContextFromThreadPayload(payload);
if (context) {
selector.channel = {
ownerBlockchainName: context.ownerBlockchainName,
channelRootBlockNumber: context.channelRootBlockNumber,
channelRootBlockHash: normalizeRouteHash(context.channelRootBlockHash),
};
}
}
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
}
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
event.preventDefault();
animatePress(event.currentTarget);
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
if (routeToChannel) navigate(routeToChannel);
else navigate('channels-list');
};
}
let seq = 0; let seq = 0;
const nextNumber = () => { const nextNumber = () => {
seq += 1; seq += 1;
return seq; return seq;
}; };
let ancestorsWrap = null;
if (ancestors.length) { if (ancestors.length) {
ancestorsWrap = document.createElement('div'); const ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors'; ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
const title = document.createElement('h3'); const title = document.createElement('h3');
title.className = 'section-title'; title.className = 'section-title';
title.textContent = 'История выше (на что это ответ)'; title.textContent = 'Предыдущие сообщения';
ancestorsWrap.append(title); ancestorsWrap.append(title);
ancestors.forEach((node, index) => { ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber())); ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
}); });
screen.append(ancestorsWrap);
} }
let focusWrap = null;
if (focus) { if (focus) {
focusWrap = document.createElement('div'); const focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus'; focusWrap.className = 'stack thread-block thread-block--focus';
const focusTitle = document.createElement('h3');
focusTitle.className = 'section-title';
focusTitle.textContent = 'Текущее сообщение';
focusWrap.append(focusTitle);
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber())); focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
screen.append(focusWrap);
} }
const descendantsWrap = document.createElement('div'); const descendantsWrap = document.createElement('div');
@ -1012,23 +664,8 @@ export function render({ navigate, route }) {
descendantsWrap.append(empty); descendantsWrap.append(empty);
} }
if (ancestorsWrap) {
screen.append(ancestorsWrap);
const divider = document.createElement('div');
divider.className = 'thread-history-divider';
screen.append(divider);
}
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap); screen.append(descendantsWrap);
applyPendingScroll(screen, routeKey); applyPendingScroll(screen, routeKey);
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
setTimeout(() => {
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
}, 20);
}
} catch (error) { } catch (error) {
skeleton.remove(); skeleton.remove();
const failed = document.createElement('div'); const failed = document.createElement('div');

View File

@ -17,72 +17,12 @@ import {
softHaptic, softHaptic,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { navigateBack } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineMessageRoute,
} from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' }; export const pageMeta = { id: 'channel-view', title: 'Канал' };
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
const pendingReactionActions = new Set(); const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map(); const pendingScrollByRoute = new Map();
const messageAvatarSnapshotCache = new Map();
const messageAvatarPendingByLogin = new Map();
async function loadMessageAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key);
if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
messageAvatarSnapshotCache.set(key, snapshot || null);
messageAvatarPendingByLogin.delete(key);
return snapshot || null;
})
.catch(() => {
messageAvatarSnapshotCache.set(key, null);
messageAvatarPendingByLogin.delete(key);
return null;
});
messageAvatarPendingByLogin.set(key, pending);
return pending;
}
function createMessageAvatar(login) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
className: 'channel-message-avatar',
title,
});
if (!cleanLogin) return avatarEl;
void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'channel-message-avatar',
title,
});
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
function isChannelsDemoMode() { function isChannelsDemoMode() {
try { try {
@ -115,11 +55,6 @@ function toSafeInt(value) {
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }
function looksLikeBlockchainName(value) {
const raw = String(value || '').trim();
return /^[^-]+-\d+$/.test(raw);
}
function makeReactionActionKey(messageRef) { function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase(); const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim(); const blockchainName = String(messageRef?.blockchainName || '').trim();
@ -163,8 +98,7 @@ function blockRefToMessageKey(blockRef, fallbackBch = '') {
function buildAbsoluteRouteUrl(routePath = '') { function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.pathname = `/${cleanRoute}`; url.hash = `#/${cleanRoute}`;
url.hash = '';
return url.toString(); return url.toString();
} }
@ -200,12 +134,25 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) { function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return ''; if (!messageRef || !selector) return '';
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName); const ownerBlockchainName = String(selector.ownerBlockchainName || '').trim();
return makeShineMessageRoute({ const channelName = String(selector.channelName || '').trim();
ownerLogin, if (ownerBlockchainName && channelName) {
messageBlockchainName: messageRef.blockchainName, return [
messageBlockNumber: messageRef.blockNumber, 'channel',
}); encodeRoutePart(ownerBlockchainName),
encodeRoutePart(channelName),
messageRef.blockNumber,
].join('/');
}
return [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
encodeRoutePart(selector.ownerBlockchainName),
selector.channelRootBlockNumber,
normalizeRouteHash(selector.channelRootBlockHash),
].join('/');
} }
function firstNonEmptyText(...candidates) { function firstNonEmptyText(...candidates) {
@ -218,11 +165,12 @@ function firstNonEmptyText(...candidates) {
} }
function latestVersionText(versions) { function latestVersionText(versions) {
if (!Array.isArray(versions) || !versions.length) return ''; if (!Array.isArray(versions)) return '';
const version = versions[versions.length - 1]; for (let i = versions.length - 1; i >= 0; i -= 1) {
if (typeof version?.text === 'string') return version.text; const version = versions[i];
if (typeof version?.message === 'string') return version.message; const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (typeof version?.body === 'string') return version.body; if (value) return value;
}
return ''; return '';
} }
@ -429,92 +377,6 @@ 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);
@ -537,29 +399,20 @@ function mapApiMessageToPost(message, selector, localNumber) {
return { return {
localNumber, localNumber,
authorLogin: message?.authorLogin || 'автор', authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'), body: resolvedText || '(пусто)',
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(),
}; };
} }
async function loadFromApi(route, channelId) { async function loadFromApi(route, channelId) {
const currentSessionLogin = String(state.session.login || '').trim();
const isAuthorized = !!currentSessionLogin;
let cachedFeed = null; let cachedFeed = null;
const ensureFeed = async () => { const ensureFeed = async () => {
if (cachedFeed) return cachedFeed; if (cachedFeed) return cachedFeed;
if (!isAuthorized) { cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
cachedFeed = {};
return cachedFeed;
}
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
return cachedFeed; return cachedFeed;
}; };
const getAllRows = async () => { const getAllRows = async () => {
@ -575,11 +428,8 @@ async function loadFromApi(route, channelId) {
if (selector?.ownerBlockchainName && selector?.channelName) { if (selector?.ownerBlockchainName && selector?.channelName) {
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
let channel = null;
if (isAuthorized) {
const allRows = await getAllRows(); const allRows = await getAllRows();
channel = allRows.find((item) => ( let channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
)); ));
@ -589,7 +439,7 @@ async function loadFromApi(route, channelId) {
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
)); ));
} }
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) { if (!channel) {
try { try {
const ownerUser = await authService.getUser(routeOwnerRaw); const ownerUser = await authService.getUser(routeOwnerRaw);
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase(); const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
@ -603,22 +453,6 @@ async function loadFromApi(route, channelId) {
// ignore fallback lookup failures // ignore fallback lookup failures
} }
} }
}
if (!channel) {
const ownerLoginForLookup = routeOwnerLoginFromBch || (!looksLikeBlockchainName(routeOwnerRaw) ? routeOwnerRaw : '');
if (ownerLoginForLookup) {
try {
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
channel = ownerRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
} catch {
// ignore owner feed lookup failures
}
}
}
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.'); throw new Error('Канал не найден.');
} }
@ -634,12 +468,12 @@ async function loadFromApi(route, channelId) {
throw new Error('Не удалось определить канал из адреса страницы.'); throw new Error('Не удалось определить канал из адреса страницы.');
} }
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin); const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
const messages = Array.isArray(payload.messages) ? payload.messages : []; const messages = Array.isArray(payload.messages) ? payload.messages : [];
let reverseChannelMissingWarning = ''; let reverseChannelMissingWarning = '';
let mergedMessages = [...messages]; let mergedMessages = [...messages];
const currentLogin = currentSessionLogin; const currentLogin = String(state.session.login || '').trim();
const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const channelName = String(payload.channel?.channelName || '').trim(); const channelName = String(payload.channel?.channelName || '').trim();
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1); const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
@ -665,7 +499,7 @@ async function loadFromApi(route, channelId) {
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber), channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash), channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
}; };
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin); const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login);
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : []; const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
mergedMessages = mergedMessages.concat(reverseMessages); mergedMessages = mergedMessages.concat(reverseMessages);
} else { } else {
@ -683,9 +517,9 @@ async function loadFromApi(route, channelId) {
return aNum - bNum; return aNum - bNum;
}) })
.map((post, index) => ({ ...post, localNumber: index + 1 })); .map((post, index) => ({ ...post, localNumber: index + 1 }));
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase(); const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase();
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
const isSubscribed = isAuthorized && followedRows.some((row) => ( const isSubscribed = followedRows.some((row) => (
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '') String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber) && Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash) && normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
@ -747,31 +581,17 @@ function renderDemoFallback(screen, navigate, error) {
screen.append(back); screen.append(back);
} }
function scrollChannelToBottom(screen, smooth = true) { function applyPendingScroll(screen, routeKey) {
const feed = screen.querySelector('.channel-feed');
if (feed) {
feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
}
const appScreen = document.getElementById('app-screen');
if (appScreen) {
appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
return;
}
window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
function applyPendingScroll(screen, routeKey, forceBottom = false) {
const target = pendingScrollByRoute.get(routeKey); const target = pendingScrollByRoute.get(routeKey);
if (!target && !forceBottom) return; if (!target) return;
const doScroll = () => { const doScroll = () => {
if (!target && forceBottom) {
scrollChannelToBottom(screen, false);
return;
}
if (target === '__LAST__') { if (target === '__LAST__') {
scrollChannelToBottom(screen, true); const cards = screen.querySelectorAll('[data-message-key]');
const last = cards[cards.length - 1];
if (last) {
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
pendingScrollByRoute.delete(routeKey); pendingScrollByRoute.delete(routeKey);
return; return;
} }
@ -792,18 +612,16 @@ 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';
const authorTile = document.createElement('button'); const topRow = document.createElement('div');
authorTile.type = 'button'; topRow.className = 'channel-message-top';
authorTile.className = 'channel-message-author-tile';
const avatar = createMessageAvatar(post.authorLogin); const avatar = document.createElement('div');
avatar.className = 'channel-message-avatar';
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
const authorBlock = document.createElement('div'); const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author'; authorBlock.className = 'channel-message-author';
@ -823,37 +641,14 @@ 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); topRow.append(avatar, authorBlock);
authorTile.addEventListener('click', (event) => {
event.stopPropagation();
const cleanLogin = String(post.authorLogin || '').trim();
if (!cleanLogin) return;
navigate(makeProfileRoute(cleanLogin));
});
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p'); const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`; body.className = 'channel-message-body';
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body; body.textContent = post.body;
card.append(authorTile, body); card.append(topRow, body);
const refKey = messageRefKey(post.messageRef); const refKey = messageRefKey(post.messageRef);
if (refKey) { if (refKey) {
@ -881,7 +676,6 @@ function renderPostCard(post, {
`; `;
likeButton.disabled = isPending; likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => { likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
if (isPending) return; if (isPending) return;
if (!isLiked) { if (!isLiked) {
@ -904,7 +698,6 @@ function renderPostCard(post, {
<span class="channel-action-counter">${post.repliesCount || 0}</span> <span class="channel-action-counter">${post.repliesCount || 0}</span>
`; `;
replyButton.addEventListener('click', (event) => { replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget); animatePress(event.currentTarget);
openReplyModal({ openReplyModal({
navigate, navigate,
@ -913,6 +706,19 @@ 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) => {
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';
@ -927,46 +733,49 @@ function renderPostCard(post, {
await onShare(route); await onShare(route);
}); });
actions.append(shareButton); actions.append(openThreadButton, 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', () => {
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
return card; return card;
} }
function renderBody(screen, navigate, routeKey, channelData, handlers) { function renderBody(screen, navigate, routeKey, channelData, handlers) {
const head = document.createElement('div');
head.className = 'card channel-head-card';
const title = document.createElement('strong');
title.className = 'channel-head-title';
title.textContent = String(channelData.channel.name || '').trim();
const owner = document.createElement('p');
owner.className = 'channel-head-meta';
owner.textContent = `Владелец: ${channelData.channel.ownerName}`;
const headActions = document.createElement('div');
headActions.className = 'channel-head-actions';
const aboutButton = document.createElement('button');
aboutButton.type = 'button';
aboutButton.className = 'secondary-btn small-btn';
aboutButton.textContent = 'О канале';
aboutButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(channelData.channel);
});
headActions.append(aboutButton);
head.append(title);
head.append(owner, headActions);
if (channelData.reverseChannelMissingWarning) { if (channelData.reverseChannelMissingWarning) {
const reverseWarning = document.createElement('p'); const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta'; reverseWarning.className = 'channel-head-meta';
reverseWarning.textContent = channelData.reverseChannelMissingWarning; reverseWarning.textContent = channelData.reverseChannelMissingWarning;
screen.append(reverseWarning); head.append(reverseWarning);
} }
const actionButton = document.createElement('button'); const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action'; actionButton.className = channelData.isOwnChannel
actionButton.textContent = 'Подписаться на канал'; ? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
const feed = document.createElement('div'); const feed = document.createElement('div');
feed.className = 'stack channel-feed'; feed.className = 'stack channel-feed';
@ -980,7 +789,6 @@ 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) {
@ -996,7 +804,16 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
} }
if (!channelData.isSubscribed) { if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAddMessageModal({
channelName: channelData.channel.name,
navigate,
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
});
});
} else if (!channelData.isSubscribed) {
actionButton.addEventListener('click', handlers.onSubscribeChannel); actionButton.addEventListener('click', handlers.onSubscribeChannel);
} }
@ -1005,15 +822,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам'; backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list')); backButton.addEventListener('click', () => navigate('channels-list'));
if (channelData.isOwnChannel) { if (channelData.isOwnChannel || !channelData.isSubscribed) {
screen.append(feed); screen.append(head, actionButton, feed, backButton);
} else if (!channelData.isSubscribed) {
screen.append(actionButton, feed, backButton);
} else { } else {
screen.append(feed, backButton); screen.append(head, feed, backButton);
} }
applyPendingScroll(screen, routeKey, channelData.isOwnChannel); applyPendingScroll(screen, routeKey);
return () => { return () => {
// noop // noop
}; };
@ -1051,17 +866,6 @@ export function render({ navigate, route }) {
statusBox.style.display = ''; statusBox.style.display = '';
}; };
const header = renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigateBack() },
rightActions: [{ label: 'Канал: ...', onClick: () => {} }],
});
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
if (channelHeaderButton) {
channelHeaderButton.classList.add('channel-header-route-btn');
channelHeaderButton.disabled = true;
}
const rerender = () => { const rerender = () => {
const current = document.querySelector('section.channels-screen--channel'); const current = document.querySelector('section.channels-screen--channel');
if (!current) return; if (!current) return;
@ -1074,7 +878,7 @@ export function render({ navigate, route }) {
const login = state.session.login; const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory; const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) { if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels-list'; state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view'); navigate('login-view');
throw new Error('Для этого действия нужно войти'); throw new Error('Для этого действия нужно войти');
} }
@ -1159,25 +963,12 @@ export function render({ navigate, route }) {
rerender(); rerender();
}; };
const onEditPost = async (messageRef, text) => { screen.append(
const { login, storagePwd } = requireSigningSession(); renderHeader({
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) { title: '',
throw new Error('Идентификатор канала не готов.'); leftAction: { label: '<', onClick: () => navigate('channels-list') },
} }),
await authService.addBlockEditMessage({ );
login,
storagePwd,
message: messageRef,
text,
isChannelPost: true,
channel: activeSelector,
});
softHaptic(12);
showToast('Сообщение обновлено');
rerender();
};
screen.append(header);
screen.append(statusBox); screen.append(statusBox);
const skeleton = renderSkeleton(screen); const skeleton = renderSkeleton(screen);
@ -1188,41 +979,6 @@ export function render({ navigate, route }) {
try { try {
const apiData = await loadFromApi(route, channelId); const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null; activeSelector = apiData?.selector || null;
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
if (channelHeaderButton) {
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel);
};
}
if (apiData?.isOwnChannel) {
const headerActions = header.querySelector('.header-actions');
if (headerActions) {
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'icon-btn channel-header-add-btn';
addBtn.textContent = 'Добавить сообщение';
addBtn.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
onSubmit: async (bodyText) => {
try {
await onAddPost(bodyText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
});
});
headerActions.append(addBtn);
}
}
skeleton.remove(); skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onToggleLike: async (messageRef, action) => { onToggleLike: async (messageRef, action) => {
@ -1250,14 +1006,6 @@ 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 {

View File

@ -10,7 +10,6 @@ import {
softHaptic, softHaptic,
writeChannelNotificationsState, writeChannelNotificationsState,
} from '../services/channels-ux.js'; } from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' }; export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@ -18,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 = ['feed', 'my']; const TAB_ORDER = ['dialogs', 'feed', 'my'];
function isChannelsDemoMode() { function isChannelsDemoMode() {
try { try {
@ -44,14 +43,12 @@ function normalizeLoginInput(value) {
} }
function buildChannelRouteFromSummary(summary, fallbackId) { function buildChannelRouteFromSummary(summary, fallbackId) {
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim(); const ownerBch = summary?.channel?.ownerBlockchainName;
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim(); const channelName = String(summary?.channel?.channelName || '').trim();
return makeShineChannelRoute({ if (ownerBch && channelName) {
ownerLogin, return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`;
ownerBlockchainName: ownerBch, }
channelName: channelName || fallbackId, return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`;
});
} }
function avatarLetterFromName(name = '') { function avatarLetterFromName(name = '') {
@ -411,7 +408,7 @@ function openChannelFinderModal({ navigate }) {
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);"> <div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
<h3 class="modal-title">Поиск каналов</h3> <h3 class="modal-title">Поиск каналов</h3>
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p> <p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
<input id="channels-find-input" class="input" placeholder="Например: aidar" autocomplete="off" /> <input id="channels-find-input" class="input" placeholder="Например: aid" autocomplete="off" />
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div> <div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div> <div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-error" class="meta-muted inline-error"></div> <div id="channels-find-error" class="meta-muted inline-error"></div>
@ -466,12 +463,8 @@ function openChannelFinderModal({ navigate }) {
openBtn.textContent = 'Просмотреть'; openBtn.textContent = 'Просмотреть';
openBtn.addEventListener('click', () => { openBtn.addEventListener('click', () => {
close(); close();
const route = makeShineChannelRoute({ const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim();
ownerLogin: String(item.ownerLogin || '').trim(), navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`);
ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
channelName: String(item.channelName || '').trim(),
});
if (route) navigate(route);
}); });
row.style.display = 'flex'; row.style.display = 'flex';
@ -589,13 +582,11 @@ function openChannelFinderModal({ navigate }) {
function mapMockGroups() { function mapMockGroups() {
const mapRow = (channel) => ({ const mapRow = (channel) => ({
...channel, ...channel,
route: makeShineChannelRoute({ route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`,
ownerLogin: String(channel.ownerName || 'channel'), tabCategory: channel.kind === 'own'
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.title || channel.id),
}),
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',
@ -634,7 +625,9 @@ 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 ? 'my' : 'feed'; const tabCategory = isOwn
? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
: 'feed';
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`; const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
@ -694,13 +687,12 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) { function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent'; wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
if (!state.session.isAuthorized) {
return wrap;
}
const text = document.createElement('p'); const text = document.createElement('p');
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 {
@ -777,14 +769,7 @@ function renderDemoFallback(container, navigate, error, onRetry) {
<span class="channel-row-time"></span> <span class="channel-row-time"></span>
</div> </div>
`; `;
row.addEventListener('click', () => { row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.id),
});
if (route) navigate(route);
});
list.append(row); list.append(row);
}); });
@ -968,7 +953,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' && channel.channelDescription) { if ((activeTab === 'my' || activeTab === 'dialogs') && 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;
@ -1017,21 +1002,9 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const main = renderChannelMain(channel, activeTab); const main = renderChannelMain(channel, activeTab);
const isGuest = !state.session.isAuthorized;
const controls = document.createElement('div'); const controls = document.createElement('div');
controls.className = 'channel-row-controls'; controls.className = 'channel-row-controls';
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
const count = document.createElement('span');
count.className = 'unread channel-row-count';
const unreadCount = Number(channel.unreadCount || 0);
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
if (!isGuest) {
const menuButton = document.createElement('button'); const menuButton = document.createElement('button');
menuButton.type = 'button'; menuButton.type = 'button';
menuButton.className = 'channel-menu-trigger'; menuButton.className = 'channel-menu-trigger';
@ -1057,19 +1030,21 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
}); });
rerenderList(); rerenderList();
}); });
controls.append(menuButton);
} const time = document.createElement('span');
controls.append(time, count); time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
const count = document.createElement('span');
count.className = 'unread channel-row-count';
const unreadCount = Number(channel.unreadCount || 0);
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
controls.append(menuButton, time, count);
row.append(avatar, main, controls); row.append(avatar, main, controls);
row.addEventListener('click', () => { row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.id),
});
if (route) navigate(route);
});
list.append(row); list.append(row);
}); });
@ -1087,10 +1062,17 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
return; return;
} }
if (tab === 'my') { if (tab === 'dialogs') {
button.textContent = 'Найти канал'; button.textContent = 'Новый персональный публичный чат';
button.className = baseClass; button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate }); button.onclick = () => navigate('add-personal-public-chat-view');
return;
}
if (tab === 'my') {
button.textContent = 'Создать канал';
button.className = baseClass;
button.onclick = () => navigate('add-channel-view');
return; return;
} }
@ -1103,20 +1085,8 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState); closeChannelMenu(listState);
renderSkeletonList(contentEl, 5); renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
setChannelsFeed(null, {});
listState.channels = [];
renderListContent({
screen,
container: contentEl,
listState,
navigate,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
});
return;
}
try { try {
if (!state.session.login) throw new Error('not_authorized');
const feed = await authService.listSubscriptionsFeed(state.session.login, 200); const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
const groups = mapApiFeed(feed, listState.notificationsState); const groups = mapApiFeed(feed, listState.notificationsState);
@ -1152,7 +1122,6 @@ export function render({ navigate, route }) {
const createSuccessFlash = pullCreateSuccessFlash(); const createSuccessFlash = pullCreateSuccessFlash();
const notificationsState = readChannelNotificationsState(); const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const listState = { const listState = {
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim()) activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
? String(route?.params?.mode).trim() ? String(route?.params?.mode).trim()
@ -1163,24 +1132,18 @@ export function render({ navigate, route }) {
channels: [], channels: [],
menuCleanup: null, menuCleanup: null,
}; };
if (isGuest && listState.activeTab === 'my') {
listState.activeTab = 'feed';
}
const contentEl = document.createElement('div'); const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content'; contentEl.className = 'channels-list-content';
const topBarEl = document.createElement('div');
topBarEl.className = 'channels-top-bar';
const tabsEl = document.createElement('div'); const tabsEl = document.createElement('div');
tabsEl.className = 'channels-tabs'; tabsEl.className = 'channels-tabs';
const tabLabels = { const tabLabels = {
feed: 'Каналы', feed: 'Каналы',
my: 'Мои каналы', dialogs: 'Чаты',
my: 'Мои',
}; };
TAB_ORDER.forEach((tabKey) => { TAB_ORDER.forEach((tabKey) => {
if (isGuest && tabKey === 'my') return;
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' : ''}`;
@ -1193,15 +1156,6 @@ export function render({ navigate, route }) {
tabsEl.append(tabBtn); tabsEl.append(tabBtn);
}); });
const topActionBtn = document.createElement('button');
topActionBtn.type = 'button';
topActionBtn.className = 'secondary-btn channels-top-action-btn';
topActionBtn.textContent = 'Создать канал';
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
if (isGuest) topActionBtn.style.display = 'none';
topBarEl.append(tabsEl, topActionBtn);
const bottomCta = document.createElement('button'); const bottomCta = document.createElement('button');
bottomCta.type = 'button'; bottomCta.type = 'button';
@ -1209,9 +1163,9 @@ export function render({ navigate, route }) {
const rerenderList = () => { const rerenderList = () => {
try { try {
const expectedPath = `/channels-list/${listState.activeTab}`; const expectedHash = `#/channels-list/${listState.activeTab}`;
if (window.location.pathname !== expectedPath) { if (window.location.hash !== expectedHash) {
window.history.replaceState({}, '', expectedPath); window.history.replaceState({}, '', expectedHash);
} }
} catch { } catch {
// ignore history errors // ignore history errors
@ -1229,9 +1183,6 @@ export function render({ navigate, route }) {
refreshFeed: reloadFeed, refreshFeed: reloadFeed,
}); });
const showCreate = !isGuest && listState.activeTab === 'my';
topActionBtn.style.display = showCreate ? '' : 'none';
updateBottomCta({ updateBottomCta({
button: bottomCta, button: bottomCta,
listState, listState,
@ -1265,7 +1216,7 @@ export function render({ navigate, route }) {
rerenderList(); rerenderList();
}, { passive: true }); }, { passive: true });
screen.append(topBarEl, contentEl, bottomCta); screen.append(tabsEl, contentEl, bottomCta);
if (createSuccessFlash) { if (createSuccessFlash) {
showToast(createSuccessFlash); showToast(createSuccessFlash);

View File

@ -10,108 +10,14 @@ import {
markOutgoingSent, markOutgoingSent,
markReadReceiptSentByBaseKey, markReadReceiptSentByBaseKey,
authService, authService,
setContacts,
state, state,
} from '../state.js'; } from '../state.js';
import { startOutgoingCall } from '../services/call-service.js'; import { startOutgoingCall } from '../services/call-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
function openMessageActionsModal({ messageText = '', onReadAloud }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="chat-message-actions-modal-overlay">
<div class="modal-card stack dm-dialog-card dm-message-actions-menu" id="chat-message-actions-modal">
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Копировать</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => {
if (event.target?.id === 'chat-message-actions-modal-overlay') close();
});
root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(String(messageText || ''));
}
showToast('Сообщение скопированно', { timeoutMs: 1000 });
} catch {
showToast('Не удалось скопировать сообщение', { kind: 'error', timeoutMs: 1200 });
} finally {
close();
}
});
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
close();
if (typeof onReadAloud === 'function') await onReadAloud();
});
}
function showTtsMissingConfigDialog(navigate) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="chat-tts-missing-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Озвучка не настроена</h3>
<p class="meta-muted">Перейти в настройки инструментов?</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-tts-no">Нет</button>
<button class="primary-btn" type="button" id="chat-tts-yes">Да</button>
</div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#chat-tts-no')?.addEventListener('click', close);
root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
close();
navigate('tools-settings-view');
});
}
function autoResizeComposer(textarea) {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
}
function openConfirmContactModal(targetLogin = '') {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
return new Promise((resolve) => {
root.innerHTML = `
<div class="modal" id="contact-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Добавить собеседника</h3>
<p class="meta-muted">Добавить пользователя @${targetLogin} в контакты?</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="contact-confirm-no">Нет</button>
<button class="primary-btn" type="button" id="contact-confirm-yes">Да</button>
</div>
</div>
</div>
`;
const close = (answer) => {
root.innerHTML = '';
resolve(!!answer);
};
root.querySelector('#contact-confirm-no')?.addEventListener('click', () => close(false));
root.querySelector('#contact-confirm-yes')?.addEventListener('click', () => close(true));
});
}
function parseBaseKey(baseKey) { function parseBaseKey(baseKey) {
const raw = String(baseKey || '').trim(); const raw = String(baseKey || '').trim();
const parts = raw.split('|'); const parts = raw.split('|');
@ -172,14 +78,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) {
list.innerHTML = ''; list.innerHTML = '';
const messages = getChatMessages(chatId); const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false; let unreadSeparatorInserted = false;
@ -219,9 +122,6 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
} }
bubble.append(textNode, metaNode); bubble.append(textNode, metaNode);
bubble.addEventListener('click', () => {
if (typeof onOpenActions === 'function') onOpenActions(msg);
});
list.append(bubble); list.append(bubble);
}); });
scrollToLatestMessage(list); scrollToLatestMessage(list);
@ -242,42 +142,20 @@ export function render({ navigate, route }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: `Чат с ${contact.name}`, title: `Чат: ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') }, leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{ rightActions: [{
label: 'Позвонить', label: 'Позвонить',
onClick: async () => { onClick: async () => {
try { try {
await startOutgoingCall(chatId); await startOutgoingCall(chatId);
renderLog(log, chatId, { renderLog(log, chatId);
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
} catch (e) { } catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out', from: 'out',
kind: 'call-tech', kind: 'call-tech',
}); });
renderLog(log, chatId, { renderLog(log, chatId);
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
} }
}, },
}], }],
@ -290,26 +168,18 @@ export function render({ navigate, route }) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'secondary-btn'; btn.className = 'secondary-btn';
btn.type = 'button'; btn.type = 'button';
btn.textContent = 'Добавить собеседника в контакты'; btn.textContent = 'Добавить в контакты';
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
try { try {
const approved = await openConfirmContactModal(chatId); await authService.addCloseFriend(chatId);
if (!approved) return; state.contacts = [...new Set([...(state.contacts || []), chatId])];
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'contacts', source: 'contacts',
message: `Пользователь ${chatId} добавлен в контакты`, message: `Пользователь ${chatId} добавлен в контакты`,
}); });
card.remove(); btn.disabled = true;
btn.textContent = 'Добавлено';
} catch (e) { } catch (e) {
addAppLogEntry({ addAppLogEntry({
level: 'warn', level: 'warn',
@ -332,30 +202,52 @@ export function render({ navigate, route }) {
const form = document.createElement('form'); const form = document.createElement('form');
form.className = 'chat-input dm-chat-input'; form.className = 'chat-input dm-chat-input';
form.innerHTML = ` form.innerHTML = `
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea> <input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<div class="dm-actions-col"> <button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input">🎤</button>
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button> <button class="ghost-btn dm-voice-btn" type="button" id="chat-read-aloud">🔊</button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button> <button class="primary-btn dm-send-btn" type="submit">Отправить</button>
</div>
`; `;
const sendTextMessage = async (rawText) => { form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
const text = String(rawText || '').trim(); const input = form.elements.message;
if (!text) return; await openSpeechInputModal({
const tempId = addOutgoingPendingMessage(chatId, text); navigate,
renderLog(log, chatId, { onTextReady: (text) => {
onOpenActions: (msg) => openMessageActionsModal({ const prev = String(input.value || '').trim();
messageText: msg?.text || '', input.value = prev ? `${prev} ${text}` : text;
onReadAloud: async () => { },
if (!isTextToSpeechConfigured(state.entrySettings)) { });
showTtsMissingConfigDialog(navigate); });
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
const input = form.elements.message;
const text = String(input.value || '').trim();
if (!text) {
window.alert('Введите текст для озвучки.');
return; return;
} }
await speakTextBySettings(String(msg?.text || ''), state.entrySettings); if (!isTextToSpeechConfigured(state.entrySettings)) {
}, const goSettings = window.confirm('Озвучка не настроена. Перейти в настройки инструментов?');
}), if (goSettings) navigate('tools-settings-view');
return;
}
try {
await speakTextBySettings(text, state.entrySettings);
} catch (error) {
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
}
}); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
const input = form.elements.message;
const text = input.value.trim();
if (!text) return;
const tempId = addOutgoingPendingMessage(chatId, text);
input.value = '';
renderLog(log, chatId);
try { try {
const result = await authService.sendDirectMessage({ const result = await authService.sendDirectMessage({
login: state.session.login, login: state.session.login,
@ -367,18 +259,7 @@ export function render({ navigate, route }) {
messageKey: result?.outgoingKey || '', messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '', baseKey: result?.baseKey || result?.localBaseKey || '',
}); });
renderLog(log, chatId, { renderLog(log, chatId);
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'outgoing-dm', source: 'outgoing-dm',
@ -401,92 +282,14 @@ export function render({ navigate, route }) {
error: e?.message || 'unknown', error: e?.message || 'unknown',
}, },
}); });
renderLog(log, chatId, { renderLog(log, chatId);
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
} }
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
}
};
const input = form.elements.message;
autoResizeComposer(input);
input?.addEventListener('input', () => autoResizeComposer(input));
input?.addEventListener('focus', () => {
scrollToLatestMessage(log);
});
input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
if (event.ctrlKey) {
event.preventDefault();
const start = Number(input.selectionStart ?? input.value.length);
const end = Number(input.selectionEnd ?? input.value.length);
const value = String(input.value || '');
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
const nextPos = start + 1;
try {
input.setSelectionRange(nextPos, nextPos);
} catch {
// ignore
}
autoResizeComposer(input);
return;
}
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);
});
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(input.value || '').trim();
input.value = prev ? `${prev} ${text}` : text;
autoResizeComposer(input);
},
onSendText: async (text) => sendTextMessage(text),
onSendQueued: () => {
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
},
});
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);
}); });
wrap.append(log, form); wrap.append(log, form);
screen.append(wrap); screen.append(wrap);
renderLog(log, chatId, { renderLog(log, chatId);
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
},
}),
});
window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180);
void sendReadReceiptsForVisible(chatId); void sendReadReceiptsForVisible(chatId);
return screen; return screen;
} }

View File

@ -1,62 +1,7 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { authService } from '../state.js'; import { authService } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' }; export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
const searchAvatarSnapshotCache = new Map();
const searchAvatarPendingByLogin = new Map();
async function loadSearchAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (searchAvatarSnapshotCache.has(key)) return searchAvatarSnapshotCache.get(key);
if (searchAvatarPendingByLogin.has(key)) return searchAvatarPendingByLogin.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
searchAvatarSnapshotCache.set(key, snapshot || null);
searchAvatarPendingByLogin.delete(key);
return snapshot || null;
})
.catch(() => {
searchAvatarSnapshotCache.set(key, null);
searchAvatarPendingByLogin.delete(key);
return null;
});
searchAvatarPendingByLogin.set(key, pending);
return pending;
}
function createSearchAvatar(login) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
className: 'avatar',
title,
});
if (!cleanLogin) return avatarEl;
void loadSearchAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
className: 'avatar',
title,
});
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
@ -99,17 +44,16 @@ export function render({ navigate }) {
matches.forEach((login) => { matches.forEach((login) => {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const avatarEl = createSearchAvatar(login);
row.innerHTML = ` row.innerHTML = `
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div> <div>
<strong>${login}</strong> <strong>${login}</strong>
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p> <p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
</div> </div>
<div class="meta-muted">Профиль</div> <div class="meta-muted">Профиль</div>
`; `;
row.prepend(avatarEl);
row.addEventListener('click', () => { row.addEventListener('click', () => {
navigate(makeProfileRoute(login)); navigate(`user-profile-view/${encodeURIComponent(login)}/contact-search-view`);
}); });
resultsList.append(row); resultsList.append(row);
}); });

View File

@ -184,8 +184,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
async function forceUiUpdateNow() { async function forceUiUpdateNow() {
try { try {
window.history.replaceState({}, '', '/settings-view'); window.location.hash = '#/settings-view';
window.dispatchEvent(new PopStateEvent('popstate'));
} catch {} } catch {}
if (!('serviceWorker' in navigator)) { if (!('serviceWorker' in navigator)) {
window.location.reload(); window.location.reload();

View File

@ -8,72 +8,8 @@ import {
terminateCurrentSession, terminateCurrentSession,
} from '../state.js'; } from '../state.js';
import { loadCurrentRelations } from '../services/user-connections.js'; import { loadCurrentRelations } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const dmAvatarSnapshotCache = new Map();
const dmAvatarPendingByLogin = new Map();
async function loadDmAvatarSnapshot(login) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) return null;
const key = cleanLogin.toLowerCase();
if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key);
if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key);
const pending = loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
dmAvatarSnapshotCache.set(key, snapshot || null);
dmAvatarPendingByLogin.delete(key);
return snapshot || null;
})
.catch(() => {
dmAvatarSnapshotCache.set(key, null);
dmAvatarPendingByLogin.delete(key);
return null;
});
dmAvatarPendingByLogin.set(key, pending);
return pending;
}
function createDmAvatar(login) {
const cleanLogin = String(login || '').trim();
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
const avatarEl = renderUserAvatar({
login: cleanLogin || 'unknown',
size: 'small',
title,
});
if (!cleanLogin) return avatarEl;
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
if (!avatarEl.isConnected) return;
const upgraded = renderUserAvatar({
login: cleanLogin,
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'small',
title,
});
upgraded.classList.add('avatar');
avatarEl.replaceWith(upgraded);
});
return avatarEl;
}
function formatChatRowTime(ts) {
const value = Number(ts || 0);
if (!Number.isFinite(value) || value <= 0) return '-';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
@ -93,22 +29,20 @@ export function render({ navigate }) {
function renderRow(item) { function renderRow(item) {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id);
avatarEl.classList.add('avatar');
row.innerHTML = ` row.innerHTML = `
<div class="dm-row-main"> <div class="avatar">${item.initials}</div>
<div class="dm-row-title-wrap"> <div>
<strong class="dm-row-title">${item.name}</strong> <div class="row" style="justify-content:flex-start; gap:8px;">
<strong>${item.name}</strong>
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''} ${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
</div> </div>
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p> <p class="meta-muted" style="margin-top:4px;">${item.lastMessage}</p>
</div> </div>
<div class="dm-row-meta-col"> <div style="display:grid; justify-items:end; gap:6px;">
<span class="meta-muted">${item.time}</span>
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'} ${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
<span class="meta-muted dm-row-time">${item.time}</span>
</div> </div>
`; `;
row.prepend(avatarEl);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`)); row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
return row; return row;
} }
@ -125,12 +59,12 @@ export function render({ navigate }) {
const chat = getChatMessages(login); const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1]; const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return { return {
id: login, id: login,
initials: (login[0] || '?').toUpperCase(),
name: preview?.name || login, name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs), time: preview?.time || '—',
unread, unread,
notInContacts: false, notInContacts: false,
}; };
@ -147,12 +81,12 @@ export function render({ navigate }) {
const chat = getChatMessages(login); const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1]; const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length; const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return { return {
id: login, id: login,
initials: (login[0] || '?').toUpperCase(),
name: login, name: login,
lastMessage: lastChat?.text || 'Диалог пока пуст.', lastMessage: lastChat?.text || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs), time: 'сейчас',
unread, unread,
notInContacts: true, notInContacts: true,
}; };

View File

@ -2,8 +2,6 @@ import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { loadUserProfileCard } from '../services/user-connections.js'; import { loadUserProfileCard } from '../services/user-connections.js';
import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'network-view', title: 'Связи' }; export const pageMeta = { id: 'network-view', title: 'Связи' };
@ -16,14 +14,6 @@ function normalizeLogin(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
function createDebounced(fn, delayMs = 2000) {
let timer = 0;
return (...args) => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => fn(...args), delayMs);
};
}
function normKey(value) { function normKey(value) {
return normalizeLogin(value).toLowerCase(); return normalizeLogin(value).toLowerCase();
} }
@ -517,7 +507,6 @@ let persistedCenterHistory = [];
export function render({ navigate, route }) { export function render({ navigate, route }) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) { if (!keepHistory) {
persistedCenterLogin = ''; persistedCenterLogin = '';
persistedCenterHistory = []; persistedCenterHistory = [];
@ -544,7 +533,7 @@ export function render({ navigate, route }) {
const cleanLogin = normalizeLogin(login); const cleanLogin = normalizeLogin(login);
if (!cleanLogin) return ''; if (!cleanLogin) return '';
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view'; if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
return makeProfileRoute(cleanLogin); return `user-profile-view/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
} }
function helpText() { function helpText() {
@ -562,15 +551,6 @@ export function render({ navigate, route }) {
persistedCenterHistory = [...centerHistory]; persistedCenterHistory = [...centerHistory];
} }
function syncLinksUrl(login, { push = false } = {}) {
const clean = normalizeLogin(login);
if (!clean) return;
const nextPath = `/${makeProfileLinksRoute(clean)}`;
if (window.location.pathname === nextPath) return;
if (push) window.history.pushState({}, '', nextPath);
else window.history.replaceState({}, '', nextPath);
}
function setBackButtonState(backBtn) { function setBackButtonState(backBtn) {
if (!(backBtn instanceof HTMLButtonElement)) return; if (!(backBtn instanceof HTMLButtonElement)) return;
backBtn.disabled = centerHistory.length === 0; backBtn.disabled = centerHistory.length === 0;
@ -588,8 +568,13 @@ export function render({ navigate, route }) {
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" /> <input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
<button class="primary-btn" type="button" id="network-search-run">Искать</button> <button class="primary-btn" type="button" id="network-search-run">Искать</button>
</div> </div>
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</div> <div class="meta-muted" id="network-search-meta">Введите логин и нажмите «Искать».</div>
<div class="stack" id="network-search-results"></div> <div class="stack" id="network-search-results"></div>
<div class="form-actions-grid">
<button class="ghost-btn" type="button" id="network-search-profile" disabled>Показать профиль</button>
<button class="primary-btn" type="button" id="network-search-graph" disabled>Показать связи</button>
</div>
<button class="secondary-btn" type="button" id="network-search-ok" disabled>OK</button>
</div> </div>
</div> </div>
`; `;
@ -600,6 +585,9 @@ export function render({ navigate, route }) {
const runBtn = root.querySelector('#network-search-run'); const runBtn = root.querySelector('#network-search-run');
const metaEl = root.querySelector('#network-search-meta'); const metaEl = root.querySelector('#network-search-meta');
const resultsEl = root.querySelector('#network-search-results'); const resultsEl = root.querySelector('#network-search-results');
const profileBtn = root.querySelector('#network-search-profile');
const graphBtn = root.querySelector('#network-search-graph');
const okBtn = root.querySelector('#network-search-ok');
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) { if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
root.innerHTML = ''; root.innerHTML = '';
return; return;
@ -619,6 +607,10 @@ export function render({ navigate, route }) {
if (!(row instanceof HTMLElement)) return; if (!(row instanceof HTMLElement)) return;
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin); row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
}); });
const hasSelected = Boolean(selectedLogin);
if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected;
if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected;
if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected;
}; };
const renderCandidates = (logins) => { const renderCandidates = (logins) => {
@ -669,8 +661,6 @@ export function render({ navigate, route }) {
}); });
closeBtn?.addEventListener('click', close); closeBtn?.addEventListener('click', close);
runBtn?.addEventListener('click', () => { void runSearch(); }); runBtn?.addEventListener('click', () => { void runSearch(); });
const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000);
inputEl.addEventListener('input', debouncedSearch);
inputEl.addEventListener('keydown', (event) => { inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@ -682,11 +672,23 @@ export function render({ navigate, route }) {
if (!(target instanceof HTMLElement)) return; if (!(target instanceof HTMLElement)) return;
const button = target.closest('[data-candidate]'); const button = target.closest('[data-candidate]');
if (!(button instanceof HTMLElement)) return; if (!(button instanceof HTMLElement)) return;
const nextLogin = String(button.dataset.candidate || ''); applySelection(String(button.dataset.candidate || ''));
applySelection(nextLogin); });
if (!nextLogin) return; profileBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
const routeTo = profileInfoRoute(selectedLogin);
if (!routeTo) return;
close(); close();
void load(nextLogin, { pushHistory: true }); navigate(routeTo);
});
graphBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
close();
void load(selectedLogin, { pushHistory: true });
});
okBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
}); });
window.setTimeout(() => inputEl.focus(), 0); window.setTimeout(() => inputEl.focus(), 0);
@ -763,7 +765,6 @@ export function render({ navigate, route }) {
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
centerHistory.push(prevCenter); centerHistory.push(prevCenter);
} }
syncLinksUrl(targetCenter, { push: pushHistory });
const model = buildGraphModel(graph, targetCenter); const model = buildGraphModel(graph, targetCenter);
const layout = layoutNodes(model); const layout = layoutNodes(model);
@ -838,22 +839,13 @@ export function render({ navigate, route }) {
appScreenEl?.classList.remove('network-scroll-lock'); appScreenEl?.classList.remove('network-scroll-lock');
}; };
if (routeLogin) { if (keepHistory && centerLogin) {
centerLogin = routeLogin;
centerHistory = [];
persistHistory();
void load(centerLogin, { pushHistory: false });
} else if (keepHistory && centerLogin) {
void load(centerLogin, { pushHistory: false }); void load(centerLogin, { pushHistory: false });
} else { } else {
centerLogin = normalizeLogin(state.session.login || ''); centerLogin = normalizeLogin(state.session.login || '');
centerHistory = []; centerHistory = [];
persistHistory(); persistHistory();
if (centerLogin) {
void load(centerLogin, { pushHistory: false }); void load(centerLogin, { pushHistory: false });
} else {
window.setTimeout(() => openSearchModal(), 0);
}
} }
setBackButtonState(backBtnEl); setBackButtonState(backBtnEl);

View File

@ -7,7 +7,6 @@ import {
} from '../services/user-profile-params.js'; } from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js'; import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' }; export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@ -30,40 +29,6 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
function openProfileInfoModal({ title, text }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="profile-info-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#profile-info-close')?.addEventListener('click', close);
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-info-modal') close();
});
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
}
function shineInfoText() {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
+ 'Пять принципов сияющих:\n'
+ '1) сияющие не обманывают;\n'
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
}
export function render({ navigate }) { export function render({ navigate }) {
const login = state.session.login || profile.login; const login = state.session.login || profile.login;
@ -73,23 +38,15 @@ export function render({ navigate }) {
const topActions = document.createElement('div'); const topActions = document.createElement('div');
topActions.className = 'profile-top-actions'; topActions.className = 'profile-top-actions';
topActions.innerHTML = ` topActions.innerHTML = `
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button> <button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Изменить профиль</button>
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="wallet">Кошелёк</button>
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button> <button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
`; `;
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view')); topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view')); topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
screen.append(topActions); screen.append(topActions);
const bottomActions = document.createElement('div');
bottomActions.className = 'profile-bottom-actions';
bottomActions.innerHTML = `
<button class="ghost-btn profile-top-action-btn" type="button" data-bottom-action="wallet">Кошелёк</button>
<button class="ghost-btn profile-top-action-btn profile-links-two-line" type="button" data-bottom-action="links">Показать\nсвязи</button>
`;
bottomActions.querySelector('[data-bottom-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
bottomActions.querySelector('[data-bottom-action="links"]')?.addEventListener('click', () => navigate(makeProfileLinksRoute(login)));
screen.append(bottomActions);
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card stack profile-main-card'; card.className = 'card stack profile-main-card';
@ -104,6 +61,13 @@ export function render({ navigate }) {
</div> </div>
`; `;
const statusRow = document.createElement('div');
statusRow.className = 'row profile-status-row';
statusRow.innerHTML = `
<div class="status-line" data-profile-status-line="true">Загрузка параметров...</div>
<button class="ghost-btn profile-refresh-btn" type="button" data-reload="true">Обновить</button>
`;
const badgesRow = document.createElement('div'); const badgesRow = document.createElement('div');
badgesRow.className = 'row'; badgesRow.className = 'row';
badgesRow.innerHTML = ` badgesRow.innerHTML = `
@ -114,6 +78,8 @@ export function render({ navigate }) {
const listWrap = document.createElement('div'); const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list'; listWrap.className = 'stack profile-param-list';
const reloadBtn = statusRow.querySelector('[data-reload="true"]');
const statusLineEl = statusRow.querySelector('[data-profile-status-line="true"]');
const officialBtn = badgesRow.querySelector('[data-toggle="official"]'); const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]'); const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const identityEl = topRow.querySelector('[data-profile-identity="true"]'); const identityEl = topRow.querySelector('[data-profile-identity="true"]');
@ -169,21 +135,6 @@ export function render({ navigate }) {
updateToggleButton(shineBtn, 'Сияющий', shine.enabled); updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
} }
officialBtn?.classList.add('profile-badge-trigger');
shineBtn?.classList.add('profile-badge-trigger');
officialBtn?.addEventListener('click', () => {
openProfileInfoModal({
title: 'Официальный канал',
text: officialInfoText(),
});
});
shineBtn?.addEventListener('click', () => {
openProfileInfoModal({
title: 'Справка о сияющих',
text: shineInfoText(),
});
});
function renderFields(fields) { function renderFields(fields) {
listWrap.innerHTML = ''; listWrap.innerHTML = '';
fields.forEach((field) => { fields.forEach((field) => {
@ -204,6 +155,10 @@ export function render({ navigate }) {
async function refreshProfileSnapshot() { async function refreshProfileSnapshot() {
try { try {
if (statusLineEl instanceof HTMLElement) {
statusLineEl.className = 'status-line';
statusLineEl.textContent = 'Загрузка параметров...';
}
const snapshot = await loadProfileSnapshot(login); const snapshot = await loadProfileSnapshot(login);
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : []; currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : []; currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
@ -213,12 +168,39 @@ export function render({ navigate }) {
updateAvatarUi(); updateAvatarUi();
updateTogglesUi(); updateTogglesUi();
renderFields(currentFields); renderFields(currentFields);
if (statusLineEl instanceof HTMLElement) {
statusLineEl.className = 'status-line is-available';
statusLineEl.textContent = 'Профиль обновлён.';
}
} catch (error) { } catch (error) {
// ignore status row in profile-view if (statusLineEl instanceof HTMLElement) {
statusLineEl.className = 'status-line is-unavailable';
statusLineEl.textContent = `Ошибка загрузки профиля: ${error?.message || 'unknown'}`;
}
} }
} }
card.append(topRow, badgesRow, listWrap); const showToggleInfo = (toggleKey) => {
const item = currentToggles.find((entry) => entry.key === toggleKey);
const isEnabled = Boolean(item?.enabled);
if (toggleKey === 'official') {
if (statusLineEl instanceof HTMLElement) statusLineEl.className = 'status-line is-available';
if (statusLineEl instanceof HTMLElement) statusLineEl.textContent = isEnabled
? 'Аккаунт является официальным.'
: 'Аккаунт не является официальным.';
return;
}
if (statusLineEl instanceof HTMLElement) statusLineEl.className = 'status-line is-available';
if (statusLineEl instanceof HTMLElement) statusLineEl.textContent = isEnabled
? 'Аккаунт является сияющим.'
: 'Аккаунт не является сияющим.';
};
reloadBtn?.addEventListener('click', refreshProfileSnapshot);
officialBtn?.addEventListener('click', () => showToggleInfo('official'));
shineBtn?.addEventListener('click', () => showToggleInfo('shine'));
card.append(topRow, badgesRow, listWrap, statusRow);
screen.append(card); screen.append(card);
updateAvatarUi(); updateAvatarUi();

View File

@ -132,8 +132,8 @@ export function render({ navigate }) {
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`); : `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
const nextHash = String(state.authReturnHash || '').trim(); const nextHash = String(state.authReturnHash || '').trim();
state.authReturnHash = ''; state.authReturnHash = '';
if (nextHash.startsWith('/')) { if (nextHash.startsWith('#/')) {
navigate(nextHash.slice(1)); navigate(nextHash.slice(2));
} else { } else {
navigate('profile-view'); navigate('profile-view');
} }

View File

@ -32,19 +32,13 @@ export function render({ navigate }) {
registerButton.textContent = 'Зарегистрироваться'; registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view')); registerButton.addEventListener('click', () => navigate('register-view'));
const guestViewButton = document.createElement('button');
guestViewButton.className = 'ghost-btn';
guestViewButton.type = 'button';
guestViewButton.textContent = 'Только просмотр';
guestViewButton.addEventListener('click', () => navigate('network-view'));
const settingsButton = document.createElement('button'); const settingsButton = document.createElement('button');
settingsButton.className = 'ghost-btn'; settingsButton.className = 'ghost-btn';
settingsButton.type = 'button'; settingsButton.type = 'button';
settingsButton.textContent = 'Настройки'; settingsButton.textContent = 'Настройки';
settingsButton.addEventListener('click', () => navigate('entry-settings-view')); settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, guestViewButton, settingsButton); actions.append(loginButton, registerButton, settingsButton);
screen.append(logo, title, actions); screen.append(logo, title, actions);
return screen; return screen;
} }

View File

@ -6,11 +6,8 @@ import {
loadUserProfileCard, loadUserProfileCard,
} from '../services/user-connections.js'; } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js'; export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
export const pageMeta = { id: 'user', title: 'Чужой профиль' };
function escapeHtml(text) { function escapeHtml(text) {
return String(text || '') return String(text || '')
@ -21,40 +18,6 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;'); .replaceAll("'", '&#39;');
} }
function openProfileInfoModal({ title, text }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="profile-info-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#profile-info-close')?.addEventListener('click', close);
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-info-modal') close();
});
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
}
function shineInfoText() {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
+ 'Пять принципов сияющих:\n'
+ '1) сияющие не обманывают;\n'
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
}
function genderText(value) { function genderText(value) {
const normalized = String(value || '').trim().toLowerCase(); const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'male') return 'Мужской'; if (normalized === 'male') return 'Мужской';
@ -63,28 +26,28 @@ function genderText(value) {
} }
function relationButtonLabel(kind, flags) { function relationButtonLabel(kind, flags) {
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты'; if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья'; if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
return flags.outFollow ? 'Отписаться' : 'Подписаться'; return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
} }
function relationNextState(kind, flags) { function relationNextState(kind, flags) {
if (kind === 'contact') return !flags.outContact; if (kind === 'follow') return !flags.outFollow;
if (kind === 'friend') return !flags.outFriend; if (kind === 'friend') return !flags.outFriend;
return !flags.outFollow; return !flags.outContact;
} }
function relationConfirmLabel(kind) { function relationConfirmLabel(kind) {
if (kind === 'contact') return 'контакт'; if (kind === 'follow') return 'подписку';
if (kind === 'friend') return 'статус близкого друга'; if (kind === 'friend') return 'статус близкого друга';
return 'подписку'; return 'контакт';
} }
function relationStateText(kind, flags) { function relationStateText(kind, flags) {
if (kind === 'contact') { if (kind === 'follow') {
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.'; if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
if (flags.outContact) return 'Вы добавили этот профиль в контакты.'; if (flags.outFollow) return 'Вы подписаны на этот профиль.';
if (flags.inContact) return 'Этот профиль добавил вас в контакты.'; if (flags.inFollow) return 'Этот профиль подписан на вас.';
return ''; return '';
} }
if (kind === 'friend') { if (kind === 'friend') {
@ -93,52 +56,12 @@ function relationStateText(kind, flags) {
if (flags.inFriend) return 'Этот профиль считает вас близким другом.'; if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
return ''; return '';
} }
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.'; if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
if (flags.outFollow) return 'Вы подписаны на этот профиль.'; if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
if (flags.inFollow) return 'Этот профиль подписан на вас.'; if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
return ''; return '';
} }
function opinionItemsFromFlags(flags) {
const items = [];
if (flags.outShineSeen) {
items.push({
kind: 'shine_seen',
text: 'вы утверждаете, что очень мало знаете этого человека, но вы видели его сияющим, и всё, что вы о нём знаете, подтверждает это',
label: 'видел сияющим',
});
}
if (flags.outShineConfirmed) {
items.push({
kind: 'shine_confirmed',
text: 'вы утверждаете, что достаточно хорошо знаете этого человека и точно уверены, что этот человек сияющий',
label: 'точно сияющий',
});
}
if (flags.outKnownPerson) {
items.push({
kind: 'known_person',
text: 'вы утверждаете, что просто знаете этого человека',
label: 'просто знаю',
});
}
return items;
}
function resolveActiveOpinionKind(flags) {
if (flags.outShineSeen) return 'shine_seen';
if (flags.outShineConfirmed) return 'shine_confirmed';
if (flags.outKnownPerson) return 'known_person';
return '';
}
function opinionLabelByKind(kind) {
if (kind === 'shine_seen') return 'мало знаком, но видел сияющим';
if (kind === 'shine_confirmed') return 'точно уверен, что сияющий';
if (kind === 'known_person') return 'просто знаю человека';
return kind;
}
function renderIdentity(card) { function renderIdentity(card) {
const lines = buildIdentityLines({ const lines = buildIdentityLines({
login: card.login, login: card.login,
@ -176,88 +99,31 @@ function renderIdentity(card) {
function renderReadOnlyBadges(card) { function renderReadOnlyBadges(card) {
return ` return `
<div class="row wrap-row"> <div class="row wrap-row">
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button> <span class="badge ${card.official ? 'is-yes-official' : 'is-no'}">Официальный: ${card.official ? 'Yes' : 'No'}</span>
<button class="badge profile-badge-trigger ${card.shine ? 'is-yes-shine' : 'is-no'}" type="button" data-profile-info="shine">Сияющий: ${card.shine ? 'Yes' : 'No'}</button> <span class="badge ${card.shine ? 'is-yes-shine' : 'is-no'}">Сияющий: ${card.shine ? 'Yes' : 'No'}</span>
</div> </div>
`; `;
} }
function renderRelations(flags) { function renderRelations(flags) {
const rows = [ const rows = [
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) }, { kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
]; ];
const opinionItems = opinionItemsFromFlags(flags);
const hasOpinion = opinionItems.length > 0;
return ` return `
<div class="card stack user-relations-list" data-profile-relations="true"> <div class="card stack user-relations-list">
${rows.map((row) => ` ${rows.map((row) => `
<div class="user-rel-row ${row.text ? '' : 'is-empty'}"> <div class="user-rel-row ${row.text ? '' : 'is-empty'}">
<span class="user-rel-text">${escapeHtml(row.text)}</span> <span class="user-rel-text">${escapeHtml(row.text)}</span>
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button> <button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
</div> </div>
`).join('')} `).join('')}
<div class="user-rel-opinions-wrap ${hasOpinion ? '' : 'is-empty'}">
<div class="user-rel-opinions-list">
${opinionItems.map((item) => `
<div class="user-rel-opinion-item">${escapeHtml(item.text)}</div>
`).join('')}
</div>
<div class="user-rel-opinions-hint">Добавьте одну из этих трёх формулировок.</div>
</div>
<div class="user-rel-row">
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}</button>
</div>
</div> </div>
`; `;
} }
function openOpinionMenuModal({ flags, onApply }) {
const root = document.getElementById('modal-root');
if (!root) return;
const activeKind = resolveActiveOpinionKind(flags);
const items = [
{ kind: 'known_person', title: 'просто знаю человека' },
{ kind: 'shine_confirmed', title: 'точно уверен, что сияющий' },
{ kind: 'shine_seen', title: 'мало знаком, но видел сияющим' },
];
const rowsHtml = items
.filter((item) => item.kind !== activeKind)
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Высказать: ${item.title}</button>`)
.join('');
const removeHtml = activeKind
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
: '';
root.innerHTML = `
<div class="modal" id="user-opinion-modal">
<div class="modal-card stack">
<h3 class="modal-title">${activeKind ? 'Изменить мнение' : 'Добавить мнение'}</h3>
<div class="stack">${rowsHtml}${removeHtml}</div>
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#user-opinion-modal-close')?.addEventListener('click', close);
root.querySelector('#user-opinion-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'user-opinion-modal') close();
});
root.querySelectorAll('[data-opinion-mode]').forEach((btn) => {
btn.addEventListener('click', async () => {
const nextKind = String(btn.getAttribute('data-opinion-kind') || '').trim();
const mode = String(btn.getAttribute('data-opinion-mode') || '').trim();
close();
if (!nextKind) return;
await onApply({ mode, nextKind, activeKind });
});
});
}
function renderReadOnlyParams(card) { function renderReadOnlyParams(card) {
const rows = [ const rows = [
{ label: 'Имя', value: card.firstName }, { label: 'Имя', value: card.firstName },
@ -281,6 +147,7 @@ function renderReadOnlyParams(card) {
export function render({ navigate, route }) { export function render({ navigate, route }) {
const requestedLogin = String(route.params.login || '').trim(); const requestedLogin = String(route.params.login || '').trim();
const fromPage = String(route.params.fromPage || 'messages-list').trim() || 'messages-list';
const sessionLogin = String(state.session.login || '').trim(); const sessionLogin = String(state.session.login || '').trim();
const screen = document.createElement('section'); const screen = document.createElement('section');
@ -296,14 +163,12 @@ export function render({ navigate, route }) {
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Профиль пользователя', title: 'Профиль пользователя',
leftAction: { label: '←', onClick: () => navigateBack() }, leftAction: { label: '←', onClick: () => navigate(fromPage) },
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }], rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
}), }),
status, status,
body, body,
); );
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
linksHeaderBtn?.classList.add('profile-links-header-btn');
let currentCard = null; let currentCard = null;
let currentFlags = null; let currentFlags = null;
@ -313,17 +178,14 @@ export function render({ navigate, route }) {
const followBtn = body.querySelector('[data-relation-action="follow"]'); const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]'); const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]'); const contactBtn = body.querySelector('[data-relation-action="contact"]');
const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]'); if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase(); const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
followBtn.textContent = relationButtonLabel('follow', currentFlags); followBtn.textContent = relationButtonLabel('follow', currentFlags);
contactBtn.disabled = Boolean(isSelf); friendBtn.textContent = relationButtonLabel('friend', currentFlags);
friendBtn.disabled = Boolean(isSelf); contactBtn.textContent = relationButtonLabel('contact', currentFlags);
followBtn.disabled = Boolean(isSelf); followBtn.disabled = Boolean(isSelf);
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение'; friendBtn.disabled = Boolean(isSelf);
opinionBtn.disabled = Boolean(isSelf); contactBtn.disabled = Boolean(isSelf);
} }
async function refresh() { async function refresh() {
@ -358,10 +220,6 @@ export function render({ navigate, route }) {
body.prepend(identityCard); body.prepend(identityCard);
syncActionButtons(); syncActionButtons();
if (String(route?.params?.section || '').toLowerCase() === 'links') {
const rel = body.querySelector('[data-profile-relations="true"]');
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
status.className = 'status-line is-available'; status.className = 'status-line is-available';
status.textContent = 'Профиль обновлён.'; status.textContent = 'Профиль обновлён.';
} catch (error) { } catch (error) {
@ -384,14 +242,6 @@ export function render({ navigate, route }) {
return; return;
} }
if (kind === 'opinion-menu') {
openOpinionMenuModal({
flags: currentFlags,
onApply: onOpinionApply,
});
return;
}
const nextEnabled = relationNextState(kind, currentFlags); const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm( const confirmed = window.confirm(
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` + `Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
@ -420,87 +270,13 @@ export function render({ navigate, route }) {
} }
} }
async function onOpinionApply({ mode, nextKind, activeKind }) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
const confirmed = window.confirm(`Изменить мнение о пользователе ${currentCard.login}?`);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
if (activeKind) {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: activeKind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
if (mode === 'set') {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: nextKind,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
}
await refresh();
if (mode === 'set') {
const opinionVisible = Boolean(
currentFlags?.outKnownPerson
|| currentFlags?.outShineConfirmed
|| currentFlags?.outShineSeen,
);
if (!opinionVisible) {
await new Promise((resolve) => window.setTimeout(resolve, 350));
await refresh();
}
}
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
body.addEventListener('click', (event) => { body.addEventListener('click', (event) => {
const target = event.target; const target = event.target;
if (!(target instanceof HTMLElement)) return; if (!(target instanceof HTMLElement)) return;
const infoBtn = target.closest('[data-profile-info]');
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
if (infoKind === 'official') {
openProfileInfoModal({
title: 'Официальный канал',
text: officialInfoText(),
});
return;
}
if (infoKind === 'shine') {
openProfileInfoModal({
title: 'Справка о сияющих',
text: shineInfoText(),
});
return;
}
const actionBtn = target.closest('[data-relation-action]'); const actionBtn = target.closest('[data-relation-action]');
const kind = String(actionBtn?.getAttribute('data-relation-action') || ''); const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
if (!kind) return; if (!kind) return;
void onRelationAction(kind); onRelationAction(kind);
}); });
refresh(); refresh();

View File

@ -1,8 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { import {
createRandomSolanaWallet,
createSolanaWalletFromPrivateBase58,
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getTopupSiteUrl, getTopupSiteUrl,
@ -19,7 +17,6 @@ import {
} from '../services/arweave-wallet-service.js'; } from '../services/arweave-wallet-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' }; export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
function nowRu() { function nowRu() {
return new Date().toLocaleString('ru-RU'); return new Date().toLocaleString('ru-RU');
@ -168,203 +165,6 @@ export function render({ navigate }) {
const sendBtn = actions.querySelector('#send-sol'); const sendBtn = actions.querySelector('#send-sol');
const topupBtn = actions.querySelector('#topup-sol'); const topupBtn = actions.querySelector('#topup-sol');
const generatedCard = document.createElement('div');
generatedCard.className = 'card stack';
generatedCard.innerHTML = `
<h3 style="margin:0;">Создание нового кошелька Solana</h3>
<p class="meta-muted" style="margin:0;">Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.</p>
`;
const privateLabel = document.createElement('label');
privateLabel.className = 'meta-muted';
privateLabel.textContent = 'Приватный ключ (Base58, 32 байта)';
privateLabel.setAttribute('for', 'solana-private-base58-input');
const privateInput = document.createElement('input');
privateInput.id = 'solana-private-base58-input';
privateInput.type = 'text';
privateInput.placeholder = 'Введите приватный ключ Base58';
privateInput.maxLength = SOLANA_PRIVATE_BASE58_MAX_LEN;
privateInput.autocomplete = 'off';
privateInput.spellcheck = false;
const privateState = document.createElement('p');
privateState.className = 'meta-muted';
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
const generatedPublicLabel = document.createElement('label');
generatedPublicLabel.className = 'meta-muted';
generatedPublicLabel.textContent = 'Публичный ключ (Base58)';
generatedPublicLabel.setAttribute('for', 'solana-generated-public-key');
const generatedPublicInput = document.createElement('input');
generatedPublicInput.id = 'solana-generated-public-key';
generatedPublicInput.type = 'text';
generatedPublicInput.readOnly = true;
generatedPublicInput.placeholder = 'Будет сгенерирован после нажатия кнопки';
const generatedPrivateLabel = document.createElement('label');
generatedPrivateLabel.className = 'meta-muted';
generatedPrivateLabel.textContent = 'Сгенерированный приватный ключ (Base58)';
generatedPrivateLabel.setAttribute('for', 'solana-generated-private-key');
const generatedPrivateInput = document.createElement('input');
generatedPrivateInput.id = 'solana-generated-private-key';
generatedPrivateInput.type = 'text';
generatedPrivateInput.readOnly = true;
generatedPrivateInput.placeholder = 'Появится после генерации';
const generationActions = document.createElement('div');
generationActions.className = 'row';
generationActions.innerHTML = `
<button class="primary-btn" id="generate-random-solana" style="width:100%;">Сгенерировать случайный кошелёк</button>
<button class="primary-btn" id="generate-from-private-solana" style="width:100%;">Сгенерировать из приватного ключа</button>
`;
const copyGeneratedActions = document.createElement('div');
copyGeneratedActions.className = 'row';
copyGeneratedActions.innerHTML = `
<button class="text-btn" id="copy-generated-private-solana" style="width:100%;">Копировать приватный</button>
<button class="text-btn" id="copy-generated-public-solana" style="width:100%;">Копировать публичный</button>
`;
generatedCard.append(
privateLabel,
privateInput,
privateState,
generationActions,
generatedPrivateLabel,
generatedPrivateInput,
generatedPublicLabel,
generatedPublicInput,
copyGeneratedActions,
);
const randomGenerateBtn = generationActions.querySelector('#generate-random-solana');
const fromPrivateGenerateBtn = generationActions.querySelector('#generate-from-private-solana');
const copyGeneratedPrivateBtn = copyGeneratedActions.querySelector('#copy-generated-private-solana');
const copyGeneratedPublicBtn = copyGeneratedActions.querySelector('#copy-generated-public-solana');
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
const validatePrivateInput = () => {
const value = String(privateInput.value || '').trim();
if (!value) {
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
return false;
}
if (!BASE58_RE.test(value)) {
privateState.textContent = 'Недопустимый формат: используйте только Base58.';
return false;
}
if (value.length > SOLANA_PRIVATE_BASE58_MAX_LEN) {
privateState.textContent = 'Слишком длинное значение.';
return false;
}
try {
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const c of value) {
num = num * 58n + BigInt(alphabet.indexOf(c));
}
let hex = num.toString(16);
if (hex.length % 2) hex = `0${hex}`;
const decoded = hex ? hex.match(/.{1,2}/g)?.map((h) => parseInt(h, 16)) || [] : [];
let leadingZeros = 0;
while (leadingZeros < value.length && value[leadingZeros] === '1') leadingZeros += 1;
const byteLen = leadingZeros + decoded.length;
if (byteLen < 32) {
privateState.textContent = 'Слишком короткое значение: нужно 32 байта.';
return false;
}
if (byteLen > 32) {
privateState.textContent = 'Слишком длинное значение: нужно ровно 32 байта.';
return false;
}
} catch {
privateState.textContent = 'Ошибка декодирования Base58.';
return false;
}
privateState.textContent = 'Подходит';
return true;
};
privateInput.addEventListener('input', () => {
validatePrivateInput();
});
const setGenerationDisabled = (disabled) => {
randomGenerateBtn.disabled = disabled;
fromPrivateGenerateBtn.disabled = disabled;
copyGeneratedPrivateBtn.disabled = disabled;
copyGeneratedPublicBtn.disabled = disabled;
};
randomGenerateBtn.addEventListener('click', async () => {
setGenerationDisabled(true);
try {
const generated = await createRandomSolanaWallet();
if (modeToken !== activeModeToken) return;
generatedPrivateInput.value = generated.privateKey32Base58;
generatedPublicInput.value = generated.address;
privateState.textContent = 'Случайный кошелёк создан.';
setStatus('Случайный кошелёк Solana успешно сгенерирован.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка генерации случайного кошелька: ${error?.message || 'unknown'}`);
} finally {
setGenerationDisabled(false);
}
});
fromPrivateGenerateBtn.addEventListener('click', async () => {
if (!validatePrivateInput()) {
setStatus('Исправьте приватный ключ перед генерацией.');
return;
}
setGenerationDisabled(true);
try {
const generated = await createSolanaWalletFromPrivateBase58(privateInput.value);
if (modeToken !== activeModeToken) return;
generatedPrivateInput.value = generated.privateKey32Base58;
generatedPublicInput.value = generated.address;
privateState.textContent = 'Подходит';
setStatus('Публичный ключ сгенерирован из введённого приватного ключа.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка генерации из приватного ключа: ${error?.message || 'unknown'}`);
} finally {
setGenerationDisabled(false);
}
});
copyGeneratedPrivateBtn.addEventListener('click', async () => {
const value = String(generatedPrivateInput.value || '').trim();
if (!value) {
setStatus('Сначала сгенерируйте приватный ключ.');
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus('Приватный ключ скопирован.');
} catch {
setStatus('Не удалось скопировать приватный ключ в этом браузере.');
}
});
copyGeneratedPublicBtn.addEventListener('click', async () => {
const value = String(generatedPublicInput.value || '').trim();
if (!value) {
setStatus('Сначала сгенерируйте публичный ключ.');
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus('Публичный ключ скопирован.');
} catch {
setStatus('Не удалось скопировать публичный ключ в этом браузере.');
}
});
const refreshBalance = async () => { const refreshBalance = async () => {
if (!walletAddress) { if (!walletAddress) {
setStatus('Кошелёк не инициализирован.'); setStatus('Кошелёк не инициализирован.');
@ -465,7 +265,7 @@ export function render({ navigate }) {
} }
}); });
content.append(backBtn, card, actions, generatedCard); content.append(backBtn, card, actions);
setStatus('Инициализация wallet.key...'); setStatus('Инициализация wallet.key...');
try { try {

View File

@ -1,5 +1,3 @@
import { parseShineRootSegment } from './services/shine-routes.js';
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view']; const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
export const PRE_AUTH_PAGES = [ export const PRE_AUTH_PAGES = [
@ -16,13 +14,10 @@ export const PRE_AUTH_PAGES = [
]; ];
export function getRoute() { export function getRoute() {
const currentPath = String(window.location.pathname || '').trim(); const raw = window.location.hash.replace(/^#\/?/, '');
const raw = currentPath if (!raw) {
.replace(/^\/+/, '') return { pageId: '', params: {} };
.replace(/^index\.html$/i, '') }
.replace(/^index\.html\//i, '')
.replace(/\/+$/, '');
if (!raw) return { pageId: '', params: {} };
const segments = raw.split('/').filter(Boolean); const segments = raw.split('/').filter(Boolean);
const pageId = segments[0] || ''; const pageId = segments[0] || '';
@ -36,73 +31,6 @@ export function getRoute() {
} }
}; };
const shineLogin = parseShineRootSegment(pageId);
if (shineLogin) {
const section = decodePart(segments[1] || '').toLowerCase();
if (!section) {
return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } };
}
if (section === 'links') {
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
}
if (section === 'channels') {
const sub = decodePart(segments[2] || '').toLowerCase();
if (sub === 'owned') {
return {
pageId: 'channels-list',
params: { mode: 'my', login: shineLogin, scope: 'owned' },
};
}
if (sub === 'following') {
return {
pageId: 'channels-list',
params: { mode: 'feed', login: shineLogin, scope: 'following' },
};
}
return {
pageId: 'channels-list',
params: { mode: 'feed', login: shineLogin, scope: 'all' },
};
}
if (section === 'msg') {
return {
pageId: 'channel-thread-view',
params: {
messageBlockchainName: decodePart(segments[2]),
messageBlockNumber: segments[3] || '',
messageBlockHash: '',
channelOwnerBlockchainName: '',
channelRootBlockNumber: '',
channelRootBlockHash: '',
},
};
}
if (section === 'channel') {
const ownerBlockchainName = decodePart(segments[2] || '');
const channelName = decodePart(segments[3] || '');
const messageBlockNumber = segments[4] || '';
if (ownerBlockchainName && channelName && messageBlockNumber) {
return {
pageId: 'channel-thread-view',
params: {
ownerBlockchainName,
channelName,
messageBlockNumber,
messageBlockHash: '',
messageBlockchainName: '',
channelOwnerBlockchainName: ownerBlockchainName,
channelRootBlockNumber: '',
channelRootBlockHash: '',
},
};
}
return {
pageId: 'channel-view',
params: { ownerBlockchainName, channelName, channelId: '' },
};
}
}
if (pageId === 'chat-view') { if (pageId === 'chat-view') {
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } }; return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
} }
@ -122,16 +50,51 @@ export function getRoute() {
return { pageId, params: { channelId: dynamicId || '' } }; return { pageId, params: { channelId: dynamicId || '' } };
} }
if (pageId === 'channel') {
// Короткий формат:
// #/channel/{ownerBlockchainName}/{channelName}
// #/channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber}
const ownerBlockchainName = decodePart(segments[1] || '');
const channelName = decodePart(segments[2] || '');
const messageBlockNumber = segments[3] || '';
if (ownerBlockchainName && channelName && messageBlockNumber) {
return {
pageId: 'channel-thread-view',
params: {
ownerBlockchainName,
channelName,
messageBlockNumber,
messageBlockHash: '',
// поддержка старого контракта страницы треда
messageBlockchainName: '',
channelOwnerBlockchainName: ownerBlockchainName,
channelRootBlockNumber: '',
channelRootBlockHash: '',
},
};
}
return {
pageId: 'channel-view',
params: {
ownerBlockchainName,
channelName,
channelId: '',
},
};
}
if (pageId === 'channel-thread-view') { if (pageId === 'channel-thread-view') {
return { return {
pageId, pageId,
params: { params: {
messageBlockchainName: decodePart(segments[1]), messageBlockchainName: decodePart(segments[1]),
messageBlockNumber: segments[2] || '', messageBlockNumber: segments[2] || '',
messageBlockHash: '', messageBlockHash: segments[3] || '',
channelOwnerBlockchainName: decodePart(segments[3]), channelOwnerBlockchainName: decodePart(segments[4]),
channelRootBlockNumber: segments[4] || '', channelRootBlockNumber: segments[5] || '',
channelRootBlockHash: segments[5] || '', channelRootBlockHash: segments[6] || '',
}, },
}; };
} }
@ -140,29 +103,39 @@ export function getRoute() {
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } }; return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
} }
if (pageId === 'user-profile-view') {
return {
pageId,
params: {
login: dynamicId ? decodeURIComponent(dynamicId) : '',
fromPage: segments[2] ? decodeURIComponent(segments[2]) : 'messages-list',
},
};
}
if (pageId === 'network-view') { if (pageId === 'network-view') {
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } }; return {
pageId,
params: {
mode: segments[1] ? decodePart(segments[1]) : '',
},
};
} }
if (pageId === 'channels-list') { if (pageId === 'channels-list') {
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } }; return {
pageId,
params: {
mode: segments[1] ? decodePart(segments[1]) : '',
},
};
} }
return { pageId, params: {} }; return { pageId, params: {} };
} }
export function navigate(path) { export function navigate(path) {
const cleanPath = String(path || '').replace(/^\/+/, ''); window.location.hash = `#/${path}`;
const nextPath = cleanPath ? `/${cleanPath}` : '/';
if (window.location.pathname !== nextPath) {
window.history.pushState({}, '', nextPath);
}
window.dispatchEvent(new PopStateEvent('popstate'));
}
export function navigateBack() {
if (window.history.length <= 1) return;
window.history.back();
} }
export function resolveToolbarActive(pageId) { export function resolveToolbarActive(pageId) {
@ -183,9 +156,11 @@ export function resolveToolbarActive(pageId) {
pageId === 'language-view' || pageId === 'language-view' ||
pageId === 'app-log-view' || pageId === 'app-log-view' ||
pageId === 'pwa-diagnostics-view' pageId === 'pwa-diagnostics-view'
) return 'profile-view'; ) {
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list'; return 'profile-view';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list'; }
if (pageId === 'user') return 'messages-list'; if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
if (pageId === 'user-profile-view') return 'messages-list';
return 'profile-view'; return 'profile-view';
} }

View File

@ -1,45 +0,0 @@
import { navigate } from '../router.js';
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function openAuthRequiredModal({
title = 'Нужен вход',
text = 'Эта часть доступна после входа в систему.',
startRoute = 'start-view',
} = {}) {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) {
window.alert(`${title}\n\n${text}`);
return;
}
root.innerHTML = `
<div class="modal" id="auth-required-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="auth-required-close">Закрыть</button>
<button class="primary-btn" type="button" id="auth-required-start">На старт</button>
</div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#auth-required-close')?.addEventListener('click', close);
root.querySelector('#auth-required-start')?.addEventListener('click', () => {
close();
navigate(startRoute);
});
root.querySelector('#auth-required-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'auth-required-modal') close();
});
}

View File

@ -37,9 +37,7 @@ 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;
@ -61,9 +59,6 @@ const CONNECTION_SUBTYPES = Object.freeze({
parent: { on: 50, off: 51 }, parent: { on: 50, off: 51 },
child: { on: 52, off: 53 }, child: { on: 52, off: 53 },
sibling: { on: 54, off: 55 }, sibling: { on: 54, off: 55 },
known_person: { on: 60, off: 61 },
shine_confirmed: { on: 70, off: 71 },
shine_seen: { on: 74, off: 75 },
}); });
function normalizeServerUrl(url) { function normalizeServerUrl(url) {
@ -371,56 +366,6 @@ 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,
@ -1010,98 +955,6 @@ 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');
@ -1381,15 +1234,10 @@ export class AuthService {
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber); const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, ''); const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
const latestVersionsTotal = Number(latestMessage?.versionsTotal);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) { if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber; prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash; prevLineHashHex = latestBlockHash;
// В line-цепочке thisLineNumber — это номер шага линии, а не глобальный blockNumber. thisLineNumber = latestBlockNumber + 1;
// Для следующего POST берем шаг после последней известной версии сообщения.
thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0
? Math.max(0, latestVersionsTotal)
: 1;
} }
} catch { } catch {
// fallback to root anchor // fallback to root anchor

View File

@ -1,65 +0,0 @@
function encodeRoutePart(value = '') {
return encodeURIComponent(String(value || '').trim());
}
function decodeRoutePart(value = '') {
try {
return decodeURIComponent(String(value || ''));
} catch {
return String(value || '');
}
}
export function normalizeLogin(value = '') {
return String(value || '').trim().replace(/^@+/, '');
}
export function extractLoginFromBlockchainName(value = '') {
const raw = String(value || '').trim();
const match = raw.match(/^(.+)-\d+$/);
if (!match) return normalizeLogin(raw);
return normalizeLogin(String(match[1] || ''));
}
export function makeProfileRoute(login = '') {
const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile-view';
}
export function makeProfileLinksRoute(login = '') {
const clean = normalizeLogin(login);
return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network-view/keep-history';
}
export function makeProfileChannelsRoute(login = '', scope = 'all') {
const clean = normalizeLogin(login);
if (!clean) return 'channels-list/feed';
const normalizedScope = String(scope || '').trim().toLowerCase();
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
return `shine.${encodeRoutePart(clean)}/channels`;
}
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName);
const ownerBch = String(ownerBlockchainName || '').trim();
const chName = String(channelName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!cleanOwnerLogin || !ownerBch || !chName) return '';
if (msgNo) return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
}
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
const cleanOwnerLogin = normalizeLogin(ownerLogin);
const msgBch = String(messageBlockchainName || '').trim();
const msgNo = String(messageBlockNumber || '').trim();
if (!cleanOwnerLogin || !msgBch || !msgNo) return '';
return `shine.${encodeRoutePart(cleanOwnerLogin)}/msg/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
}
export function parseShineRootSegment(segment = '') {
const raw = String(segment || '').trim();
if (!raw.toLowerCase().startsWith('shine.')) return '';
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
}

View File

@ -6,7 +6,6 @@ const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/'; const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
let solanaLibPromise = null; let solanaLibPromise = null;
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
function normalizeEndpoint(url) { function normalizeEndpoint(url) {
const raw = String(url || '').trim(); const raw = String(url || '').trim();
@ -38,34 +37,6 @@ export async function deriveWalletFromPassword(password) {
}; };
} }
export async function createRandomSolanaWallet() {
const solana = await loadSolanaLib();
const keypair = solana.Keypair.generate();
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
return {
address: keypair.publicKey.toBase58(),
privateKey32Base58,
keypair,
};
}
export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
const solana = await loadSolanaLib();
const clean = String(privateKey32Base58 || '').trim();
if (!clean) throw new Error('Введите приватный ключ');
if (!BASE58_RE.test(clean)) throw new Error('Разрешены только символы Base58');
const privateBytes = solana.bs58.decode(clean);
if (privateBytes.length !== 32) {
throw new Error('Приватный ключ должен быть ровно 32 байта в Base58');
}
const keypair = solana.Keypair.fromSeed(privateBytes);
return {
address: keypair.publicKey.toBase58(),
privateKey32Base58: clean,
keypair,
};
}
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) { export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim(); const cleanPwd = String(storagePwd || '').trim();

View File

@ -47,23 +47,7 @@ function toToggleMap(snapshot) {
} }
function readArray(payload, key) { function readArray(payload, key) {
const aliases = { const value = payload?.[key];
outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
inKnownPersons: ['inKnownPersons', 'inKnownPerson', 'in_known_persons'],
outShineConfirmed: ['outShineConfirmed', 'outShineConfident', 'out_shine_confirmed'],
inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
outShineSeen: ['outShineSeen', 'out_shine_seen'],
inShineSeen: ['inShineSeen', 'in_shine_seen'],
};
const keys = aliases[key] || [key];
let value = null;
for (const oneKey of keys) {
const candidate = payload?.[oneKey];
if (Array.isArray(candidate)) {
value = candidate;
break;
}
}
return Array.isArray(value) ? uniqueLogins(value) : null; return Array.isArray(value) ? uniqueLogins(value) : null;
} }
@ -91,12 +75,6 @@ async function buildRelationsModel(login) {
inChildren: [], inChildren: [],
outSiblings: [], outSiblings: [],
inSiblings: [], inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
inShineSeen: [],
}; };
} }
@ -139,12 +117,6 @@ async function buildRelationsModel(login) {
inChildren: readArray(graph, 'inChildren') || [], inChildren: readArray(graph, 'inChildren') || [],
outSiblings: readArray(graph, 'outSiblings') || [], outSiblings: readArray(graph, 'outSiblings') || [],
inSiblings: readArray(graph, 'inSiblings') || [], inSiblings: readArray(graph, 'inSiblings') || [],
outKnownPersons: readArray(graph, 'outKnownPersons') || [],
inKnownPersons: readArray(graph, 'inKnownPersons') || [],
outShineConfirmed: readArray(graph, 'outShineConfirmed') || [],
inShineConfirmed: readArray(graph, 'inShineConfirmed') || [],
outShineSeen: readArray(graph, 'outShineSeen') || [],
inShineSeen: readArray(graph, 'inShineSeen') || [],
}; };
} }
@ -179,12 +151,6 @@ export async function loadCurrentRelations() {
inChildren: [], inChildren: [],
outSiblings: [], outSiblings: [],
inSiblings: [], inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
inShineSeen: [],
}; };
} }
return buildRelationsModel(login); return buildRelationsModel(login);
@ -204,12 +170,6 @@ export function relationFlagsForTarget(relations, targetLogin) {
inChild: listContainsLogin(relations?.inChildren, targetLogin), inChild: listContainsLogin(relations?.inChildren, targetLogin),
outSibling: listContainsLogin(relations?.outSiblings, targetLogin), outSibling: listContainsLogin(relations?.outSiblings, targetLogin),
inSibling: listContainsLogin(relations?.inSiblings, targetLogin), inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
outKnownPerson: listContainsLogin(relations?.outKnownPersons, targetLogin),
inKnownPerson: listContainsLogin(relations?.inKnownPersons, targetLogin),
outShineConfirmed: listContainsLogin(relations?.outShineConfirmed, targetLogin),
inShineConfirmed: listContainsLogin(relations?.inShineConfirmed, targetLogin),
outShineSeen: listContainsLogin(relations?.outShineSeen, targetLogin),
inShineSeen: listContainsLogin(relations?.inShineSeen, targetLogin),
}; };
} }

View File

@ -644,8 +644,6 @@
.avatar-image { .avatar-image {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: grid;
place-items: center;
} }
.avatar-image > .avatar-fallback, .avatar-image > .avatar-fallback,
@ -1206,10 +1204,6 @@ 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;
@ -1641,49 +1635,6 @@ textarea.input {
color: transparent; color: transparent;
} }
.user-rel-opinions-wrap {
display: grid;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
border: 1px dashed rgba(131, 196, 255, 0.45);
background: rgba(9, 18, 31, 0.42);
}
.user-rel-opinions-wrap.is-empty .user-rel-opinions-list {
display: none;
}
.user-rel-opinions-list {
display: grid;
gap: 6px;
}
.user-rel-opinion-item {
color: #d7e6ff;
line-height: 1.35;
font-size: 13px;
}
.user-rel-opinions-hint {
color: rgba(173, 199, 236, 0.9);
font-size: 12px;
}
.user-opinion-modal-btn {
text-align: left;
}
.user-opinion-modal-btn.is-add {
border-color: rgba(97, 170, 255, 0.7);
color: #9fcbff;
}
.user-opinion-modal-btn.is-remove {
border-color: rgba(255, 120, 120, 0.72);
color: #ff9b9b;
}
.tabs { .tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -1830,14 +1781,6 @@ textarea.input {
.channels-screen .page-header { .channels-screen .page-header {
margin-bottom: 0; margin-bottom: 0;
align-items: flex-end; align-items: flex-end;
position: sticky;
top: calc(-1 * max(10px, env(safe-area-inset-top)));
z-index: 14;
padding: calc(max(10px, env(safe-area-inset-top)) + 2px) 0 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(8, 12, 20, 0.9);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
} }
.channels-screen .page-header .icon-btn, .channels-screen .page-header .icon-btn,
@ -2228,47 +2171,21 @@ textarea.input {
gap: 12px; gap: 12px;
} }
.channel-message-author-tile {
appearance: none;
-webkit-appearance: none;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(19, 24, 33, 0.9);
color: #f5f8ff;
text-align: left;
cursor: pointer;
min-height: 0;
}
.channel-message-author-tile:hover {
background: rgba(28, 35, 48, 0.94);
}
.channel-message-avatar { .channel-message-avatar {
width: 40px; width: 44px;
height: 40px; height: 44px;
min-width: 40px; min-width: 44px;
min-height: 40px; min-height: 44px;
border-radius: 50%; border-radius: 50%;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 16px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: #f4f6ff; color: #f4f6ff;
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89); background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
} }
.channel-message-avatar.avatar-image {
display: grid;
place-items: center;
}
.channel-message-author { .channel-message-author {
display: grid; display: grid;
gap: 4px; gap: 4px;
@ -2276,7 +2193,7 @@ textarea.input {
} }
.channel-message-title { .channel-message-title {
font-size: 15px; font-size: 20px;
color: #f5f8ff; color: #f5f8ff;
} }
@ -2292,16 +2209,8 @@ 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: 12px;
color: rgba(255, 255, 255, 0.48); color: rgba(255, 255, 255, 0.48);
} }
@ -2381,25 +2290,6 @@ 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);
@ -2430,31 +2320,6 @@ textarea.input {
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
.channel-header-route-btn {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -66%) !important;
max-width: 72vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
min-height: 40px;
border-radius: 10px;
text-align: center;
}
.channels-screen .page-header .channel-header-route-btn:hover,
.channels-screen .page-header .channel-header-route-btn:focus-visible {
transform: translate(-50%, -66%) !important;
}
.channels-screen .page-header .channel-header-route-btn:active,
.channels-screen .page-header .channel-header-route-btn.is-springing {
transform: translate(-50%, -66%) !important;
}
.thread-node-heading { .thread-node-heading {
color: #f1dcab; color: #f1dcab;
font-size: 15px; font-size: 15px;
@ -2491,15 +2356,10 @@ textarea.input {
.thread-node-actions { .thread-node-actions {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }
.channels-screen--thread .thread-node-actions {
display: flex !important;
grid-template-columns: none !important;
}
.thread-node-level { .thread-node-level {
--depth: 0; --depth: 0;
margin-left: calc(var(--depth) * 12px); margin-left: calc(var(--depth) * 12px);
@ -2508,22 +2368,11 @@ textarea.input {
.thread-block { .thread-block {
gap: 8px; gap: 8px;
border-radius: 15px; border-radius: 15px;
padding: 8px; padding: 10px;
border: 1px solid rgba(151, 174, 221, 0.2); border: 1px solid rgba(151, 174, 221, 0.2);
background: linear-gradient(160deg, rgba(10, 21, 43, 0.72), rgba(8, 16, 31, 0.78)); background: linear-gradient(160deg, rgba(10, 21, 43, 0.72), rgba(8, 16, 31, 0.78));
} }
.channels-screen--thread .thread-node-card {
padding: 14px;
margin-bottom: 10px;
}
.thread-history-divider {
height: 0;
border-top: 2px solid rgba(255, 255, 255, 0.26);
margin: 6px 0 10px;
}
.thread-block--ancestors > .section-title { .thread-block--ancestors > .section-title {
color: #b9cbef; color: #b9cbef;
} }
@ -2590,10 +2439,6 @@ textarea.input {
color: rgba(255, 255, 255, 0.55); color: rgba(255, 255, 255, 0.55);
} }
.thread-open-btn {
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 430px) { @media (max-width: 430px) {
.channels-screen .page-title { .channels-screen .page-title {
font-size: 26px; font-size: 26px;
@ -2727,7 +2572,7 @@ textarea.input {
.channels-tabs { .channels-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 8px;
padding: 6px; padding: 6px;
border-radius: 14px; border-radius: 14px;
@ -2735,19 +2580,6 @@ textarea.input {
background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96)); background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
} }
.channels-top-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.channels-top-action-btn {
min-height: 38px;
padding: 8px 12px;
white-space: nowrap;
}
.channels-tab-btn { .channels-tab-btn {
min-height: 38px; min-height: 38px;
border-radius: 10px; border-radius: 10px;
@ -3312,14 +3144,6 @@ textarea.input {
background: none; background: none;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
box-shadow: none; box-shadow: none;
transform: translateY(-40%);
}
.channels-screen--channel .page-header .channel-header-route-btn,
.channels-screen--thread .page-header .channel-header-route-btn {
border: 1px solid rgba(146, 173, 229, 0.38);
background: linear-gradient(180deg, rgba(20, 37, 67, 0.9), rgba(13, 24, 47, 0.94));
color: #d9e6ff;
} }
.channel-head-actions .secondary-btn { .channel-head-actions .secondary-btn {
@ -3499,10 +3323,6 @@ textarea.input {
font-weight: 700; font-weight: 700;
} }
.dm-screen .list-item {
align-items: stretch;
}
.dm-screen .meta-muted { .dm-screen .meta-muted {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
@ -3525,49 +3345,6 @@ textarea.input {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32);
} }
.dm-row-meta-col {
display: grid;
justify-items: end;
align-content: end;
gap: 6px;
min-width: 64px;
align-self: stretch;
}
.dm-row-main {
min-width: 0;
display: grid;
grid-template-rows: auto auto;
gap: 4px;
}
.dm-row-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.dm-row-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-row-last-message {
margin-top: 0 !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 6px;
}
.dm-row-time {
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.dm-chat-wrap { .dm-chat-wrap {
gap: 12px; gap: 12px;
} }
@ -3604,16 +3381,7 @@ textarea.input {
.dm-chat-input { .dm-chat-input {
gap: 10px; gap: 10px;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto auto auto;
align-items: end;
position: sticky;
bottom: 0;
z-index: 10;
padding: 10px;
border-top: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(8, 12, 20, 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
} }
.dm-voice-btn { .dm-voice-btn {
@ -3621,21 +3389,6 @@ textarea.input {
padding: 0 10px; padding: 0 10px;
} }
.dm-actions-col {
display: grid;
grid-template-rows: auto auto;
gap: 6px;
}
.dm-chat-input .dm-input {
min-height: 42px;
max-height: 180px;
resize: none;
overflow-y: auto;
line-height: 1.35;
padding: 10px 12px;
}
.voice-level-wrap { .voice-level-wrap {
width: 100%; width: 100%;
height: 8px; height: 8px;
@ -3673,35 +3426,6 @@ textarea.input {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
} }
.dm-send-icon-btn {
min-width: 42px;
width: 42px;
padding: 0;
font-size: 15px;
line-height: 1;
}
.dm-message-actions-menu {
width: min(52vw, 240px);
padding: 8px;
gap: 6px;
}
.dm-message-action-btn {
width: 100%;
justify-content: flex-start;
}
.speech-actions-top {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.speech-send-now-btn {
width: 100%;
}
/* DM messages-list status + empty block as full glass buttons */ /* DM messages-list status + empty block as full glass buttons */
.dm-screen .dm-status-line { .dm-screen .dm-status-line {
display: block; display: block;
@ -3821,12 +3545,6 @@ textarea.input {
box-shadow: inset 0 1px 0 rgba(255, 238, 197, 0.3); box-shadow: inset 0 1px 0 rgba(255, 238, 197, 0.3);
} }
.channels-screen--list .channels-tab-btn.is-disabled {
text-decoration: line-through;
text-decoration-thickness: 1.5px;
opacity: 0.72;
}
.toolbar { .toolbar {
background: rgba(18, 24, 38, 0.4); background: rgba(18, 24, 38, 0.4);
backdrop-filter: blur(25px); backdrop-filter: blur(25px);
@ -4117,13 +3835,7 @@ textarea.input {
.profile-top-actions { .profile-top-actions {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1.6fr 1fr 1fr;
gap: 5px;
}
.profile-bottom-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px; gap: 5px;
} }
@ -4132,18 +3844,13 @@ textarea.input {
min-height: 32px; min-height: 32px;
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 12px;
line-height: 1.15; line-height: 1;
text-align: center; text-align: center;
white-space: pre-line; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.profile-links-header-btn {
white-space: pre-line;
line-height: 1.1;
}
.profile-main-card { .profile-main-card {
margin-top: 0; margin-top: 0;
padding: 2px 8px 8px; padding: 2px 8px 8px;

View File

@ -113,21 +113,6 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */ /** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55; public static final short CONNECTION_UNSIBLING = 55;
/** Просто знаю этого человека. */
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
public static final short CONNECTION_SHINE_CONFIRMED = 70;
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/* ===================== USER_PARAM (msg_type=4) ===================== */ /* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */ /** Параметр профиля key/value (обе строки). */

View File

@ -16,13 +16,9 @@ import java.util.Objects;
* FRIEND=10, UNFRIEND=11 * FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21 * CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31 * FOLLOW=30, UNFOLLOW=31
* SPOUSE=40, UNSPOUSE=41
* PARENT=50, UNPARENT=51 * PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53 * CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55 * SIBLING=54, UNSIBLING=55
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75
* *
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* [4] lineCode * [4] lineCode
@ -196,13 +192,7 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF) || v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF) || v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF) || v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF);
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
} }
@Override @Override

View File

@ -105,7 +105,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.toBlockHash32 = null; this.toBlockHash32 = null;
} }
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)); this.message = readStrictUtf8Len16(bb, "TextLineBody text");
ensureNoTail(bb, "TextLineBody"); ensureNoTail(bb, "TextLineBody");
} }
@ -129,9 +129,7 @@ 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 (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) { if (message.isBlank()) throw new IllegalArgumentException("message is blank");
throw new IllegalArgumentException("message is blank");
}
this.subType = subType; this.subType = subType;
this.version = VER; this.version = VER;
@ -167,15 +165,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");
} }
@ -186,12 +184,10 @@ 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)) {
@ -238,12 +234,9 @@ 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, boolean allowEmpty) { private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) { if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
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];
@ -255,7 +248,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 (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); if (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);

View File

@ -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", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)); this.message = readStrictUtf8Len16(bb, "TextReplyBody text");
ensureNoTail(bb, "TextReplyBody"); ensureNoTail(bb, "TextReplyBody");
} }
@ -113,10 +113,8 @@ 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");
@ -144,18 +142,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");
} }
@ -166,12 +164,10 @@ 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");
@ -217,12 +213,9 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
/* ====================== helpers ====================== */ /* ====================== helpers ====================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) { private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
int len = Short.toUnsignedInt(bb.getShort()); int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) { if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
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];
@ -234,7 +227,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 (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); if (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);

View File

@ -66,15 +66,6 @@ public final class DatabaseInitializer {
public static final short CONNECTION_SIBLING = 54; public static final short CONNECTION_SIBLING = 54;
public static final short CONNECTION_UNSIBLING = 55; public static final short CONNECTION_UNSIBLING = 55;
public static final short CONNECTION_KNOWN_PERSON = 60;
public static final short CONNECTION_UNKNOWN_PERSON = 61;
public static final short CONNECTION_SHINE_CONFIRMED = 70;
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
public static final short CONNECTION_SHINE_SEEN = 74;
public static final short CONNECTION_SHINE_UNSEEN = 75;
public static void createNewDB(String[] args) { public static void createNewDB(String[] args) {
AppConfig config = AppConfig.getInstance(); AppConfig config = AppConfig.getInstance();
String dbPath = config.getParam("db.path"); String dbPath = config.getParam("db.path");

View File

@ -198,9 +198,6 @@ public final class DatabaseTriggersInstaller {
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT; int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD; int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING; int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
@ -209,16 +206,13 @@ public final class DatabaseTriggersInstaller {
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT; int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD; int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING; int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
st.executeUpdate(""" st.executeUpdate("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
AFTER INSERT ON blocks AFTER INSERT ON blocks
WHEN NEW.msg_type = 3 WHEN NEW.msg_type = 3
BEGIN BEGIN
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*: -- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING:
-- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј -- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј
INSERT OR IGNORE INTO connections_state ( INSERT OR IGNORE INTO connections_state (
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
@ -228,12 +222,6 @@ public final class DatabaseTriggersInstaller {
NEW.msg_sub_type, NEW.msg_sub_type,
COALESCE( COALESCE(
NEW.to_login, NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE CASE
WHEN NEW.to_bch_name IS NOT NULL WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4 AND length(NEW.to_bch_name) > 4
@ -245,15 +233,9 @@ public final class DatabaseTriggersInstaller {
NEW.to_bch_name, NEW.to_bch_name,
NEW.to_block_number, NEW.to_block_number,
NEW.to_block_hash NEW.to_block_hash
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d)
AND COALESCE( AND COALESCE(
NEW.to_login, NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE CASE
WHEN NEW.to_bch_name IS NOT NULL WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4 AND length(NEW.to_bch_name) > 4
@ -274,12 +256,6 @@ public final class DatabaseTriggersInstaller {
AND rel_type = NEW.msg_sub_type AND rel_type = NEW.msg_sub_type
AND to_login = COALESCE( AND to_login = COALESCE(
NEW.to_login, NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE CASE
WHEN NEW.to_bch_name IS NOT NULL WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4 AND length(NEW.to_bch_name) > 4
@ -288,15 +264,9 @@ public final class DatabaseTriggersInstaller {
ELSE NULL ELSE NULL
END END
) )
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d) AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d)
AND COALESCE( AND COALESCE(
NEW.to_login, NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE CASE
WHEN NEW.to_bch_name IS NOT NULL WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4 AND length(NEW.to_bch_name) > 4
@ -307,18 +277,12 @@ public final class DatabaseTriggersInstaller {
) IS NOT NULL ) IS NOT NULL
AND NEW.to_bch_name IS NOT NULL; AND NEW.to_bch_name IS NOT NULL;
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*: -- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING:
-- удаляем СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ -- удаляем СЃРѕРѕСРІРµССЃСРІСѓСЋСее "позитивное" СЃРѕСЃСРѕСЏРЅРёРµ
DELETE FROM connections_state DELETE FROM connections_state
WHERE login = NEW.login WHERE login = NEW.login
AND to_login = COALESCE( AND to_login = COALESCE(
NEW.to_login, NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE CASE
WHEN NEW.to_bch_name IS NOT NULL WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4 AND length(NEW.to_bch_name) > 4
@ -335,19 +299,10 @@ public final class DatabaseTriggersInstaller {
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
WHEN %d THEN %d
ELSE rel_type ELSE rel_type
END END
AND COALESCE( AND COALESCE(
NEW.to_login, NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE CASE
WHEN NEW.to_bch_name IS NOT NULL WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4 AND length(NEW.to_bch_name) > 4
@ -356,11 +311,11 @@ public final class DatabaseTriggersInstaller {
ELSE NULL ELSE NULL
END END
) IS NOT NULL ) IS NOT NULL
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d); AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d);
END; END;
""".formatted( """.formatted(
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING,
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN, FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING,
UNFRIEND, FRIEND, UNFRIEND, FRIEND,
UNCONTACT, CONTACT, UNCONTACT, CONTACT,
@ -369,11 +324,8 @@ public final class DatabaseTriggersInstaller {
UNPARENT, PARENT, UNPARENT, PARENT,
UNCHILD, CHILD, UNCHILD, CHILD,
UNSIBLING, SIBLING, UNSIBLING, SIBLING,
UNKNOWN, KNOWN,
SHINE_UNCONF, SHINE_CONF,
SHINE_UNSEEN, SHINE_SEEN,
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING
)); ));
} }

View File

@ -40,10 +40,8 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */ /* ===================== CONNECTION (msg_type=3) ===================== */
/** /**
* Совпадает с ConnectionBody: * Совпадает с ConnectionBody:
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54, * SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74 * UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
*/ */
/** Добавить в близкие друзья (close friend). */ /** Добавить в близкие друзья (close friend). */
@ -94,24 +92,6 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */ /** Удалить связь "брат/сестра". */
public static final short CONNECTION_UNSIBLING = 55; public static final short CONNECTION_UNSIBLING = 55;
/** Просто знаю этого человека. */
public static final short CONNECTION_KNOWN_PERSON = 60;
/** Не знаю этого человека. */
public static final short CONNECTION_UNKNOWN_PERSON = 61;
/** Точно уверен, что сияющий. */
public static final short CONNECTION_SHINE_CONFIRMED = 70;
/** Не подтверждаю, что сияющий. */
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
/** Мало знаком, но видел сияющим. */
public static final short CONNECTION_SHINE_SEEN = 74;
/** Не отмечаю, что видел сияющим. */
public static final short CONNECTION_SHINE_UNSEEN = 75;
/* ===================== USER_PARAM (msg_type=4) ===================== */ /* ===================== USER_PARAM (msg_type=4) ===================== */
/** Параметр профиля key/value (обе строки). */ /** Параметр профиля key/value (обе строки). */

View File

@ -40,15 +40,13 @@ public final class ConnectionsStateDAO {
*/ */
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException { public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
String sql = """ String sql = """
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login SELECT u.login AS friend_login
FROM connections_state cs FROM connections_state cs
LEFT JOIN solana_users u_login JOIN solana_users u
ON u_login.login = cs.to_login COLLATE NOCASE ON u.login = cs.to_login COLLATE NOCASE
LEFT JOIN solana_users u_bch
ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
WHERE cs.login = ? COLLATE NOCASE WHERE cs.login = ? COLLATE NOCASE
AND cs.rel_type = ? AND cs.rel_type = ?
ORDER BY friend_login ORDER BY u.login
"""; """;
List<String> out = new ArrayList<>(); List<String> out = new ArrayList<>();
@ -70,25 +68,19 @@ public final class ConnectionsStateDAO {
*/ */
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException { public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
String sql = """ String sql = """
SELECT COALESCE(u_actor.login, cs.login) AS friend_login SELECT u.login AS friend_login
FROM connections_state cs FROM connections_state cs
LEFT JOIN solana_users u_actor JOIN solana_users u
ON u_actor.login = cs.login COLLATE NOCASE ON u.login = cs.login COLLATE NOCASE
LEFT JOIN solana_users u_target WHERE cs.to_login = ? COLLATE NOCASE
ON u_target.login = ? COLLATE NOCASE
WHERE (
cs.to_login = ? COLLATE NOCASE
OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
)
AND cs.rel_type = ? AND cs.rel_type = ?
ORDER BY friend_login ORDER BY u.login
"""; """;
List<String> out = new ArrayList<>(); List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase); ps.setString(1, loginAnyCase);
ps.setString(2, loginAnyCase); ps.setInt(2, relType);
ps.setInt(3, relType);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) { while (rs.next()) {
String v = rs.getString("friend_login"); String v = rs.getString("friend_login");

View File

@ -410,23 +410,10 @@ 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(targetBchName); be.setToBchName(t.toBchName());
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_* это "редактирование блока цели"

View File

@ -218,17 +218,16 @@ 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_block_number=? AND to_block_hash=? AND to_bch_name=? 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.setInt(4, originalBlock); ps.setString(4, ownerBch);
ps.setBytes(5, originalHash); ps.setInt(5, originalBlock);
ps.setString(6, ownerBch); ps.setBytes(6, originalHash);
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()) {

View File

@ -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, post.bchName, post.blockNumber, post.blockHash); List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, ownerBch, 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();

View File

@ -196,16 +196,15 @@ 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_block_number=? AND to_block_hash=? AND to_bch_name=? 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.setInt(3, targetBlock); ps.setString(3, bch);
ps.setBytes(4, targetHash); ps.setInt(4, targetBlock);
ps.setString(5, bch); ps.setBytes(5, targetHash);
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));

View File

@ -30,14 +30,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
@Override @Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest; Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? "" : req.getLogin().trim(); if (ctx == null || !ctx.isAuthenticatedUser()) {
if (requestedLogin.isEmpty()) { return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
if (ctx != null && ctx.isAuthenticatedUser()) {
requestedLogin = ctx.getLogin();
} else {
return NetExceptionResponseFactory.error(req, 422, "LOGIN_REQUIRED", "Нужно передать login пользователя");
}
} }
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim();
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
String canonicalLogin = findCanonicalLogin(c, requestedLogin); String canonicalLogin = findCanonicalLogin(c, requestedLogin);
@ -59,18 +55,11 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD); List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING); List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING); List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
List<String> outKnownPersons = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
List<String> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
LinkedHashSet<String> allLogins = new LinkedHashSet<>(); LinkedHashSet<String> allLogins = new LinkedHashSet<>();
allLogins.add(canonicalLogin); allLogins.add(canonicalLogin);
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows, addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings, outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings);
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins); Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses); List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
@ -97,12 +86,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setInChildren(inChildren); resp.setInChildren(inChildren);
resp.setOutSiblings(outSiblings); resp.setOutSiblings(outSiblings);
resp.setInSiblings(inSiblings); resp.setInSiblings(inSiblings);
resp.setOutKnownPersons(outKnownPersons);
resp.setInKnownPersons(inKnownPersons);
resp.setOutShineConfirmed(outShineConfirmed);
resp.setInShineConfirmed(inShineConfirmed);
resp.setOutShineSeen(outShineSeen);
resp.setInShineSeen(inShineSeen);
resp.setParents(toRelativeItems(parentLogins, metaByLogin)); resp.setParents(toRelativeItems(parentLogins, metaByLogin));
resp.setChildren(toRelativeItems(childLogins, metaByLogin)); resp.setChildren(toRelativeItems(childLogins, metaByLogin));
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin)); resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));

View File

@ -21,12 +21,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private List<String> inChildren = new ArrayList<>(); private List<String> inChildren = new ArrayList<>();
private List<String> outSiblings = new ArrayList<>(); private List<String> outSiblings = new ArrayList<>();
private List<String> inSiblings = new ArrayList<>(); private List<String> inSiblings = new ArrayList<>();
private List<String> outKnownPersons = new ArrayList<>();
private List<String> inKnownPersons = new ArrayList<>();
private List<String> outShineConfirmed = new ArrayList<>();
private List<String> inShineConfirmed = new ArrayList<>();
private List<String> outShineSeen = new ArrayList<>();
private List<String> inShineSeen = new ArrayList<>();
private List<RelativeItem> parents = new ArrayList<>(); private List<RelativeItem> parents = new ArrayList<>();
private List<RelativeItem> children = new ArrayList<>(); private List<RelativeItem> children = new ArrayList<>();
private List<RelativeItem> siblings = new ArrayList<>(); private List<RelativeItem> siblings = new ArrayList<>();
@ -108,18 +102,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; } public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; }
public List<String> getInSiblings() { return inSiblings; } public List<String> getInSiblings() { return inSiblings; }
public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; } public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; }
public List<String> getOutKnownPersons() { return outKnownPersons; }
public void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
public List<String> getInKnownPersons() { return inKnownPersons; }
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = inKnownPersons; }
public List<String> getOutShineConfirmed() { return outShineConfirmed; }
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
public List<String> getInShineConfirmed() { return inShineConfirmed; }
public void setInShineConfirmed(List<String> inShineConfirmed) { this.inShineConfirmed = inShineConfirmed; }
public List<String> getOutShineSeen() { return outShineSeen; }
public void setOutShineSeen(List<String> outShineSeen) { this.outShineSeen = outShineSeen; }
public List<String> getInShineSeen() { return inShineSeen; }
public void setInShineSeen(List<String> inShineSeen) { this.inShineSeen = inShineSeen; }
public List<RelativeItem> getParents() { return parents; } public List<RelativeItem> getParents() { return parents; }
public void setParents(List<RelativeItem> parents) { this.parents = parents; } public void setParents(List<RelativeItem> parents) { this.parents = parents; }
public List<RelativeItem> getChildren() { return children; } public List<RelativeItem> getChildren() { return children; }