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/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` (в корне репозитория).
@ -37,15 +22,16 @@
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
- Все документы и заметки по деплою хранить в папке `Deploy Server/`.
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
- Базовый целевой хост для деплоя по умолчанию: `player@45.136.124.227`.
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`.
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
- По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
- При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `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_FOLLOW (30)`
- `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)**
- `USER_PARAM_TEXT_TEXT (1)`

View File

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

View File

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

View File

@ -24,21 +24,7 @@
- связей и подписок;
- пользовательских параметров.
## 3. Правила line-полей (фактическая серверная валидация)
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-идея для каналов и подписок
## 3. Root-идея для каналов и подписок
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
- `HEADER` для базовой сущности пользователя/канала `0`;

View File

@ -27,7 +27,3 @@
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.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`
- создание нового канала;
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
- хранит line-поля + `channelName`.
## Назначение

View File

@ -19,14 +19,7 @@ TEXT-тип хранит сообщения и редактирования.
4. `subType=21``TEXT_EDIT_REPLY`
- редактирование ответа;
- target на исходный REPLY + новый текст.
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
## Правило для edit
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
## Пустой text в edit
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
- Такой edit трактуется как логическое удаление содержимого сообщения.
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.

View File

@ -5,9 +5,6 @@
1. `subType=1``REACTION_LIKE`
- лайк на целевой блок;
- хранит 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` (близкий друг)
`20/21``contact / uncontact` (контакт)
`30/31``follow / unfollow` (подписан)
`40/41``spouse / unspouse` (супруг/супруга)
`50/51``parent / unparent` (родитель)
`52/53``child / unchild` (ребёнок)
`54/55``sibling / unsibling` (брат/сестра)
`60/61``known_person / unknown_person` (знаю этого человека)
`70/71``shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий)
`74/75``shine_seen / shine_unseen` (мало знаком, но видел сияющим)
1. `subType=10``CONNECTION_FRIEND`
2. `subType=11``CONNECTION_UNFRIEND`
3. `subType=20``CONNECTION_CONTACT`
4. `subType=21``CONNECTION_UNCONTACT`
5. `subType=30``CONNECTION_FOLLOW`
6. `subType=31``CONNECTION_UNFOLLOW`
## Общий формат payload
@ -26,4 +22,3 @@ CONNECTION-тип описывает социальные связи и подп
- FOLLOW указывает на root канала:
- `HEADER` для канала `0`;
- `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
- Базовый коммит-ориентир: `f63f40f1eb2f`.
- Добавлен текущий формат `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)
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
Виды цепочек и правила line-полей.
3. [10_TECH_Blocks.md](./10_TECH_Blocks.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)
Журнал изменений документации.
## Оглавление
1. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Текущий формат `CreateChannelBody`, типы каналов, уникальность имён и правила `stories`.
2. [02_Channel_Commands.md](./02_Channel_Commands.md)
Командные сообщения в каналах: `/.desc`, `/.add`, `/.remove`.
3. [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
server.version=1.2.74
client.version=1.2.57
server.version=1.2.51

View File

@ -182,22 +182,67 @@ tasks.register('deployServer', JavaExec) {
// можно переопределить при запуске:
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server")
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
}
tasks.register('deployUI', Exec) {
tasks.register('deployServerWithBackupCleanAndTests') {
group = "!!deployment"
description = "Deploy WEB UI (production: shineup.me)"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
description = "BLOCKED: удаление БД на проде запрещено, используйте только миграции"
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) {
group = "!!run"
description = "Builds server, starts local WS server and local HTTP UI for end-to-end local testing"
@ -258,11 +303,10 @@ tasks.register('startLocal', Exec) {
echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL"
fi
SPA_SERVER_SCRIPT="${file('scripts/local_spa_server.py').absolutePath}"
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
SHINE_UI_PORT="\$WEB_PORT" python "\$SPA_SERVER_SCRIPT"
(cd "\$UI_DIR" && python -m http.server "\$WEB_PORT")
fi
"""
}

View File

@ -2,14 +2,13 @@
set -euo pipefail
SRC_DIR="shine-UI"
REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
REMOTE_BASE_DIR="${REMOTE_BASE_DIR:-/home/player/SHiNE}"
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
VERSION_FILE="VERSION.properties"
export BUILD_VERSION
TMP_DIR="$(mktemp -d)"
TARGET="${1:-prod}"
if [[ ! -f "$VERSION_FILE" ]]; then
echo "ERROR: version file not found: $VERSION_FILE" >&2
@ -23,8 +22,44 @@ if [[ -z "$CLIENT_VERSION" ]]; then
fi
export CLIENT_VERSION
TARGET_DIR="shine-UI"
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() {
rm -rf "$TMP_DIR"
@ -38,7 +73,7 @@ fi
echo "==> Preparing staged UI copy with build version: $BUILD_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"/
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"
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"
ssh "$REMOTE_HOST" "sudo mkdir -p '$REMOTE_DIR'"
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
echo "==> Syncing staged files to $REMOTE_DIR"
rsync -rlvz --delete --omit-dir-times --no-perms --no-owner --no-group \
--rsync-path="sudo rsync" \
"$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
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
./gradlew deployUI
```
## Gradle-команды
- Продакшен (`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`
- путь: `/home/player/SHiNE/SHiNE-UI`
Также поддерживаются алиасы с дефисом:
- `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`
Переопределение при необходимости:
## Поддержка переопределения
- `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,
'contact-search-view': contactSearchView,
'chat-view': chatView,
user: userProfileView,
'user-profile-view': userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-thread-view': channelThreadView,
@ -136,16 +136,6 @@ let pwaUpdateCheckAttempted = false;
let uiVersionCheckInFlight = false;
let uiVersionPeriodicIntervalId = null;
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));
setClientErrorSentNotifier((payload) => {
@ -299,7 +289,7 @@ function consumeCallPushActionFromUrlIfAny() {
params.delete('callPushAction');
params.delete('callPushPayload');
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);
} catch {
// ignore URL parsing errors
@ -644,7 +634,7 @@ function renderPageFailureFallback(pageId, error) {
stack: error?.stack || '',
context: {
pageId,
routeHash: window.location.pathname || '',
routeHash: window.location.hash || '',
},
});
@ -681,7 +671,7 @@ function renderApp() {
const route = getRoute();
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');
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 hydrateMessagesFromStore();
startConnectionMonitor();
startPeriodicUiVersionCheck();
await ensureSessionRuntimeStarted();
} finally {
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
} else {
renderApp();
}
})();
window.addEventListener('popstate', renderApp);
window.addEventListener('hashchange', renderApp);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
void checkConnectionHealth();

View File

@ -19,39 +19,35 @@ function showSttMissingConfigDialog(navigate) {
if (goSettings) navigate('tools-settings-view');
}
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) {
export async function openSpeechInputModal({ navigate, onTextReady }) {
if (!isSpeechToTextConfigured(state.entrySettings)) {
showSttMissingConfigDialog(navigate);
return;
}
const root = document.getElementById('modal-root');
const host = document.createElement('div');
host.innerHTML = `
<div class="modal" id="speech-input-modal-layer">
root.innerHTML = `
<div class="modal" id="speech-input-modal">
<div class="modal-card stack">
<h3 class="modal-title">Голосовой ввод</h3>
<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>
<p class="meta-muted" id="speech-input-time">00:00</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="primary-btn" type="button" id="speech-ok">OK</button>
</div>
<button class="primary-btn speech-send-now-btn" type="button" id="speech-send-now">Распознать и сразу отправить сообщение</button>
</div>
</div>
`;
root.append(host);
const statusEl = host.querySelector('#speech-input-status');
const timeEl = host.querySelector('#speech-input-time');
const levelEl = host.querySelector('#speech-level-fill');
const errorEl = host.querySelector('#speech-input-error');
const cancelBtn = host.querySelector('#speech-cancel');
const sendNowBtn = host.querySelector('#speech-send-now');
const okBtn = host.querySelector('#speech-ok');
const statusEl = root.querySelector('#speech-input-status');
const timeEl = root.querySelector('#speech-input-time');
const levelEl = root.querySelector('#speech-level-fill');
const errorEl = root.querySelector('#speech-input-error');
const cancelBtn = root.querySelector('#speech-cancel');
const okBtn = root.querySelector('#speech-ok');
const recorder = createMicrophoneRecorder();
let closed = false;
let busy = false;
@ -59,16 +55,14 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
const close = () => {
if (closed) return;
closed = true;
host.remove();
root.innerHTML = '';
};
const setBusy = (flag) => {
busy = !!flag;
cancelBtn.disabled = busy;
sendNowBtn.disabled = busy;
okBtn.disabled = busy;
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
};
try {
@ -90,16 +84,10 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
okBtn.addEventListener('click', async () => {
if (busy) return;
setBusy(true);
errorEl.textContent = '';
statusEl.textContent = 'Распознаю речь...';
try {
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);
if (typeof onTextReady === 'function') onTextReady(text);
close();
@ -109,24 +97,4 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
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 { openAuthRequiredModal } from '../services/auth-required-modal.js';
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
@ -28,35 +27,6 @@ function getTotalUnreadMessages() {
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) {
const root = document.createElement('nav');
root.className = 'toolbar';
@ -93,7 +63,7 @@ export function renderToolbar(currentPageId, navigate) {
if (item.pageId === 'channels-list') {
installChannelsHoldSwitcher(btn, navigate);
} else {
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
btn.addEventListener('click', () => navigate(item.pageId));
}
root.append(btn);
});
@ -106,7 +76,7 @@ function installChannelsHoldSwitcher(button, navigate) {
let pressed = false;
let holdActive = false;
let overlay = null;
let selectedMode = 'feed';
let selectedMode = 'dialogs';
const clearTimer = () => {
if (holdTimer) {
@ -150,7 +120,7 @@ function installChannelsHoldSwitcher(button, navigate) {
button.addEventListener('pointerdown', (event) => {
pressed = true;
holdActive = false;
selectedMode = 'feed';
selectedMode = 'dialogs';
clearTimer();
holdTimer = window.setTimeout(() => {
if (!pressed) return;
@ -173,7 +143,7 @@ function installChannelsHoldSwitcher(button, navigate) {
navigate(`channels-list/${mode}`);
return;
}
navigate('channels-list/feed');
navigate('channels-list/dialogs');
});
button.addEventListener('pointercancel', () => {
@ -186,4 +156,3 @@ function installChannelsHoldSwitcher(button, navigate) {
event.preventDefault();
});
}

View File

@ -11,67 +11,11 @@ import {
softHaptic,
} from '../services/channels-ux.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: 'Тред' };
const pendingReactionActions = new Set();
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 = {}) {
const message = String(error?.message || error || 'thread runtime error');
@ -105,11 +49,6 @@ function toSafeInt(value) {
return Number.isFinite(parsed) ? parsed : null;
}
function looksLikeBlockchainName(value) {
const raw = String(value || '').trim();
return /^[^-]+-\d+$/.test(raw);
}
function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim();
@ -130,8 +69,7 @@ function messageRefKey(messageRef) {
function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href);
url.pathname = `/${cleanRoute}`;
url.hash = '';
url.hash = `#/${cleanRoute}`;
return url.toString();
}
@ -150,8 +88,8 @@ function parseThreadSelector(route) {
},
channel: {
ownerBlockchainName: '',
channelRootBlockNumber: null,
channelRootBlockHash: '0',
rootBlockNumber: null,
rootBlockHash: '0',
},
};
}
@ -166,8 +104,8 @@ function parseThreadSelector(route) {
},
channel: {
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber),
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
},
};
}
@ -182,12 +120,10 @@ function allFeedSummaries() {
}
function resolveChannelDisplayName(channelSelector) {
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
if (!channelSelector?.ownerBlockchainName || rootNumber == null) return '';
if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
const ownerBch = String(channelSelector.ownerBlockchainName);
const rootNo = Number(rootNumber);
const rootHash = normalizeRouteHash(rootHashRaw);
const rootNo = Number(channelSelector.rootBlockNumber);
const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
const found = allFeedSummaries().find((summary) => (
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
@ -198,81 +134,37 @@ function resolveChannelDisplayName(channelSelector) {
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
}
function extractChannelContextFromThreadPayload(payload) {
const focusInfo = payload?.focus?.channelInfo;
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
return {
ownerBlockchainName: String(focusInfo.ownerBlockchainName || '').trim(),
channelRootBlockNumber: Number(focusInfo.channelRoot.blockNumber),
channelRootBlockHash: '0',
};
}
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 '';
function buildBackRoute(selector) {
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
return [
'channel',
encodeRoutePart(selector.short.ownerBlockchainName),
encodeRoutePart(selector.short.channelName),
].join('/');
}
return 'channels-list';
}
function buildThreadRouteFromTarget(target, selector) {
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) {
return makeShineChannelRoute({
ownerLogin: extractLoginFromBlockchainName(ownerBch),
ownerBlockchainName: ownerBch,
channelName: selector.short.channelName,
});
return [
'channel',
encodeRoutePart(selector.short.ownerBlockchainName),
encodeRoutePart(selector.short.channelName),
target.blockNumber,
].join('/');
}
const label = String(resolvedChannelLabel || '').trim();
const slashIndex = label.indexOf('/');
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
return makeShineChannelRoute({
ownerLogin: extractLoginFromBlockchainName(ownerBch),
ownerBlockchainName: ownerBch,
channelName,
});
if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
return [
'channel-thread-view',
encodeRoutePart(target.blockchainName),
target.blockNumber,
normalizeRouteHash(target.blockHash),
encodeRoutePart(selector.channel.ownerBlockchainName),
selector.channel.rootBlockNumber,
normalizeRouteHash(selector.channel.rootBlockHash),
].join('/');
}
function buildTargetFromNode(node) {
@ -293,11 +185,12 @@ function firstNonEmptyText(...candidates) {
}
function latestVersionText(versions) {
if (!Array.isArray(versions) || !versions.length) return '';
const version = versions[versions.length - 1];
if (typeof version?.text === 'string') return version.text;
if (typeof version?.message === 'string') return version.message;
if (typeof version?.body === 'string') return version.body;
if (!Array.isArray(versions)) return '';
for (let i = versions.length - 1; i >= 0; i -= 1) {
const version = versions[i];
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (value) return value;
}
return '';
}
@ -379,104 +272,16 @@ function openReplyModal({ onSubmit, navigate }) {
if (textEl) textEl.focus();
}
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
const root = document.getElementById('modal-root');
const rows = Array.isArray(versions) ? versions : [];
root.innerHTML = `
<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) {
const card = document.createElement('article');
card.className = 'card stack thread-node-card channel-message-card';
card.classList.add('is-counters-visible');
card.className = 'card stack thread-node-card';
const author = node?.authorLogin || 'автор';
const versions = Array.isArray(node?.versions) ? node.versions : [];
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
const text = resolveNodeText(node) || '(пусто)';
const likes = Number(node?.likesCount || 0);
const replies = Number(node?.repliesCount || 0);
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
const versions = Number(node?.versionsTotal || 1);
const changes = Math.max(0, versions - 1);
const headingText = String(heading || '').trim();
if (headingText) {
@ -486,51 +291,18 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.append(headingEl);
}
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
const meta = document.createElement('p');
meta.className = 'thread-node-meta';
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');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
body.className = 'thread-node-body';
body.textContent = text;
card.append(authorTile, body);
card.append(meta, body);
const target = buildTargetFromNode(node);
const refKey = messageRefKey(target);
@ -546,20 +318,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const isLiked = getMessageReactionState(target) === 'liked';
const actions = document.createElement('div');
actions.className = 'thread-node-actions channel-message-actions';
actions.className = 'thread-node-actions';
const likeButton = document.createElement('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');
likeButton.innerHTML = `
<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.textContent = isPending ? `❤️ ${likes}...` : `${isLiked ? '❤️' : '🤍'} ${likes}`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
@ -568,6 +335,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
likeButton.textContent = `❤️ ${likes}...`;
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@ -582,14 +350,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item thread-reply-btn';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
`;
replyButton.className = 'secondary-btn thread-reply-btn';
replyButton.textContent = `💬 ${replies}`;
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
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');
shareButton.type = 'button';
shareButton.className = 'channel-action-item thread-share-btn';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Отправить</span>
`;
shareButton.className = 'secondary-btn thread-share-btn';
shareButton.textContent = '↗ Отправить';
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
await handlers.onShare(target);
});
actions.append(likeButton, replyButton, shareButton);
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
<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);
}
actions.append(likeButton, replyButton, changedButton, shareButton);
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;
}
@ -703,6 +441,7 @@ function renderSkeleton(screen) {
export function render({ navigate, route }) {
const selector = parseThreadSelector(route);
const backRoute = buildBackRoute(selector);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
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');
appScreen?.classList.add('channels-scroll-clean');
const header = renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigateBack() },
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
});
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
if (threadHeaderButton) {
threadHeaderButton.classList.add('channel-header-route-btn');
threadHeaderButton.disabled = true;
}
const channelIndicator = document.createElement('div');
channelIndicator.className = 'card channels-user-chip';
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
@ -733,7 +465,7 @@ export function render({ navigate, route }) {
const next = render({ navigate, route });
current.replaceWith(next);
} 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 storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels-list';
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
@ -813,38 +545,21 @@ export function render({ navigate, route }) {
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
}
},
onOpenThread: (target) => {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) {
showStatus('Не удалось определить путь до треда.');
return;
}
navigate(routePath);
},
onActionError: (error, action) => {
const fallback = action === 'unlike'
? 'Не удалось убрать лайк.'
: 'Не удалось поставить лайк.';
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) {
const invalid = document.createElement('div');
@ -866,46 +581,10 @@ export function render({ navigate, route }) {
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []),
];
const ownerRaw = String(selector.short.ownerBlockchainName || '').trim();
const ownerNormalized = ownerRaw.toLowerCase();
const ownerLoginFromBch = extractLoginFromBlockchainName(ownerRaw);
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
const channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim() === selector.short.ownerBlockchainName
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
));
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 rootNo = Number(channel?.channel?.channelRoot?.blockNumber);
const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash);
@ -914,14 +593,25 @@ export function render({ navigate, route }) {
}
selector.channel = {
ownerBlockchainName: ownerBch,
channelRootBlockNumber: rootNo,
channelRootBlockHash: rootHash,
rootBlockNumber: rootNo,
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 = {
blockchainName: ownerBch,
blockNumber: resolvedMessage.blockNumber,
blockHash: normalizeMessageHash(resolvedMessage?.blockHash),
blockHash: resolvedHash,
};
}
@ -932,68 +622,30 @@ export function render({ navigate, route }) {
const focus = payload?.focus || null;
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;
const nextNumber = () => {
seq += 1;
return seq;
};
let ancestorsWrap = null;
if (ancestors.length) {
ancestorsWrap = document.createElement('div');
const ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'История выше (на что это ответ)';
title.textContent = 'Предыдущие сообщения';
ancestorsWrap.append(title);
ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
});
screen.append(ancestorsWrap);
}
let focusWrap = null;
if (focus) {
focusWrap = document.createElement('div');
const focusWrap = document.createElement('div');
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()));
screen.append(focusWrap);
}
const descendantsWrap = document.createElement('div');
@ -1012,23 +664,8 @@ export function render({ navigate, route }) {
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);
applyPendingScroll(screen, routeKey);
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
setTimeout(() => {
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
}, 20);
}
} catch (error) {
skeleton.remove();
const failed = document.createElement('div');

View File

@ -17,72 +17,12 @@ import {
softHaptic,
} from '../services/channels-ux.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: 'Канал' };
const CHANNEL_TYPE_PERSONAL = 100;
const pendingReactionActions = new Set();
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() {
try {
@ -115,11 +55,6 @@ function toSafeInt(value) {
return Number.isFinite(parsed) ? parsed : null;
}
function looksLikeBlockchainName(value) {
const raw = String(value || '').trim();
return /^[^-]+-\d+$/.test(raw);
}
function makeReactionActionKey(messageRef) {
const login = String(state.session.login || '').trim().toLowerCase();
const blockchainName = String(messageRef?.blockchainName || '').trim();
@ -163,8 +98,7 @@ function blockRefToMessageKey(blockRef, fallbackBch = '') {
function buildAbsoluteRouteUrl(routePath = '') {
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
const url = new URL(window.location.href);
url.pathname = `/${cleanRoute}`;
url.hash = '';
url.hash = `#/${cleanRoute}`;
return url.toString();
}
@ -200,12 +134,25 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return '';
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
return makeShineMessageRoute({
ownerLogin,
messageBlockchainName: messageRef.blockchainName,
messageBlockNumber: messageRef.blockNumber,
});
const ownerBlockchainName = String(selector.ownerBlockchainName || '').trim();
const channelName = String(selector.channelName || '').trim();
if (ownerBlockchainName && channelName) {
return [
'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) {
@ -218,11 +165,12 @@ function firstNonEmptyText(...candidates) {
}
function latestVersionText(versions) {
if (!Array.isArray(versions) || !versions.length) return '';
const version = versions[versions.length - 1];
if (typeof version?.text === 'string') return version.text;
if (typeof version?.message === 'string') return version.message;
if (typeof version?.body === 'string') return version.body;
if (!Array.isArray(versions)) return '';
for (let i = versions.length - 1; i >= 0; i -= 1) {
const version = versions[i];
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
if (value) return value;
}
return '';
}
@ -429,92 +377,6 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
if (textEl) textEl.focus();
}
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
const root = document.getElementById('modal-root');
const rows = Array.isArray(versions) ? versions : [];
root.innerHTML = `
<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) {
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
@ -537,29 +399,20 @@ function mapApiMessageToPost(message, selector, localNumber) {
return {
localNumber,
authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'),
versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [],
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
};
}
async function loadFromApi(route, channelId) {
const currentSessionLogin = String(state.session.login || '').trim();
const isAuthorized = !!currentSessionLogin;
let cachedFeed = null;
const ensureFeed = async () => {
if (cachedFeed) return cachedFeed;
if (!isAuthorized) {
cachedFeed = {};
return cachedFeed;
}
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
return cachedFeed;
};
const getAllRows = async () => {
@ -575,11 +428,8 @@ async function loadFromApi(route, channelId) {
if (selector?.ownerBlockchainName && selector?.channelName) {
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
let channel = null;
if (isAuthorized) {
const allRows = await getAllRows();
channel = allRows.find((item) => (
let channel = allRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
&& 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()
));
}
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
if (!channel) {
try {
const ownerUser = await authService.getUser(routeOwnerRaw);
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
@ -603,22 +453,6 @@ async function loadFromApi(route, channelId) {
// 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) {
throw new Error('Канал не найден.');
}
@ -634,12 +468,12 @@ async function loadFromApi(route, channelId) {
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 : [];
let reverseChannelMissingWarning = '';
let mergedMessages = [...messages];
const currentLogin = currentSessionLogin;
const currentLogin = String(state.session.login || '').trim();
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const channelName = String(payload.channel?.channelName || '').trim();
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
@ -665,7 +499,7 @@ async function loadFromApi(route, channelId) {
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
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 : [];
mergedMessages = mergedMessages.concat(reverseMessages);
} else {
@ -683,9 +517,9 @@ async function loadFromApi(route, channelId) {
return aNum - bNum;
})
.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 isSubscribed = isAuthorized && followedRows.some((row) => (
const isSubscribed = followedRows.some((row) => (
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
@ -747,31 +581,17 @@ function renderDemoFallback(screen, navigate, error) {
screen.append(back);
}
function scrollChannelToBottom(screen, smooth = true) {
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) {
function applyPendingScroll(screen, routeKey) {
const target = pendingScrollByRoute.get(routeKey);
if (!target && !forceBottom) return;
if (!target) return;
const doScroll = () => {
if (!target && forceBottom) {
scrollChannelToBottom(screen, false);
return;
}
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);
return;
}
@ -792,18 +612,16 @@ function renderPostCard(post, {
onToggleLike,
onReply,
onShare,
onEdit,
}) {
const versionsTotal = Number(post?.versionsTotal || 1);
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
const topRow = document.createElement('div');
topRow.className = 'channel-message-top';
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');
authorBlock.className = 'channel-message-author';
@ -823,37 +641,14 @@ function renderPostCard(post, {
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
title.append(loginEl, numberEl);
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openMessageHistoryModal({
title: `История #${post.localNumber}`,
versions: post.versions,
});
});
title.append(editedMarker);
}
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
authorTile.addEventListener('click', (event) => {
event.stopPropagation();
const cleanLogin = String(post.authorLogin || '').trim();
if (!cleanLogin) return;
navigate(makeProfileRoute(cleanLogin));
});
topRow.append(avatar, authorBlock);
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
body.className = 'channel-message-body';
body.textContent = post.body;
card.append(authorTile, body);
card.append(topRow, body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@ -881,7 +676,6 @@ function renderPostCard(post, {
`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
@ -904,7 +698,6 @@ function renderPostCard(post, {
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openReplyModal({
navigate,
@ -913,6 +706,19 @@ function renderPostCard(post, {
});
actions.append(likeButton, replyButton);
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
openThreadButton.className = 'channel-action-item channel-action-thread';
openThreadButton.innerHTML = `
<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');
shareButton.type = 'button';
shareButton.className = 'channel-action-item channel-action-share';
@ -927,46 +733,49 @@ function renderPostCard(post, {
await onShare(route);
});
actions.append(shareButton);
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
`;
editButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
actions.append(editButton);
}
actions.append(openThreadButton, shareButton);
card.append(actions);
card.addEventListener('click', () => {
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
return card;
}
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) {
const reverseWarning = document.createElement('p');
reverseWarning.className = 'channel-head-meta';
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
screen.append(reverseWarning);
head.append(reverseWarning);
}
const actionButton = document.createElement('button');
actionButton.className = 'destructive-btn channel-main-action';
actionButton.textContent = 'Подписаться на канал';
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
@ -980,7 +789,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
});
const key = messageRefKey(post.messageRef);
if (key) {
@ -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);
}
@ -1005,15 +822,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
if (channelData.isOwnChannel) {
screen.append(feed);
} else if (!channelData.isSubscribed) {
screen.append(actionButton, feed, backButton);
if (channelData.isOwnChannel || !channelData.isSubscribed) {
screen.append(head, actionButton, feed, backButton);
} else {
screen.append(feed, backButton);
screen.append(head, feed, backButton);
}
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
applyPendingScroll(screen, routeKey);
return () => {
// noop
};
@ -1051,17 +866,6 @@ export function render({ navigate, route }) {
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 current = document.querySelector('section.channels-screen--channel');
if (!current) return;
@ -1074,7 +878,7 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels-list';
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
@ -1159,25 +963,12 @@ export function render({ navigate, route }) {
rerender();
};
const onEditPost = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
}
await authService.addBlockEditMessage({
login,
storagePwd,
message: messageRef,
text,
isChannelPost: true,
channel: activeSelector,
});
softHaptic(12);
showToast('Сообщение обновлено');
rerender();
};
screen.append(header);
screen.append(
renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
screen.append(statusBox);
const skeleton = renderSkeleton(screen);
@ -1188,41 +979,6 @@ export function render({ navigate, route }) {
try {
const apiData = await loadFromApi(route, channelId);
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();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onToggleLike: async (messageRef, action) => {
@ -1250,14 +1006,6 @@ export function render({ navigate, route }) {
}
},
onShare: onShare,
onEdit: async (messageRef, text) => {
try {
await onEditPost(messageRef, text);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
}
},
onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {

View File

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

View File

@ -10,108 +10,14 @@ import {
markOutgoingSent,
markReadReceiptSentByBaseKey,
authService,
setContacts,
state,
} from '../state.js';
import { startOutgoingCall } from '../services/call-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
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) {
const raw = String(baseKey || '').trim();
const parts = raw.split('|');
@ -172,14 +78,11 @@ function scrollToLatestMessage(list) {
};
apply();
window.requestAnimationFrame(apply);
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
window.setTimeout(apply, 0);
window.setTimeout(apply, 60);
window.setTimeout(apply, 120);
window.setTimeout(apply, 260);
}
function renderLog(list, chatId, { onOpenActions } = {}) {
function renderLog(list, chatId) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
@ -219,9 +122,6 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
}
bubble.append(textNode, metaNode);
bubble.addEventListener('click', () => {
if (typeof onOpenActions === 'function') onOpenActions(msg);
});
list.append(bubble);
});
scrollToLatestMessage(list);
@ -242,42 +142,20 @@ export function render({ navigate, route }) {
screen.append(
renderHeader({
title: `Чат с ${contact.name}`,
title: `Чат: ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{
label: 'Позвонить',
onClick: async () => {
try {
await startOutgoingCall(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);
},
}),
});
renderLog(log, chatId);
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
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);
},
}),
});
renderLog(log, chatId);
}
},
}],
@ -290,26 +168,18 @@ export function render({ navigate, route }) {
const btn = document.createElement('button');
btn.className = 'secondary-btn';
btn.type = 'button';
btn.textContent = 'Добавить собеседника в контакты';
btn.textContent = 'Добавить в контакты';
btn.addEventListener('click', async () => {
try {
const approved = await openConfirmContactModal(chatId);
if (!approved) return;
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 || []);
await authService.addCloseFriend(chatId);
state.contacts = [...new Set([...(state.contacts || []), chatId])];
addAppLogEntry({
level: 'info',
source: 'contacts',
message: `Пользователь ${chatId} добавлен в контакты`,
});
card.remove();
btn.disabled = true;
btn.textContent = 'Добавлено';
} catch (e) {
addAppLogEntry({
level: 'warn',
@ -332,30 +202,52 @@ export function render({ navigate, route }) {
const form = document.createElement('form');
form.className = 'chat-input dm-chat-input';
form.innerHTML = `
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button>
</div>
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<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-read-aloud">🔊</button>
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
`;
const sendTextMessage = async (rawText) => {
const text = String(rawText || '').trim();
if (!text) return;
const tempId = addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
const input = form.elements.message;
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(input.value || '').trim();
input.value = prev ? `${prev} ${text}` : text;
},
});
});
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
const input = form.elements.message;
const text = String(input.value || '').trim();
if (!text) {
window.alert('Введите текст для озвучки.');
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 {
const result = await authService.sendDirectMessage({
login: state.session.login,
@ -367,18 +259,7 @@ export function render({ navigate, route }) {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
});
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);
},
}),
});
renderLog(log, chatId);
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',
@ -401,92 +282,14 @@ export function render({ navigate, route }) {
error: e?.message || 'unknown',
},
});
renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
onReadAloud: async () => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
renderLog(log, chatId);
}
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);
screen.append(wrap);
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);
},
}),
});
renderLog(log, chatId);
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180);
void sendReadReceiptsForVisible(chatId);
return screen;
}

View File

@ -1,62 +1,7 @@
import { renderHeader } from '../components/header.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: 'Поиск контактов' };
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 }) {
const screen = document.createElement('section');
@ -99,17 +44,16 @@ export function render({ navigate }) {
matches.forEach((login) => {
const row = document.createElement('article');
row.className = 'list-item dm-dialog-card';
const avatarEl = createSearchAvatar(login);
row.innerHTML = `
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div>
<strong>${login}</strong>
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
</div>
<div class="meta-muted">Профиль</div>
`;
row.prepend(avatarEl);
row.addEventListener('click', () => {
navigate(makeProfileRoute(login));
navigate(`user-profile-view/${encodeURIComponent(login)}/contact-search-view`);
});
resultsList.append(row);
});

View File

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

View File

@ -8,72 +8,8 @@ import {
terminateCurrentSession,
} from '../state.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: 'Личные сообщения' };
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 }) {
const screen = document.createElement('section');
@ -93,22 +29,20 @@ export function render({ navigate }) {
function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id);
avatarEl.classList.add('avatar');
row.innerHTML = `
<div class="dm-row-main">
<div class="dm-row-title-wrap">
<strong class="dm-row-title">${item.name}</strong>
<div class="avatar">${item.initials}</div>
<div>
<div class="row" style="justify-content:flex-start; gap:8px;">
<strong>${item.name}</strong>
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
</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 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>'}
<span class="meta-muted dm-row-time">${item.time}</span>
</div>
`;
row.prepend(avatarEl);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
return row;
}
@ -125,12 +59,12 @@ export function render({ navigate }) {
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: login,
initials: (login[0] || '?').toUpperCase(),
name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
time: preview?.time || '—',
unread,
notInContacts: false,
};
@ -147,12 +81,12 @@ export function render({ navigate }) {
const chat = getChatMessages(login);
const lastChat = chat[chat.length - 1];
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return {
id: login,
initials: (login[0] || '?').toUpperCase(),
name: login,
lastMessage: lastChat?.text || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs),
time: 'сейчас',
unread,
notInContacts: true,
};

View File

@ -2,8 +2,6 @@ import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.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: 'Связи' };
@ -16,14 +14,6 @@ function normalizeLogin(value) {
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) {
return normalizeLogin(value).toLowerCase();
}
@ -517,7 +507,6 @@ let persistedCenterHistory = [];
export function render({ navigate, route }) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) {
persistedCenterLogin = '';
persistedCenterHistory = [];
@ -544,7 +533,7 @@ export function render({ navigate, route }) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin) return '';
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() {
@ -562,15 +551,6 @@ export function render({ navigate, route }) {
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) {
if (!(backBtn instanceof HTMLButtonElement)) return;
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="Введите логин" />
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
</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="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>
`;
@ -600,6 +585,9 @@ export function render({ navigate, route }) {
const runBtn = root.querySelector('#network-search-run');
const metaEl = root.querySelector('#network-search-meta');
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)) {
root.innerHTML = '';
return;
@ -619,6 +607,10 @@ export function render({ navigate, route }) {
if (!(row instanceof HTMLElement)) return;
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) => {
@ -669,8 +661,6 @@ export function render({ navigate, route }) {
});
closeBtn?.addEventListener('click', close);
runBtn?.addEventListener('click', () => { void runSearch(); });
const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000);
inputEl.addEventListener('input', debouncedSearch);
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
@ -682,11 +672,23 @@ export function render({ navigate, route }) {
if (!(target instanceof HTMLElement)) return;
const button = target.closest('[data-candidate]');
if (!(button instanceof HTMLElement)) return;
const nextLogin = String(button.dataset.candidate || '');
applySelection(nextLogin);
if (!nextLogin) return;
applySelection(String(button.dataset.candidate || ''));
});
profileBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
const routeTo = profileInfoRoute(selectedLogin);
if (!routeTo) return;
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);
@ -763,7 +765,6 @@ export function render({ navigate, route }) {
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
centerHistory.push(prevCenter);
}
syncLinksUrl(targetCenter, { push: pushHistory });
const model = buildGraphModel(graph, targetCenter);
const layout = layoutNodes(model);
@ -838,22 +839,13 @@ export function render({ navigate, route }) {
appScreenEl?.classList.remove('network-scroll-lock');
};
if (routeLogin) {
centerLogin = routeLogin;
centerHistory = [];
persistHistory();
void load(centerLogin, { pushHistory: false });
} else if (keepHistory && centerLogin) {
if (keepHistory && centerLogin) {
void load(centerLogin, { pushHistory: false });
} else {
centerLogin = normalizeLogin(state.session.login || '');
centerHistory = [];
persistHistory();
if (centerLogin) {
void load(centerLogin, { pushHistory: false });
} else {
window.setTimeout(() => openSearchModal(), 0);
}
}
setBackButtonState(backBtnEl);

View File

@ -7,7 +7,6 @@ import {
} from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@ -30,40 +29,6 @@ function escapeHtml(text) {
.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 }) {
const login = state.session.login || profile.login;
@ -73,23 +38,15 @@ export function render({ navigate }) {
const topActions = document.createElement('div');
topActions.className = 'profile-top-actions';
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>
`;
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'));
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');
card.className = 'card stack profile-main-card';
@ -104,6 +61,13 @@ export function render({ navigate }) {
</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');
badgesRow.className = 'row';
badgesRow.innerHTML = `
@ -114,6 +78,8 @@ export function render({ navigate }) {
const listWrap = document.createElement('div');
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 shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
@ -169,21 +135,6 @@ export function render({ navigate }) {
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) {
listWrap.innerHTML = '';
fields.forEach((field) => {
@ -204,6 +155,10 @@ export function render({ navigate }) {
async function refreshProfileSnapshot() {
try {
if (statusLineEl instanceof HTMLElement) {
statusLineEl.className = 'status-line';
statusLineEl.textContent = 'Загрузка параметров...';
}
const snapshot = await loadProfileSnapshot(login);
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
@ -213,12 +168,39 @@ export function render({ navigate }) {
updateAvatarUi();
updateTogglesUi();
renderFields(currentFields);
if (statusLineEl instanceof HTMLElement) {
statusLineEl.className = 'status-line is-available';
statusLineEl.textContent = 'Профиль обновлён.';
}
} 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);
updateAvatarUi();

View File

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

View File

@ -32,19 +32,13 @@ export function render({ navigate }) {
registerButton.textContent = 'Зарегистрироваться';
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');
settingsButton.className = 'ghost-btn';
settingsButton.type = 'button';
settingsButton.textContent = 'Настройки';
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
actions.append(loginButton, registerButton, settingsButton);
screen.append(logo, title, actions);
return screen;
}

View File

@ -6,11 +6,8 @@ import {
loadUserProfileCard,
} from '../services/user-connections.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', title: 'Чужой профиль' };
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
function escapeHtml(text) {
return String(text || '')
@ -21,40 +18,6 @@ function escapeHtml(text) {
.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) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'male') return 'Мужской';
@ -63,28 +26,28 @@ function genderText(value) {
}
function relationButtonLabel(kind, flags) {
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
return flags.outFollow ? 'Отписаться' : 'Подписаться';
return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
}
function relationNextState(kind, flags) {
if (kind === 'contact') return !flags.outContact;
if (kind === 'follow') return !flags.outFollow;
if (kind === 'friend') return !flags.outFriend;
return !flags.outFollow;
return !flags.outContact;
}
function relationConfirmLabel(kind) {
if (kind === 'contact') return 'контакт';
if (kind === 'follow') return 'подписку';
if (kind === 'friend') return 'статус близкого друга';
return 'подписку';
return 'контакт';
}
function relationStateText(kind, flags) {
if (kind === 'contact') {
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
if (kind === 'follow') {
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
if (flags.inFollow) return 'Этот профиль подписан на вас.';
return '';
}
if (kind === 'friend') {
@ -93,52 +56,12 @@ function relationStateText(kind, flags) {
if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
return '';
}
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
if (flags.inFollow) return 'Этот профиль подписан на вас.';
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
if (flags.inContact) 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) {
const lines = buildIdentityLines({
login: card.login,
@ -176,88 +99,31 @@ function renderIdentity(card) {
function renderReadOnlyBadges(card) {
return `
<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>
<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.official ? 'is-yes-official' : 'is-no'}">Официальный: ${card.official ? 'Yes' : 'No'}</span>
<span class="badge ${card.shine ? 'is-yes-shine' : 'is-no'}">Сияющий: ${card.shine ? 'Yes' : 'No'}</span>
</div>
`;
}
function renderRelations(flags) {
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: '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 `
<div class="card stack user-relations-list" data-profile-relations="true">
<div class="card stack user-relations-list">
${rows.map((row) => `
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
<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>
</div>
`).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>
`;
}
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) {
const rows = [
{ label: 'Имя', value: card.firstName },
@ -281,6 +147,7 @@ function renderReadOnlyParams(card) {
export function render({ navigate, route }) {
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 screen = document.createElement('section');
@ -296,14 +163,12 @@ export function render({ navigate, route }) {
screen.append(
renderHeader({
title: 'Профиль пользователя',
leftAction: { label: '←', onClick: () => navigateBack() },
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
leftAction: { label: '←', onClick: () => navigate(fromPage) },
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
}),
status,
body,
);
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
linksHeaderBtn?.classList.add('profile-links-header-btn');
let currentCard = null;
let currentFlags = null;
@ -313,17 +178,14 @@ export function render({ navigate, route }) {
const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]');
const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]');
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
followBtn.textContent = relationButtonLabel('follow', currentFlags);
contactBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
followBtn.disabled = Boolean(isSelf);
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
opinionBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
contactBtn.disabled = Boolean(isSelf);
}
async function refresh() {
@ -358,10 +220,6 @@ export function render({ navigate, route }) {
body.prepend(identityCard);
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.textContent = 'Профиль обновлён.';
} catch (error) {
@ -384,14 +242,6 @@ export function render({ navigate, route }) {
return;
}
if (kind === 'opinion-menu') {
openOpinionMenuModal({
flags: currentFlags,
onApply: onOpinionApply,
});
return;
}
const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm(
`Изменить ${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) => {
const target = event.target;
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 kind = String(actionBtn?.getAttribute('data-relation-action') || '');
if (!kind) return;
void onRelationAction(kind);
onRelationAction(kind);
});
refresh();

View File

@ -1,8 +1,6 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import {
createRandomSolanaWallet,
createSolanaWalletFromPrivateBase58,
formatSol,
getBalanceSol,
getTopupSiteUrl,
@ -19,7 +17,6 @@ import {
} from '../services/arweave-wallet-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
function nowRu() {
return new Date().toLocaleString('ru-RU');
@ -168,203 +165,6 @@ export function render({ navigate }) {
const sendBtn = actions.querySelector('#send-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 () => {
if (!walletAddress) {
setStatus('Кошелёк не инициализирован.');
@ -465,7 +265,7 @@ export function render({ navigate }) {
}
});
content.append(backBtn, card, actions, generatedCard);
content.append(backBtn, card, actions);
setStatus('Инициализация wallet.key...');
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'];
export const PRE_AUTH_PAGES = [
@ -16,13 +14,10 @@ export const PRE_AUTH_PAGES = [
];
export function getRoute() {
const currentPath = String(window.location.pathname || '').trim();
const raw = currentPath
.replace(/^\/+/, '')
.replace(/^index\.html$/i, '')
.replace(/^index\.html\//i, '')
.replace(/\/+$/, '');
if (!raw) return { pageId: '', params: {} };
const raw = window.location.hash.replace(/^#\/?/, '');
if (!raw) {
return { pageId: '', params: {} };
}
const segments = raw.split('/').filter(Boolean);
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') {
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
@ -122,16 +50,51 @@ export function getRoute() {
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') {
return {
pageId,
params: {
messageBlockchainName: decodePart(segments[1]),
messageBlockNumber: segments[2] || '',
messageBlockHash: '',
channelOwnerBlockchainName: decodePart(segments[3]),
channelRootBlockNumber: segments[4] || '',
channelRootBlockHash: segments[5] || '',
messageBlockHash: segments[3] || '',
channelOwnerBlockchainName: decodePart(segments[4]),
channelRootBlockNumber: segments[5] || '',
channelRootBlockHash: segments[6] || '',
},
};
}
@ -140,29 +103,39 @@ export function getRoute() {
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') {
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
return {
pageId,
params: {
mode: segments[1] ? decodePart(segments[1]) : '',
},
};
}
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: {} };
}
export function navigate(path) {
const cleanPath = String(path || '').replace(/^\/+/, '');
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();
window.location.hash = `#/${path}`;
}
export function resolveToolbarActive(pageId) {
@ -183,9 +156,11 @@ export function resolveToolbarActive(pageId) {
pageId === 'language-view' ||
pageId === 'app-log-view' ||
pageId === 'pwa-diagnostics-view'
) return 'profile-view';
) {
return 'profile-view';
}
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') return 'messages-list';
if (pageId === 'user-profile-view') return 'messages-list';
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_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
const MSG_SUBTYPE_TEXT_REPLY = 20;
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
@ -61,9 +59,6 @@ const CONNECTION_SUBTYPES = Object.freeze({
parent: { on: 50, off: 51 },
child: { on: 52, off: 53 },
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) {
@ -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({
lineCode = 0,
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 }) {
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
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 latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
const latestVersionsTotal = Number(latestMessage?.versionsTotal);
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
prevLineNumber = latestBlockNumber;
prevLineHashHex = latestBlockHash;
// В line-цепочке thisLineNumber — это номер шага линии, а не глобальный blockNumber.
// Для следующего POST берем шаг после последней известной версии сообщения.
thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0
? Math.max(0, latestVersionsTotal)
: 1;
thisLineNumber = latestBlockNumber + 1;
}
} catch {
// 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/';
let solanaLibPromise = null;
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
function normalizeEndpoint(url) {
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 }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();

View File

@ -47,23 +47,7 @@ function toToggleMap(snapshot) {
}
function readArray(payload, key) {
const aliases = {
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;
}
}
const value = payload?.[key];
return Array.isArray(value) ? uniqueLogins(value) : null;
}
@ -91,12 +75,6 @@ async function buildRelationsModel(login) {
inChildren: [],
outSiblings: [],
inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
inShineSeen: [],
};
}
@ -139,12 +117,6 @@ async function buildRelationsModel(login) {
inChildren: readArray(graph, 'inChildren') || [],
outSiblings: readArray(graph, 'outSiblings') || [],
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: [],
outSiblings: [],
inSiblings: [],
outKnownPersons: [],
inKnownPersons: [],
outShineConfirmed: [],
inShineConfirmed: [],
outShineSeen: [],
inShineSeen: [],
};
}
return buildRelationsModel(login);
@ -204,12 +170,6 @@ export function relationFlagsForTarget(relations, targetLogin) {
inChild: listContainsLogin(relations?.inChildren, targetLogin),
outSibling: listContainsLogin(relations?.outSiblings, 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 {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
}
.avatar-image > .avatar-fallback,
@ -1206,10 +1204,6 @@ textarea.input {
gap: 10px;
}
.modal-danger-action {
width: 100%;
}
.small-btn {
padding: 6px 10px;
font-size: 13px;
@ -1641,49 +1635,6 @@ textarea.input {
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 {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -1830,14 +1781,6 @@ textarea.input {
.channels-screen .page-header {
margin-bottom: 0;
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,
@ -2228,47 +2171,21 @@ textarea.input {
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 {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-size: 17px;
font-weight: 700;
color: #f4f6ff;
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
}
.channel-message-avatar.avatar-image {
display: grid;
place-items: center;
}
.channel-message-author {
display: grid;
gap: 4px;
@ -2276,7 +2193,7 @@ textarea.input {
}
.channel-message-title {
font-size: 15px;
font-size: 20px;
color: #f5f8ff;
}
@ -2292,16 +2209,8 @@ textarea.input {
border: 0;
}
.channel-message-body--deleted {
color: #ff9e9e;
border: 1px solid rgba(255, 126, 126, 0.5);
background: rgba(120, 18, 18, 0.28);
border-radius: 10px;
padding: 8px 10px;
}
.channel-message-time {
font-size: 11px;
font-size: 12px;
color: rgba(255, 255, 255, 0.48);
}
@ -2381,25 +2290,6 @@ textarea.input {
letter-spacing: 0.01em;
}
.message-edited-marker {
appearance: none;
border: none;
background: transparent;
color: rgba(255, 220, 100, 0.85);
font-size: 11px;
line-height: 1;
padding: 0;
margin-left: 6px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.message-edited-marker:hover,
.message-edited-marker:focus-visible {
color: rgba(255, 232, 150, 0.95);
}
.channel-action-counter {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
@ -2430,31 +2320,6 @@ textarea.input {
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 {
color: #f1dcab;
font-size: 15px;
@ -2491,15 +2356,10 @@ textarea.input {
.thread-node-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.channels-screen--thread .thread-node-actions {
display: flex !important;
grid-template-columns: none !important;
}
.thread-node-level {
--depth: 0;
margin-left: calc(var(--depth) * 12px);
@ -2508,22 +2368,11 @@ textarea.input {
.thread-block {
gap: 8px;
border-radius: 15px;
padding: 8px;
padding: 10px;
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));
}
.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 {
color: #b9cbef;
}
@ -2590,10 +2439,6 @@ textarea.input {
color: rgba(255, 255, 255, 0.55);
}
.thread-open-btn {
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 430px) {
.channels-screen .page-title {
font-size: 26px;
@ -2727,7 +2572,7 @@ textarea.input {
.channels-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 6px;
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));
}
.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 {
min-height: 38px;
border-radius: 10px;
@ -3312,14 +3144,6 @@ textarea.input {
background: none;
color: rgba(255, 255, 255, 0.9);
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 {
@ -3499,10 +3323,6 @@ textarea.input {
font-weight: 700;
}
.dm-screen .list-item {
align-items: stretch;
}
.dm-screen .meta-muted {
color: rgba(255, 255, 255, 0.5);
}
@ -3525,49 +3345,6 @@ textarea.input {
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 {
gap: 12px;
}
@ -3604,16 +3381,7 @@ textarea.input {
.dm-chat-input {
gap: 10px;
grid-template-columns: 1fr 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);
grid-template-columns: 1fr auto auto auto;
}
.dm-voice-btn {
@ -3621,21 +3389,6 @@ textarea.input {
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 {
width: 100%;
height: 8px;
@ -3673,35 +3426,6 @@ textarea.input {
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-screen .dm-status-line {
display: block;
@ -3821,12 +3545,6 @@ textarea.input {
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 {
background: rgba(18, 24, 38, 0.4);
backdrop-filter: blur(25px);
@ -4117,13 +3835,7 @@ textarea.input {
.profile-top-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
}
.profile-bottom-actions {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1.6fr 1fr 1fr;
gap: 5px;
}
@ -4132,18 +3844,13 @@ textarea.input {
min-height: 32px;
padding: 0 10px;
font-size: 12px;
line-height: 1.15;
line-height: 1;
text-align: center;
white-space: pre-line;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-links-header-btn {
white-space: pre-line;
line-height: 1.1;
}
.profile-main-card {
margin-top: 0;
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_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) ===================== */
/** Параметр профиля key/value (обе строки). */

View File

@ -16,13 +16,9 @@ import java.util.Objects;
* FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31
* SPOUSE=40, UNSPOUSE=41
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* 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 НЕ ХРАНИМ):
* [4] lineCode
@ -196,13 +192,7 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|| v == (MsgSubType.CONNECTION_SIBLING & 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);
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF);
}
@Override

View File

@ -105,7 +105,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.toBlockHash32 = null;
}
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
this.message = readStrictUtf8Len16(bb, "TextLineBody text");
ensureNoTail(bb, "TextLineBody");
}
@ -129,9 +129,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
}
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = subType;
this.version = VER;
@ -167,15 +165,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
if (prevLineHash32 == null || prevLineHash32.length != 32)
throw new IllegalArgumentException("prevLineHash32 invalid");
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
} else {
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST must not contain target fields");
}
@ -186,12 +184,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
int cap;
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
@ -238,12 +234,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
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());
if (len == 0) {
if (allowEmpty) return "";
throw new IllegalArgumentException(fieldName + " is empty");
}
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
@ -255,7 +248,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
try {
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;
} catch (CharacterCodingException 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);
}
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
this.message = readStrictUtf8Len16(bb, "TextReplyBody text");
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)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
@ -144,18 +142,18 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
} else {
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
}
@ -166,12 +164,10 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
@Override
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
@ -217,12 +213,9 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
/* ====================== 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());
if (len == 0) {
if (allowEmpty) return "";
throw new IllegalArgumentException(fieldName + " is empty");
}
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
@ -234,7 +227,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
try {
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;
} catch (CharacterCodingException 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_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) {
AppConfig config = AppConfig.getInstance();
String dbPath = config.getParam("db.path");

View File

@ -198,9 +198,6 @@ public final class DatabaseTriggersInstaller {
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
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 UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
@ -209,16 +206,13 @@ public final class DatabaseTriggersInstaller {
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
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("""
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
AFTER INSERT ON blocks
WHEN NEW.msg_type = 3
BEGIN
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING:
-- 1) если записи РЅРµС вЂ СЃРѕР·РґР°СРј
INSERT OR IGNORE INTO connections_state (
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,
COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@ -245,15 +233,9 @@ public final class DatabaseTriggersInstaller {
NEW.to_bch_name,
NEW.to_block_number,
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(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@ -274,12 +256,6 @@ public final class DatabaseTriggersInstaller {
AND rel_type = NEW.msg_sub_type
AND to_login = COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@ -288,15 +264,9 @@ public final class DatabaseTriggersInstaller {
ELSE NULL
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(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@ -307,18 +277,12 @@ public final class DatabaseTriggersInstaller {
) 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
WHERE login = NEW.login
AND to_login = COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
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
ELSE rel_type
END
AND COALESCE(
NEW.to_login,
(
SELECT su.login
FROM solana_users su
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
LIMIT 1
),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@ -356,11 +311,11 @@ public final class DatabaseTriggersInstaller {
ELSE NULL
END
) 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;
""".formatted(
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
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,
UNFRIEND, FRIEND,
UNCONTACT, CONTACT,
@ -369,11 +324,8 @@ public final class DatabaseTriggersInstaller {
UNPARENT, PARENT,
UNCHILD, CHILD,
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) ===================== */
/**
* Совпадает с ConnectionBody:
* 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,
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55
*/
/** Добавить в близкие друзья (close friend). */
@ -94,24 +92,6 @@ public final class MsgSubType {
/** Удалить связь "брат/сестра". */
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) ===================== */
/** Параметр профиля key/value (обе строки). */

View File

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

View File

@ -410,23 +410,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
// target columns (optional)
if (block.body instanceof BodyHasTarget t) {
String targetBchName = t.toBchName();
int type = block.type & 0xFFFF;
int sub = block.subType & 0xFFFF;
boolean isTextEdit = type == 1
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
if (isTextEdit && (targetBchName == null || targetBchName.isBlank())) {
targetBchName = blockchainName;
}
be.setToLogin(t.toLogin());
be.setToBchName(targetBchName);
be.setToBchName(t.toBchName());
be.setToBlockNumber(t.toBlockGlobalNumber());
be.setToBlockHash(t.toBlockHashBytes());
if (isTextEdit && (be.getToLogin() == null || be.getToLogin().isBlank()) && targetBchName != null && !targetBchName.isBlank()) {
be.setToLogin(BlockchainNameUtil.loginFromBlockchainName(targetBchName));
}
}
// edit helper (optional): если TEXT_EDIT_* это "редактирование блока цели"

View File

@ -218,17 +218,16 @@ final class ChannelsReadSupport {
SELECT login,bch_name,block_number,block_hash,block_bytes
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
AND to_block_number=? AND to_block_hash=?
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
ps.setInt(4, originalBlock);
ps.setBytes(5, originalHash);
ps.setString(6, ownerBch);
ps.setString(4, ownerBch);
ps.setInt(5, originalBlock);
ps.setBytes(6, originalHash);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {

View File

@ -111,7 +111,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
v1.setCreatedAtMs(postText.createdAtMs);
versionsOut.add(v1);
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, ownerBch, post.blockNumber, post.blockHash);
for (ChannelsReadSupport.PostBlock edit : edits) {
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();

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
FROM blocks
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
AND to_block_number=? AND to_block_hash=?
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
ORDER BY block_number ASC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, subType);
ps.setInt(3, targetBlock);
ps.setBytes(4, targetHash);
ps.setString(5, bch);
ps.setString(3, bch);
ps.setInt(4, targetBlock);
ps.setBytes(5, targetHash);
try (ResultSet rs = ps.executeQuery()) {
List<PostRow> out = new ArrayList<>();
while (rs.next()) out.add(mapRow(rs));

View File

@ -30,14 +30,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? "" : req.getLogin().trim();
if (requestedLogin.isEmpty()) {
if (ctx != null && ctx.isAuthenticatedUser()) {
requestedLogin = ctx.getLogin();
} else {
return NetExceptionResponseFactory.error(req, 422, "LOGIN_REQUIRED", "Нужно передать login пользователя");
}
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim();
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
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> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(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<>();
allLogins.add(canonicalLogin);
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings);
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
@ -97,12 +86,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
resp.setInChildren(inChildren);
resp.setOutSiblings(outSiblings);
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.setChildren(toRelativeItems(childLogins, 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> outSiblings = 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> children = 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 List<String> getInSiblings() { return 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 void setParents(List<RelativeItem> parents) { this.parents = parents; }
public List<RelativeItem> getChildren() { return children; }