Compare commits
23 Commits
ab31ccf6d8
...
21413268f3
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
21413268f3 | ||
|
|
aa35d87885 | ||
|
|
a53444b863 | ||
|
|
4b0031fb08 | ||
|
|
62b8534769 | ||
|
|
f3262c2d64 | ||
|
|
7986184111 | ||
|
|
9c35567389 | ||
|
|
8325cbec84 | ||
|
|
c6d310184b | ||
|
|
83892d5093 | ||
|
|
1e1cdd9e76 | ||
|
|
3e62a2a01c | ||
|
|
90d10086d7 | ||
|
|
db2d9a666b | ||
|
|
3a0899bcfe | ||
|
|
d13c60fca1 | ||
|
|
580bd6fbeb | ||
|
|
49ebf1605a | ||
|
|
f1fbb35296 | ||
|
|
b85643ca33 | ||
|
|
c27da63a3e | ||
|
|
a332ddc828 |
28
AGENTS.md
28
AGENTS.md
@ -13,6 +13,21 @@
|
||||
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
||||
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `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` (в корне репозитория).
|
||||
@ -22,16 +37,15 @@
|
||||
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
||||
|
||||
## Deploy
|
||||
- Все документы и заметки по деплою хранить в папке `Deploy Server/`.
|
||||
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
|
||||
- Базовый целевой хост для деплоя по умолчанию: `player@45.136.124.227`.
|
||||
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
||||
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
|
||||
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||
- Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
|
||||
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
|
||||
- По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
|
||||
- При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
|
||||
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
|
||||
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
|
||||
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
|
||||
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
|
||||
|
||||
## Логи звонков (установка соединения)
|
||||
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
# Настройка 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-ограничения.
|
||||
@ -107,6 +107,20 @@
|
||||
- `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)`
|
||||
|
||||
@ -16,8 +16,8 @@
|
||||
|
||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY.
|
||||
- `type=2` — REACTION: LIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW и обратные операции.
|
||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
||||
|
||||
## Примечание
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Типы каналов и CreateChannel
|
||||
|
||||
## 1. Формат `CreateChannelBody`
|
||||
Формат `TECH_CREATE_CHANNEL` поддерживает единственный текущий `version=1` и включает:
|
||||
## 1. Формат `CreateChannelBody` (`msg_type=0`, `subType=1`, `version=1`)
|
||||
Payload включает:
|
||||
|
||||
1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`);
|
||||
2. `channelName`;
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
Версия типа (`channelTypeVersion`) сейчас используется со значением `1`.
|
||||
|
||||
Важно для MVP:
|
||||
- `100` и `200` в формате поддерживаются, но в текущем UI не используются.
|
||||
- В MVP рабочий UI-флоу — каналы `0` и `1`.
|
||||
|
||||
## 3. Имя root-канала
|
||||
- Root-канал (`line_code = 0`) в API/чтении отображается как `stories`.
|
||||
- Публикации в `stories` разрешены владельцу собственного блокчейна.
|
||||
|
||||
@ -24,7 +24,21 @@
|
||||
- связей и подписок;
|
||||
- пользовательских параметров.
|
||||
|
||||
## 3. Root-идея для каналов и подписок
|
||||
## 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-идея для каналов и подписок
|
||||
|
||||
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
|
||||
- `HEADER` для базовой сущности пользователя/канала `0`;
|
||||
|
||||
@ -27,3 +27,7 @@
|
||||
- Команды передаются как обычные `TEXT_POST` сообщения.
|
||||
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
|
||||
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
|
||||
|
||||
## 4. Статус для MVP
|
||||
- В текущем UI каналы `type=100` и `type=200` не используются.
|
||||
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
|
||||
|
||||
@ -10,7 +10,7 @@ TECH-тип покрывает системные записи цепочки.
|
||||
|
||||
2. `subType=1` — `TECH_CREATE_CHANNEL`
|
||||
- создание нового канала;
|
||||
- хранит line-поля + `channelName`.
|
||||
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
||||
|
||||
## Назначение
|
||||
|
||||
|
||||
@ -19,7 +19,14 @@ TEXT-тип хранит сообщения и редактирования.
|
||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||
- редактирование ответа;
|
||||
- target на исходный REPLY + новый текст.
|
||||
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
|
||||
|
||||
## Правило для edit
|
||||
|
||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||
|
||||
## Пустой text в edit
|
||||
|
||||
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
|
||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||
|
||||
@ -5,6 +5,9 @@
|
||||
1. `subType=1` — `REACTION_LIKE`
|
||||
- лайк на целевой блок;
|
||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
2. `subType=2` — `REACTION_UNLIKE`
|
||||
- снятие лайка с целевого блока;
|
||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||
|
||||
## Назначение
|
||||
|
||||
|
||||
@ -4,12 +4,16 @@ CONNECTION-тип описывает социальные связи и подп
|
||||
|
||||
## Подтипы
|
||||
|
||||
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`
|
||||
• `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` (мало знаком, но видел сияющим)
|
||||
|
||||
## Общий формат payload
|
||||
|
||||
@ -22,3 +26,4 @@ CONNECTION-тип описывает социальные связи и подп
|
||||
- FOLLOW указывает на root канала:
|
||||
- `HEADER` для канала `0`;
|
||||
- `CREATE_CHANNEL` для пользовательского канала.
|
||||
- Для остальных типов связи (`SPOUSE/PARENT/CHILD/SIBLING`) используется тот же target-формат.
|
||||
|
||||
@ -1,5 +1,27 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 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 байта)`.
|
||||
|
||||
@ -1,16 +1,33 @@
|
||||
# Blockchain Docs (Актуально)
|
||||
# Документация блокчейна SHiNE (MVP)
|
||||
|
||||
## Назначение
|
||||
Этот набор файлов — актуальная документация по текущему формату блокчейна SHiNE для каналов, их типов и командных сообщений.
|
||||
Этот каталог описывает только текущий рабочий формат протокола для MVP.
|
||||
|
||||
## Оглавление
|
||||
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)
|
||||
История изменений документации и блокчейн-правил.
|
||||
## Основные документы
|
||||
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)
|
||||
Журнал изменений документации.
|
||||
|
||||
## Обязательное правило сопровождения
|
||||
- Любое изменение блокчейн-кода (форматы, типы, правила чтения/записи, команды) должно сопровождаться обновлением файлов из этого каталога.
|
||||
- Изменение обязательно фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||
## Важные ограничения MVP
|
||||
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
|
||||
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`.
|
||||
|
||||
## Обязательное сопровождение
|
||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
||||
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
# Уведомления: продуктовые заглушки + правило intake в AGENTS
|
||||
|
||||
- краткое описание фичи:
|
||||
- На вкладке `Уведомления` удалены демонстрационные карточки из разделов `Ответы` и `События`.
|
||||
- В каждом табе добавлена отдельная продуктовая заглушка:
|
||||
- `Ответы`: про ответы и комментарии на сообщения в публичных каналах;
|
||||
- `События`: про подписки, добавления, лайки и прочие действия.
|
||||
- В обоих табах добавлено явное сообщение, что раздел находится в разработке.
|
||||
- В `AGENTS.md` добавлен обязательный блок: при новом задании сначала пересказ, вопросы, при необходимости идеи, оценка фичи и обязательное подтверждение перед началом реализации.
|
||||
|
||||
- что именно проверять:
|
||||
- Открыть `Уведомления` и проверить, что в `Ответы` отображается только заглушка (без примеров карточек).
|
||||
- Переключить на `События` и проверить отдельную заглушку с текстом про события.
|
||||
- Убедиться, что в обоих табах присутствует сообщение о разработке и будущем добавлении функционала.
|
||||
- Проверить наличие нового блока `Коммуникация по новым задачам (обязательно)` в `AGENTS.md`.
|
||||
|
||||
- ожидаемый результат:
|
||||
- Вкладка уведомлений содержит только две заглушки по табам и не показывает тестовые данные.
|
||||
- Правило работы с новыми задачами зафиксировано в `AGENTS.md`.
|
||||
|
||||
- статус:
|
||||
- pending
|
||||
@ -0,0 +1,36 @@
|
||||
## Краткое описание
|
||||
|
||||
На экране `Кошелёк -> 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`
|
||||
@ -0,0 +1,19 @@
|
||||
# Навигация по тредам и история сообщения
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Краткое описание
|
||||
В экране треда добавлен явный переход `🧵 В тред` для каждого сообщения (включая ответы), чтобы можно было углубляться в любую ветку обсуждения.
|
||||
Также уточнены заголовки блоков: сверху история сообщений, отдельно текущее сообщение.
|
||||
|
||||
## Что проверять
|
||||
1. Открыть любой канал и перейти в тред сообщения.
|
||||
2. Нажать `🧵 В тред` у одного из ответов.
|
||||
3. Убедиться, что открывается тред выбранного ответа, а не исходного сообщения.
|
||||
4. Проверить, что в новом треде сверху показывается блок истории (`История выше...`), затем блок `Текущее сообщение`, затем `Ответы`.
|
||||
5. Проверить на мобильной ширине, что кнопки действий в карточке не ломают верстку.
|
||||
|
||||
## Ожидаемый результат
|
||||
- Переход в тред ответа работает стабильно для всех узлов дерева.
|
||||
- Пользователь видит структуру треда в логичном порядке: предки → текущее сообщение → потомки.
|
||||
- UI остаётся читаемым на мобильных экранах.
|
||||
@ -0,0 +1,19 @@
|
||||
# Короткая ссылка на сообщение `#/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 для открытия треда не воспроизводится.
|
||||
@ -0,0 +1,21 @@
|
||||
# Переход на 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`
|
||||
@ -0,0 +1,25 @@
|
||||
# Карточка автора в сообщении канала и стрелка «назад» по истории
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` в карточке сообщения добавлена вложенная плитка автора (аватар, логин, номер сообщения, дата/время).
|
||||
- Клик по плитке автора открывает профиль пользователя.
|
||||
- Клик по области сообщения (вне плитки автора и вне action-кнопок) открывает тред, как кнопка `Тред`.
|
||||
- Стрелка `назад` в `channel-view`, `channel-thread-view` и профиле переведена на реальную навигацию `history.back()`.
|
||||
- Маршрут профиля переименован с `user-profile-view` на `user`.
|
||||
|
||||
- Что проверять:
|
||||
- В канале у каждого сообщения сверху есть вложенная плитка автора.
|
||||
- Клик по вложенной плитке открывает профиль автора.
|
||||
- Клик по тексту/телу сообщения открывает тред.
|
||||
- Кнопки `Лайк`, `Ответить`, `Тред`, `Отправить` работают отдельно и не конфликтуют с кликом по карточке.
|
||||
- Стрелка `назад` возвращает на предыдущий экран по реальной истории переходов.
|
||||
- При отсутствии истории стрелка `назад` не делает переход.
|
||||
- Переходы на профиль работают по новому маршруту `user/{login}/...`.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Навигация в каналах и тредах соответствует ожидаемому UX.
|
||||
- Переходы в профиль и назад по истории работают стабильно.
|
||||
- Старый маршрут `user-profile-view` больше не используется.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -0,0 +1,27 @@
|
||||
# Шапка канала и унификация карточек в треде
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` убрана отдельная кнопка `О канале` из тела экрана.
|
||||
- В шапке добавлена одна кнопка-лейбл формата `owner/channel` на уровне стрелки назад.
|
||||
- Нажатие по кнопке `owner/channel` открывает тот же модал «О канале».
|
||||
- В `channel-thread-view` карточки сообщений приведены к виду, аналогичному карточкам в канале:
|
||||
- верхняя плитка автора (аватар, логин, номер, время),
|
||||
- действия `Лайк`, `Ответить`, `Тред`, `Отправить`.
|
||||
- Клик по телу карточки в треде теперь открывает вложенный (более глубокий) тред этого сообщения.
|
||||
- Уменьшены отступы между карточками/блоками в треде.
|
||||
|
||||
- Что проверять:
|
||||
- В канале в шапке справа отображается единая кнопка `owner/channel`.
|
||||
- Кнопка `owner/channel` открывает модал «О канале».
|
||||
- Старой кнопки `О канале` в контенте экрана нет.
|
||||
- В треде визуал карточек совпадает по паттерну с каналом.
|
||||
- В треде клик по телу сообщения ведёт глубже в тред.
|
||||
- Клик по плитке автора в треде ведёт в профиль пользователя.
|
||||
- Межкарточные отступы в треде компактнее.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Шапка канала и карточки треда выглядят и работают единообразно.
|
||||
- Навигация по вложенным тредам выполняется кликом по сообщению.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -0,0 +1,18 @@
|
||||
# Поднятие верхней фиксированной шапки (канал и тред)
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` и `channel-thread-view` верхняя фиксированная шапка (стрелка назад + центральная кнопка с названием) поднята выше к верхней границе экрана.
|
||||
- Центральная кнопка и стрелка дополнительно подняты внутри шапки для более плотного позиционирования.
|
||||
- Поведение hover/focus сохранено без визуального «прыжка» центральной кнопки.
|
||||
|
||||
- Что проверять:
|
||||
- В канале и в треде верхняя шапка визуально выше, чем до правки.
|
||||
- Кнопка по центру и стрелка назад подняты и находятся на одной линии.
|
||||
- При наведении курсора центральная кнопка не смещается.
|
||||
- Шапка остаётся фиксированной при прокрутке.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Верхняя навигационная область выглядит компактнее и стабильнее.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -0,0 +1,26 @@
|
||||
# Профиль: упрощение + чат: UX меню и голосовой ввод
|
||||
|
||||
- Краткое описание:
|
||||
- В `profile-view` убрана кнопка `Обновить` и статусная строка (`Профиль обновлён`/ошибки).
|
||||
- Кнопка `Изменить профиль` переименована в `Редактировать профиль`.
|
||||
- В личном чате обновлены UX-сценарии:
|
||||
- контекстное меню на сообщении (`Копировать`, `Прочесть`) с закрытием кликом вне меню;
|
||||
- тост `Сообщение скопированно` при копировании;
|
||||
- обновлённый модал голосового ввода (`Отмена`, `OK`, `Распознать и сразу отправить сообщение`);
|
||||
- фоновое распознавание и авто-отправка для сценария «сразу отправить»;
|
||||
- для `OK` отображается режим ожидания распознавания с последующей вставкой текста в поле ввода.
|
||||
|
||||
- Что проверять:
|
||||
- На вкладке профиля отсутствуют кнопка `Обновить` и зелёный статус `Профиль обновлён`.
|
||||
- Кнопка вверху профиля называется `Редактировать профиль`.
|
||||
- В чате по клику на сообщение открывается компактное меню с двумя пунктами.
|
||||
- Копирование текста сообщения работает и показывает короткий тост.
|
||||
- Прочтение сообщения вслух запускается сразу.
|
||||
- Голосовой ввод корректно работает в двух режимах: вставка текста и авто-отправка после распознавания.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Профиль выглядит чище, без лишних статусов и ручной перезагрузки.
|
||||
- В личных сообщениях управление сообщениями и голосовым вводом работает стабильно и предсказуемо.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -0,0 +1,28 @@
|
||||
# DM: Ctrl+Enter, автоскролл и время в списке
|
||||
|
||||
- Статус: `pending`
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Исправлено поведение ввода в чате:
|
||||
- `Enter` отправляет сообщение;
|
||||
- `Ctrl+Enter` добавляет перенос строки в поле ввода.
|
||||
- В списке личных сообщений время последнего сообщения всегда отображается в правой колонке снизу.
|
||||
- Бейдж непрочитанных сообщений (если есть) отображается над временем, не заменяя его.
|
||||
- Обновлены стили карточки диалога для компактного и стабильного выравнивания.
|
||||
|
||||
## Что проверять
|
||||
|
||||
- В чате:
|
||||
- нажать `Ctrl+Enter` в середине текста и убедиться, что вставляется новая строка;
|
||||
- нажать `Enter` и убедиться, что сообщение отправляется.
|
||||
- В списке диалогов:
|
||||
- при `unread=0` справа снизу показывается время;
|
||||
- при `unread>0` сверху бейдж, снизу всё равно показывается время;
|
||||
- длинный текст последнего сообщения обрезается многоточием и не наезжает на время.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Управление вводом работает как в постановке.
|
||||
- Время в карточке диалога не исчезает при наличии непрочитанных сообщений.
|
||||
- Верстка карточки остаётся компактной и без сдвигов.
|
||||
@ -0,0 +1,24 @@
|
||||
# Личные сообщения: правая мета-колонка и Enter/Ctrl+Enter
|
||||
|
||||
- Краткое описание:
|
||||
- В списке `Личные сообщения` обновлена правая колонка карточки диалога:
|
||||
- сверху отображается бейдж количества непрочитанных (если есть);
|
||||
- снизу маленьким шрифтом отображается дата/время последнего сообщения;
|
||||
- если сообщений нет, вместо времени отображается `-`.
|
||||
- В экране чата нижний блок ввода закреплён (sticky) и остаётся на месте при прокрутке.
|
||||
- В поле ввода чата изменено поведение клавиш:
|
||||
- `Enter` отправляет сообщение;
|
||||
- `Ctrl+Enter` добавляет перенос строки и не отправляет сообщение.
|
||||
|
||||
- Что проверять:
|
||||
- В карточках диалогов справа корректно показываются непрочитанные/время/прочерк.
|
||||
- В чате нижний блок ввода не уезжает при прокрутке истории.
|
||||
- `Enter` отправляет сообщение из textarea.
|
||||
- `Ctrl+Enter` вставляет новую строку в textarea.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Список диалогов показывает полезную мета-информацию в стабильном формате.
|
||||
- Ввод сообщений в чате работает в привычной схеме Enter/многострочность.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -0,0 +1,33 @@
|
||||
# Деплой на `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 работают как ожидается.
|
||||
@ -0,0 +1,24 @@
|
||||
# Редактирование сообщений: история и delete через пустой edit
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Краткое описание
|
||||
- Исправлено применение edit-блоков в чтении канала/треда (актуальный текст и версии).
|
||||
- Для удаления сообщения используется edit с пустым `text` (`textLen=0`).
|
||||
- В UI добавлена метка `изменено N`, по нажатию открывается история версий.
|
||||
- Кнопка редактирования оставлена как иконка карандаша без текста.
|
||||
- В модалке редактирования: сверху `Отмена` и `ОК`, снизу отдельная `Удалить`.
|
||||
|
||||
## Что проверять
|
||||
1. В канале отредактировать свой пост обычным текстом.
|
||||
2. Убедиться, что текст сообщения сразу обновился и появилась метка `изменено 1`.
|
||||
3. Нажать на метку `изменено 1` и проверить историю: сверху оригинал, ниже изменения, последнее внизу.
|
||||
4. Нажать `Удалить` в модалке редактирования, убедиться, что сообщение отображается как `удалено`.
|
||||
5. Повторно отредактировать удалённое сообщение непустым текстом и проверить, что текст снова отображается.
|
||||
6. Повторить пп.1-5 в экране треда.
|
||||
7. Проверить личный канал (пара A↔B), что edit и история корректно видны для сообщений владельца.
|
||||
|
||||
## Ожидаемый результат
|
||||
- Edit всегда влияет на отображаемый текст сообщения.
|
||||
- История версий открывается из метки `изменено N` и содержит полный хронологический список версий.
|
||||
- Удаление работает как edit с пустым текстом, без физического удаления блока.
|
||||
@ -0,0 +1,23 @@
|
||||
# Каналы: убрать кнопку тред, две вкладки, и автоскролл DM
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Краткое описание
|
||||
- В карточках сообщений канала и треда убрана кнопка `Тред` (`#`).
|
||||
- В `channels-list` оставлены только две вкладки: `Каналы` и `Мои каналы` (вкладка `Чаты` удалена).
|
||||
- Удалённые сообщения в канале и треде отображаются с красной пометкой `Сообщение удалено`.
|
||||
- В личных сообщениях усилен автоскролл вниз: при открытии чата и после отправки нового сообщения.
|
||||
|
||||
## Что проверять
|
||||
1. Открыть канал, проверить, что в действиях сообщения нет кнопки `Тред`.
|
||||
2. Открыть тред, проверить, что в действиях сообщения также нет кнопки `Тред`.
|
||||
3. В списке каналов сверху проверить только 2 вкладки: `Каналы` и `Мои каналы`.
|
||||
4. Удалить сообщение edit-ом в канале и в треде, убедиться, что видно красную пометку `Сообщение удалено`.
|
||||
5. Открыть любой личный чат, убедиться, что экран сразу внизу ленты.
|
||||
6. Отправить новое сообщение в личке, убедиться, что лента остаётся прокрученной вниз и сообщение видно сразу.
|
||||
|
||||
## Ожидаемый результат
|
||||
- Лишняя кнопка `Тред` отсутствует.
|
||||
- Вкладка `Чаты` отсутствует, навигация в списке каналов состоит из 2 вкладок.
|
||||
- Удалённые сообщения визуально выделены красным в канале и в треде.
|
||||
- В DM нет ручной прокрутки после входа в чат и после отправки новых сообщений.
|
||||
@ -0,0 +1,15 @@
|
||||
## Краткое описание
|
||||
Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений.
|
||||
|
||||
## Что проверять
|
||||
1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
||||
2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
||||
3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан).
|
||||
4. Если аватар не задан или недоступен, корректно остаётся fallback (буква).
|
||||
5. Форма и размер остаются круглыми и визуально не ломают карточки.
|
||||
|
||||
## Ожидаемый результат
|
||||
Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI.
|
||||
|
||||
## Статус
|
||||
`pending`
|
||||
@ -0,0 +1,23 @@
|
||||
## Краткое описание
|
||||
Добавлены новые типы 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`
|
||||
@ -0,0 +1,25 @@
|
||||
## Краткое описание
|
||||
Перестроен блок связей в профиле чужого пользователя и добавлен UI для одностороннего "мнения" (`known_person` / `shine_confirmed` / `shine_seen`) с взаимным исключением на уровне UI.
|
||||
|
||||
## Что проверять
|
||||
1. Порядок базовых строк в профиле:
|
||||
- Контакт
|
||||
- Близкий друг
|
||||
- Подписка
|
||||
2. Под этими строками отображается блок мнений:
|
||||
- при отсутствии мнения кнопка `Добавить связь`;
|
||||
- при наличии мнения кнопка `Изменить связи`;
|
||||
- показываются текстовые формулировки для активного мнения.
|
||||
3. В модальном меню:
|
||||
- варианты добавления (синие);
|
||||
- `Убрать мнение` (красная).
|
||||
4. При смене мнения отправляется последовательность:
|
||||
- OFF старой связи,
|
||||
- ON новой связи.
|
||||
5. Для новых мнений показываются только исходящие (`out*`) оценки текущего пользователя (односторонняя логика).
|
||||
|
||||
## Ожидаемый результат
|
||||
Пользователь управляет одним активным мнением через UI, состояние читается корректно и не ломает существующие friend/contact/follow кнопки.
|
||||
|
||||
## Статус
|
||||
`pending`
|
||||
221
Dev_Docs/Personal_Messages/README.md
Normal file
221
Dev_Docs/Personal_Messages/README.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Личные сообщения (DM): как это устроено
|
||||
|
||||
## Коротко (для быстрого понимания)
|
||||
|
||||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
||||
|
||||
- тип `1` — входящее сообщение для собеседника;
|
||||
- тип `2` — исходящая копия того же сообщения для автора.
|
||||
|
||||
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
|
||||
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
|
||||
|
||||
Подтверждение прочтения также идёт парой блоков:
|
||||
|
||||
- тип `3` — «прочитано» для исходящего сообщения автора;
|
||||
- тип `4` — зеркальная копия для второй стороны.
|
||||
|
||||
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
|
||||
|
||||
---
|
||||
|
||||
## Подробно
|
||||
|
||||
## 1) Общая схема потока
|
||||
|
||||
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
|
||||
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
|
||||
3. Сервер:
|
||||
- парсит оба блока;
|
||||
- валидирует пару;
|
||||
- проверяет существование `from/to` пользователей и подписи;
|
||||
- атомарно сохраняет пару в `signed_messages_v2`.
|
||||
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
|
||||
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
|
||||
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
|
||||
|
||||
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
||||
|
||||
Префикс: `SHiNE_dm2` (ASCII).
|
||||
|
||||
Далее поля (big-endian):
|
||||
|
||||
1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60);
|
||||
2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60);
|
||||
3. `timeMs` (`u64`);
|
||||
4. `nonce` (`u32`);
|
||||
5. `messageType` (`u16`);
|
||||
6. `payloadLen` (`u16`);
|
||||
7. `payloadBytes` (`1..4096`);
|
||||
8. `signature` (`64 bytes`, Ed25519).
|
||||
|
||||
Ограничения:
|
||||
|
||||
- полный пакет: до `8192` байт;
|
||||
- `messageType` сейчас допустим только `1..4`.
|
||||
|
||||
## 3) Типы DM-сообщений
|
||||
|
||||
- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя.
|
||||
- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора.
|
||||
- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции).
|
||||
- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt.
|
||||
|
||||
Правило пары:
|
||||
|
||||
- первый блок должен быть нечётным (`1` или `3`);
|
||||
- второй должен быть ровно `+1` (`2` или `4`);
|
||||
- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`.
|
||||
|
||||
## 4) Ключи сообщений
|
||||
|
||||
- `baseKey = from|to|timeMs|nonce`
|
||||
- `messageKey = baseKey|messageType`
|
||||
|
||||
Эти ключи используются:
|
||||
|
||||
- для дедупликации;
|
||||
- для связи read-receipt с исходным сообщением;
|
||||
- для ACK доставки по сессии.
|
||||
|
||||
## 5) RPC и события
|
||||
|
||||
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendMessagePair",
|
||||
"requestId": "req-1",
|
||||
"payload": {
|
||||
"incomingBlobB64": "<base64 signed block type 1 or 3>",
|
||||
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendMessagePair",
|
||||
"requestId": "req-1",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"baseKey": "from|to|time|nonce",
|
||||
"incomingKey": "from|to|time|nonce|1",
|
||||
"outgoingKey": "from|to|time|nonce|2",
|
||||
"deliveredWsSessions": 2,
|
||||
"deliveredWebPushSessions": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `SignedMessageArrived` (server event)
|
||||
|
||||
Событие в сессию получателя содержит:
|
||||
|
||||
- `messageKey`, `baseKey`;
|
||||
- `fromLogin`, `toLogin`, `targetLogin`;
|
||||
- `messageType`, `timeMs`, `nonce`;
|
||||
- `blobB64`;
|
||||
- `backlog` (признак догрузки из очереди).
|
||||
|
||||
## `AckSessionDelivery`
|
||||
|
||||
Запрос:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "AckSessionDelivery",
|
||||
"requestId": "ack-1",
|
||||
"payload": {
|
||||
"messageKey": "from|to|time|nonce|1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ответ: `status=200`, echo `messageKey`.
|
||||
|
||||
## 6) Хранение на сервере (SQLite)
|
||||
|
||||
Основные таблицы:
|
||||
|
||||
1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`:
|
||||
- `message_key` (PK),
|
||||
- `base_key`,
|
||||
- `target_login`,
|
||||
- `from_login`, `to_login`,
|
||||
- `time_ms`, `nonce`, `message_type`,
|
||||
- `raw_block`,
|
||||
- `source_api`, `origin_session_id`,
|
||||
- `receipt_ref_base_key`, `receipt_ref_type`.
|
||||
2. `signed_message_session_delivery` — доставка по сессиям:
|
||||
- составной PK `(message_key, session_id)`,
|
||||
- `delivered` (0/1),
|
||||
- `delivered_at_ms`, `created_at_ms`.
|
||||
|
||||
Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`.
|
||||
|
||||
## 7) Доставка и backlog
|
||||
|
||||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||||
- Для офлайн/недоступных сессий остаётся pending-запись доставки.
|
||||
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`.
|
||||
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`.
|
||||
|
||||
## 8) Read-receipt логика
|
||||
|
||||
Когда клиент открывает чат:
|
||||
|
||||
1. ищет входящие `messageType=1` без `readReceiptSent`;
|
||||
2. для каждого отправляет read-receipt как пару `type=3/4`;
|
||||
3. после успешной отправки помечает `readReceiptSent`.
|
||||
|
||||
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
||||
|
||||
- `receipt_ref_base_key`;
|
||||
- `receipt_ref_type`.
|
||||
|
||||
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
||||
|
||||
## 9) Логика UI-клиента
|
||||
|
||||
В UI:
|
||||
|
||||
- чат хранится в `state.chats[chatId]`;
|
||||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`;
|
||||
- непрочитанные считаются по `from='in' && unread=true`;
|
||||
- доставка/прочтение исходящих:
|
||||
- `firstTick` — сообщение принято в парный поток,
|
||||
- `secondTick` — пришло подтверждение прочтения;
|
||||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||||
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости;
|
||||
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
|
||||
|
||||
## 10) Инварианты (обязательно соблюдать при доработках)
|
||||
|
||||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
||||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
||||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
||||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
||||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
||||
|
||||
## 11) Ключевые файлы реализации
|
||||
|
||||
- UI:
|
||||
- `shine-UI/js/services/auth-service.js`
|
||||
- `shine-UI/js/app.js`
|
||||
- `shine-UI/js/state.js`
|
||||
- `shine-UI/js/pages/chat-view.js`
|
||||
- Сервер:
|
||||
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
||||
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
||||
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
||||
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
||||
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
|
||||
- БД:
|
||||
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
||||
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`
|
||||
@ -0,0 +1,42 @@
|
||||
# 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 (между своими клиентами/агентом) в рамках одного логина.
|
||||
38
Dev_Docs/deploy/README.md
Normal file
38
Dev_Docs/deploy/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Деплой 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/` обновлять в том же коммите.
|
||||
23
Dev_Docs/deploy/servers/45.136.124.227_legacy_unavailable.md
Normal file
23
Dev_Docs/deploy/servers/45.136.124.227_legacy_unavailable.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Сервер `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`
|
||||
29
Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md
Normal file
29
Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Сервер `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`.
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.57
|
||||
server.version=1.2.51
|
||||
client.version=1.2.80
|
||||
server.version=1.2.74
|
||||
|
||||
62
build.gradle
62
build.gradle
@ -182,66 +182,21 @@ tasks.register('deployServer', JavaExec) {
|
||||
// можно переопределить при запуске:
|
||||
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||
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.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.service", System.getProperty("it.service", "shine-server")
|
||||
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||
|
||||
dependsOn testClasses
|
||||
}
|
||||
|
||||
tasks.register('deployServerWithBackupCleanAndTests') {
|
||||
tasks.register('deployUI', Exec) {
|
||||
group = "!!deployment"
|
||||
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
|
||||
description = "Deploy WEB UI (production: shineup.me)"
|
||||
workingDir = rootDir
|
||||
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath, target
|
||||
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
@ -303,10 +258,11 @@ 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
|
||||
(cd "\$UI_DIR" && python3 -m http.server "\$WEB_PORT")
|
||||
SHINE_UI_PORT="\$WEB_PORT" python3 "\$SPA_SERVER_SCRIPT"
|
||||
else
|
||||
(cd "\$UI_DIR" && python -m http.server "\$WEB_PORT")
|
||||
SHINE_UI_PORT="\$WEB_PORT" python "\$SPA_SERVER_SCRIPT"
|
||||
fi
|
||||
"""
|
||||
}
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
set -euo pipefail
|
||||
|
||||
SRC_DIR="shine-UI"
|
||||
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
||||
REMOTE_BASE_DIR="${REMOTE_BASE_DIR:-/home/player/SHiNE}"
|
||||
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}"
|
||||
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
|
||||
@ -22,44 +23,8 @@ if [[ -z "$CLIENT_VERSION" ]]; then
|
||||
fi
|
||||
export CLIENT_VERSION
|
||||
|
||||
TARGET_DIR="shine-UI"
|
||||
TARGET_URL="https://shineup.me"
|
||||
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}"
|
||||
REMOTE_DIR="${REMOTE_UI_DIR}"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMP_DIR"
|
||||
@ -73,7 +38,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 ($TARGET_DIR)"
|
||||
echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)"
|
||||
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
||||
|
||||
INDEX_FILE="$TMP_DIR/index.html"
|
||||
@ -88,10 +53,30 @@ 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" "mkdir -p '$REMOTE_DIR'"
|
||||
ssh "$REMOTE_HOST" "sudo mkdir -p '$REMOTE_DIR'"
|
||||
|
||||
echo "==> Syncing staged files to $REMOTE_DIR"
|
||||
rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
||||
rsync -rlvz --delete --omit-dir-times --no-perms --no-owner --no-group \
|
||||
--rsync-path="sudo rsync" \
|
||||
"$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
||||
|
||||
echo "Всё хорошо: $TARGET_URL"
|
||||
|
||||
@ -1,38 +1,19 @@
|
||||
# Деплой UI по окружениям (Caddy sites)
|
||||
# UI deploy
|
||||
|
||||
## Куда деплоит скрипт
|
||||
- Базовая директория на сервере: `/home/user/docker/caddyFile/sites`
|
||||
- По умолчанию деплой идёт на production (`shineup.me`) в папку `shine-UI`.
|
||||
Актуальный UI-деплой выполняется одной командой:
|
||||
|
||||
## 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
|
||||
./gradlew deployUI
|
||||
```
|
||||
|
||||
## Прямой запуск скрипта
|
||||
- `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`
|
||||
По умолчанию:
|
||||
|
||||
Также поддерживаются алиасы с дефисом:
|
||||
- `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`
|
||||
|
||||
## Поддержка переопределения
|
||||
- `REMOTE_HOST` (по умолчанию `user@194.87.0.247`)
|
||||
- `REMOTE_BASE_DIR` (по умолчанию `/home/user/docker/caddyFile/sites`)
|
||||
Переопределение при необходимости:
|
||||
|
||||
Пример:
|
||||
`REMOTE_HOST=user@194.87.0.247 REMOTE_BASE_DIR=/home/user/docker/caddyFile/sites bash deploy_shine-PWA.sh ui_2`
|
||||
```bash
|
||||
REMOTE_HOST=player@93.170.12.154 REMOTE_BASE_DIR=/home/player/SHiNE bash deploy_shine-PWA.sh
|
||||
```
|
||||
|
||||
33
scripts/local_spa_server.py
Normal file
33
scripts/local_spa_server.py
Normal file
@ -0,0 +1,33 @@
|
||||
#!/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()
|
||||
@ -101,7 +101,7 @@ const routes = {
|
||||
'messages-list': messagesList,
|
||||
'contact-search-view': contactSearchView,
|
||||
'chat-view': chatView,
|
||||
'user-profile-view': userProfileView,
|
||||
user: userProfileView,
|
||||
'channels-list': channelsList,
|
||||
'channel-view': channelView,
|
||||
'channel-thread-view': channelThreadView,
|
||||
@ -136,6 +136,16 @@ 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) => {
|
||||
@ -289,7 +299,7 @@ function consumeCallPushActionFromUrlIfAny() {
|
||||
params.delete('callPushAction');
|
||||
params.delete('callPushPayload');
|
||||
const nextQuery = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`;
|
||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
} catch {
|
||||
// ignore URL parsing errors
|
||||
@ -634,7 +644,7 @@ function renderPageFailureFallback(pageId, error) {
|
||||
stack: error?.stack || '',
|
||||
context: {
|
||||
pageId,
|
||||
routeHash: window.location.hash || '',
|
||||
routeHash: window.location.pathname || '',
|
||||
},
|
||||
});
|
||||
|
||||
@ -671,7 +681,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)) {
|
||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) {
|
||||
navigate('start-view');
|
||||
return;
|
||||
}
|
||||
@ -1025,19 +1035,26 @@ 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();
|
||||
|
||||
if (!window.location.hash) {
|
||||
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||
} else {
|
||||
} finally {
|
||||
renderApp();
|
||||
}
|
||||
})();
|
||||
|
||||
window.addEventListener('hashchange', renderApp);
|
||||
window.addEventListener('popstate', renderApp);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void checkConnectionHealth();
|
||||
|
||||
@ -19,35 +19,39 @@ function showSttMissingConfigDialog(navigate) {
|
||||
if (goSettings) navigate('tools-settings-view');
|
||||
}
|
||||
|
||||
export async function openSpeechInputModal({ navigate, onTextReady }) {
|
||||
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) {
|
||||
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
||||
showSttMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="speech-input-modal">
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = `
|
||||
<div class="modal" id="speech-input-modal-layer">
|
||||
<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="form-actions-grid">
|
||||
<div class="speech-actions-top">
|
||||
<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 = 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 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 recorder = createMicrophoneRecorder();
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
@ -55,14 +59,16 @@ export async function openSpeechInputModal({ navigate, onTextReady }) {
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
root.innerHTML = '';
|
||||
host.remove();
|
||||
};
|
||||
|
||||
const setBusy = (flag) => {
|
||||
busy = !!flag;
|
||||
cancelBtn.disabled = busy;
|
||||
sendNowBtn.disabled = busy;
|
||||
okBtn.disabled = busy;
|
||||
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
||||
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
|
||||
};
|
||||
|
||||
try {
|
||||
@ -84,10 +90,16 @@ export async function openSpeechInputModal({ navigate, onTextReady }) {
|
||||
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();
|
||||
@ -97,4 +109,24 @@ export async function openSpeechInputModal({ navigate, onTextReady }) {
|
||||
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'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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: '💬' },
|
||||
@ -27,6 +28,35 @@ 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';
|
||||
@ -63,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
if (item.pageId === 'channels-list') {
|
||||
installChannelsHoldSwitcher(btn, navigate);
|
||||
} else {
|
||||
btn.addEventListener('click', () => navigate(item.pageId));
|
||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||
}
|
||||
root.append(btn);
|
||||
});
|
||||
@ -76,7 +106,7 @@ function installChannelsHoldSwitcher(button, navigate) {
|
||||
let pressed = false;
|
||||
let holdActive = false;
|
||||
let overlay = null;
|
||||
let selectedMode = 'dialogs';
|
||||
let selectedMode = 'feed';
|
||||
|
||||
const clearTimer = () => {
|
||||
if (holdTimer) {
|
||||
@ -120,7 +150,7 @@ function installChannelsHoldSwitcher(button, navigate) {
|
||||
button.addEventListener('pointerdown', (event) => {
|
||||
pressed = true;
|
||||
holdActive = false;
|
||||
selectedMode = 'dialogs';
|
||||
selectedMode = 'feed';
|
||||
clearTimer();
|
||||
holdTimer = window.setTimeout(() => {
|
||||
if (!pressed) return;
|
||||
@ -143,7 +173,7 @@ function installChannelsHoldSwitcher(button, navigate) {
|
||||
navigate(`channels-list/${mode}`);
|
||||
return;
|
||||
}
|
||||
navigate('channels-list/dialogs');
|
||||
navigate('channels-list/feed');
|
||||
});
|
||||
|
||||
button.addEventListener('pointercancel', () => {
|
||||
@ -156,3 +186,4 @@ function installChannelsHoldSwitcher(button, navigate) {
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -11,11 +11,67 @@ 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');
|
||||
@ -49,6 +105,11 @@ 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();
|
||||
@ -69,7 +130,8 @@ function messageRefKey(messageRef) {
|
||||
function buildAbsoluteRouteUrl(routePath = '') {
|
||||
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `#/${cleanRoute}`;
|
||||
url.pathname = `/${cleanRoute}`;
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@ -88,8 +150,8 @@ function parseThreadSelector(route) {
|
||||
},
|
||||
channel: {
|
||||
ownerBlockchainName: '',
|
||||
rootBlockNumber: null,
|
||||
rootBlockHash: '0',
|
||||
channelRootBlockNumber: null,
|
||||
channelRootBlockHash: '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -104,8 +166,8 @@ function parseThreadSelector(route) {
|
||||
},
|
||||
channel: {
|
||||
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
|
||||
rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
||||
rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||||
channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
||||
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -120,10 +182,12 @@ function allFeedSummaries() {
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channelSelector) {
|
||||
if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
|
||||
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
|
||||
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
|
||||
if (!channelSelector?.ownerBlockchainName || rootNumber == null) return '';
|
||||
const ownerBch = String(channelSelector.ownerBlockchainName);
|
||||
const rootNo = Number(channelSelector.rootBlockNumber);
|
||||
const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
|
||||
const rootNo = Number(rootNumber);
|
||||
const rootHash = normalizeRouteHash(rootHashRaw);
|
||||
|
||||
const found = allFeedSummaries().find((summary) => (
|
||||
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
|
||||
@ -134,37 +198,81 @@ function resolveChannelDisplayName(channelSelector) {
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
}
|
||||
|
||||
function buildBackRoute(selector) {
|
||||
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||
return [
|
||||
'channel',
|
||||
encodeRoutePart(selector.short.ownerBlockchainName),
|
||||
encodeRoutePart(selector.short.channelName),
|
||||
].join('/');
|
||||
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 '';
|
||||
}
|
||||
return 'channels-list';
|
||||
}
|
||||
|
||||
function buildThreadRouteFromTarget(target, selector) {
|
||||
if (!target) return '';
|
||||
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||
return [
|
||||
'channel',
|
||||
encodeRoutePart(selector.short.ownerBlockchainName),
|
||||
encodeRoutePart(selector.short.channelName),
|
||||
target.blockNumber,
|
||||
].join('/');
|
||||
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
||||
return makeShineMessageRoute({
|
||||
ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName),
|
||||
messageBlockchainName: target.blockchainName,
|
||||
messageBlockNumber: target.blockNumber,
|
||||
});
|
||||
}
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function buildTargetFromNode(node) {
|
||||
@ -185,12 +293,11 @@ function firstNonEmptyText(...candidates) {
|
||||
}
|
||||
|
||||
function latestVersionText(versions) {
|
||||
if (!Array.isArray(versions)) return '';
|
||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = versions[i];
|
||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||
if (value) return value;
|
||||
}
|
||||
if (!Array.isArray(versions) || !versions.length) return '';
|
||||
const version = versions[versions.length - 1];
|
||||
if (typeof version?.text === 'string') return version.text;
|
||||
if (typeof version?.message === 'string') return version.message;
|
||||
if (typeof version?.body === 'string') return version.body;
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -272,16 +379,104 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rows = Array.isArray(versions) ? versions : [];
|
||||
root.innerHTML = `
|
||||
<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';
|
||||
card.className = 'card stack thread-node-card channel-message-card';
|
||||
card.classList.add('is-counters-visible');
|
||||
|
||||
const author = node?.authorLogin || 'автор';
|
||||
const text = resolveNodeText(node) || '(пусто)';
|
||||
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
||||
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
|
||||
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const versions = Number(node?.versionsTotal || 1);
|
||||
const changes = Math.max(0, versions - 1);
|
||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
||||
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
||||
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
@ -291,18 +486,51 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
card.append(headingEl);
|
||||
}
|
||||
|
||||
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 authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
|
||||
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 = 'thread-node-body';
|
||||
body.textContent = text;
|
||||
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
||||
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
|
||||
|
||||
card.append(meta, body);
|
||||
card.append(authorTile, body);
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
const refKey = messageRefKey(target);
|
||||
@ -318,15 +546,20 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const isLiked = getMessageReactionState(target) === 'liked';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'thread-node-actions';
|
||||
actions.className = 'thread-node-actions channel-message-actions';
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'secondary-btn thread-like-btn';
|
||||
likeButton.className = 'channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.textContent = isPending ? `❤️ ${likes}...` : `${isLiked ? '❤️' : '🤍'} ${likes}`;
|
||||
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.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
@ -335,7 +568,6 @@ 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) {
|
||||
@ -350,9 +582,14 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||
replyButton.textContent = `💬 ${replies}`;
|
||||
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.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
@ -360,25 +597,50 @@ 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 = 'secondary-btn thread-share-btn';
|
||||
shareButton.textContent = '↗ Отправить';
|
||||
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.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
await handlers.onShare(target);
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, changedButton, shareButton);
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
if (isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
editButton.className = 'channel-action-item';
|
||||
editButton.setAttribute('aria-label', 'Редактировать');
|
||||
editButton.title = 'Редактировать';
|
||||
editButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
||||
`;
|
||||
editButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
initialText: String(text || '').trim() === 'удалено' ? '' : text,
|
||||
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
|
||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
||||
});
|
||||
});
|
||||
actions.append(editButton);
|
||||
}
|
||||
card.append(actions);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -441,7 +703,6 @@ 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 || ''}`;
|
||||
|
||||
@ -450,9 +711,16 @@ export function render({ navigate, route }) {
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
appScreen?.classList.add('channels-scroll-clean');
|
||||
|
||||
const channelIndicator = document.createElement('div');
|
||||
channelIndicator.className = 'card channels-user-chip';
|
||||
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
||||
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 statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
@ -465,7 +733,7 @@ export function render({ navigate, route }) {
|
||||
const next = render({ navigate, route });
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, { routeHash: window.location.hash });
|
||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
||||
}
|
||||
};
|
||||
|
||||
@ -483,7 +751,7 @@ export function render({ navigate, route }) {
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||
state.authReturnHash = window.location.pathname || '/channels-list';
|
||||
navigate('login-view');
|
||||
throw new Error('Для этого действия нужно войти');
|
||||
}
|
||||
@ -545,21 +813,38 @@ 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(
|
||||
renderHeader({
|
||||
title: 'Тред',
|
||||
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||
}),
|
||||
);
|
||||
screen.append(channelIndicator, statusBox);
|
||||
screen.append(header, statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
@ -581,10 +866,46 @@ export function render({ navigate, route }) {
|
||||
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
||||
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []),
|
||||
];
|
||||
const channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim() === selector.short.ownerBlockchainName
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
|
||||
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
|
||||
));
|
||||
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);
|
||||
@ -593,25 +914,14 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
selector.channel = {
|
||||
ownerBlockchainName: ownerBch,
|
||||
rootBlockNumber: rootNo,
|
||||
rootBlockHash: rootHash,
|
||||
channelRootBlockNumber: rootNo,
|
||||
channelRootBlockHash: 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: resolvedHash,
|
||||
blockHash: normalizeMessageHash(resolvedMessage?.blockHash),
|
||||
};
|
||||
}
|
||||
|
||||
@ -622,30 +932,68 @@ 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) {
|
||||
const ancestorsWrap = document.createElement('div');
|
||||
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) {
|
||||
const focusWrap = document.createElement('div');
|
||||
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');
|
||||
@ -664,8 +1012,23 @@ 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');
|
||||
|
||||
@ -17,12 +17,72 @@ 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 {
|
||||
@ -55,6 +115,11 @@ 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();
|
||||
@ -98,7 +163,8 @@ function blockRefToMessageKey(blockRef, fallbackBch = '') {
|
||||
function buildAbsoluteRouteUrl(routePath = '') {
|
||||
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `#/${cleanRoute}`;
|
||||
url.pathname = `/${cleanRoute}`;
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@ -134,25 +200,12 @@ function buildSelectorFromRoute(route, channelId) {
|
||||
|
||||
function buildThreadRoute(messageRef, selector) {
|
||||
if (!messageRef || !selector) return '';
|
||||
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('/');
|
||||
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
|
||||
return makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: messageRef.blockchainName,
|
||||
messageBlockNumber: messageRef.blockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
@ -165,12 +218,11 @@ function firstNonEmptyText(...candidates) {
|
||||
}
|
||||
|
||||
function latestVersionText(versions) {
|
||||
if (!Array.isArray(versions)) return '';
|
||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = versions[i];
|
||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||
if (value) return value;
|
||||
}
|
||||
if (!Array.isArray(versions) || !versions.length) return '';
|
||||
const version = versions[versions.length - 1];
|
||||
if (typeof version?.text === 'string') return version.text;
|
||||
if (typeof version?.message === 'string') return version.message;
|
||||
if (typeof version?.body === 'string') return version.body;
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -377,6 +429,92 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
const rows = Array.isArray(versions) ? versions : [];
|
||||
root.innerHTML = `
|
||||
<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);
|
||||
@ -399,20 +537,29 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
return {
|
||||
localNumber,
|
||||
authorLogin: message?.authorLogin || 'автор',
|
||||
body: resolvedText || '(пусто)',
|
||||
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'),
|
||||
versionsTotal: Number(message?.versionsTotal || 1),
|
||||
versions: Array.isArray(message?.versions) ? message.versions : [],
|
||||
likesCount: Number(message?.likesCount || 0),
|
||||
repliesCount: Number(message?.repliesCount || 0),
|
||||
timestampMs: resolveMessageTimestampMs(message),
|
||||
messageRef,
|
||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
||||
if (!isAuthorized) {
|
||||
cachedFeed = {};
|
||||
return cachedFeed;
|
||||
}
|
||||
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
|
||||
return cachedFeed;
|
||||
};
|
||||
const getAllRows = async () => {
|
||||
@ -428,8 +575,11 @@ 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();
|
||||
let channel = allRows.find((item) => (
|
||||
channel = allRows.find((item) => (
|
||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
@ -439,7 +589,7 @@ async function loadFromApi(route, channelId) {
|
||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||
));
|
||||
}
|
||||
if (!channel) {
|
||||
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
|
||||
try {
|
||||
const ownerUser = await authService.getUser(routeOwnerRaw);
|
||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||
@ -453,6 +603,22 @@ 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('Канал не найден.');
|
||||
}
|
||||
@ -468,12 +634,12 @@ async function loadFromApi(route, channelId) {
|
||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||
}
|
||||
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
let reverseChannelMissingWarning = '';
|
||||
let mergedMessages = [...messages];
|
||||
|
||||
const currentLogin = String(state.session.login || '').trim();
|
||||
const currentLogin = currentSessionLogin;
|
||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||
const channelName = String(payload.channel?.channelName || '').trim();
|
||||
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
|
||||
@ -499,7 +665,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', state.session.login);
|
||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
|
||||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||||
mergedMessages = mergedMessages.concat(reverseMessages);
|
||||
} else {
|
||||
@ -517,9 +683,9 @@ async function loadFromApi(route, channelId) {
|
||||
return aNum - bNum;
|
||||
})
|
||||
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
||||
const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase();
|
||||
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase();
|
||||
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
|
||||
const isSubscribed = followedRows.some((row) => (
|
||||
const isSubscribed = isAuthorized && 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)
|
||||
@ -581,17 +747,31 @@ function renderDemoFallback(screen, navigate, error) {
|
||||
screen.append(back);
|
||||
}
|
||||
|
||||
function applyPendingScroll(screen, routeKey) {
|
||||
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) {
|
||||
const target = pendingScrollByRoute.get(routeKey);
|
||||
if (!target) return;
|
||||
if (!target && !forceBottom) return;
|
||||
|
||||
const doScroll = () => {
|
||||
if (target === '__LAST__') {
|
||||
const cards = screen.querySelectorAll('[data-message-key]');
|
||||
const last = cards[cards.length - 1];
|
||||
if (last) {
|
||||
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (!target && forceBottom) {
|
||||
scrollChannelToBottom(screen, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === '__LAST__') {
|
||||
scrollChannelToBottom(screen, true);
|
||||
pendingScrollByRoute.delete(routeKey);
|
||||
return;
|
||||
}
|
||||
@ -612,16 +792,18 @@ 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 topRow = document.createElement('div');
|
||||
topRow.className = 'channel-message-top';
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'channel-message-avatar';
|
||||
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
|
||||
const avatar = createMessageAvatar(post.authorLogin);
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
@ -641,14 +823,37 @@ 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);
|
||||
topRow.append(avatar, authorBlock);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
authorTile.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const cleanLogin = String(post.authorLogin || '').trim();
|
||||
if (!cleanLogin) return;
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
const body = document.createElement('p');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = post.body;
|
||||
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
||||
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
|
||||
|
||||
card.append(topRow, body);
|
||||
card.append(authorTile, body);
|
||||
|
||||
const refKey = messageRefKey(post.messageRef);
|
||||
if (refKey) {
|
||||
@ -676,6 +881,7 @@ function renderPostCard(post, {
|
||||
`;
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
@ -698,6 +904,7 @@ function renderPostCard(post, {
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
`;
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate,
|
||||
@ -706,19 +913,6 @@ function renderPostCard(post, {
|
||||
});
|
||||
actions.append(likeButton, replyButton);
|
||||
|
||||
const openThreadButton = document.createElement('button');
|
||||
openThreadButton.type = 'button';
|
||||
openThreadButton.className = 'channel-action-item channel-action-thread';
|
||||
openThreadButton.innerHTML = `
|
||||
<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';
|
||||
@ -733,49 +927,46 @@ function renderPostCard(post, {
|
||||
await onShare(route);
|
||||
});
|
||||
|
||||
actions.append(openThreadButton, shareButton);
|
||||
actions.append(shareButton);
|
||||
if (post.isOwnMessage) {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.type = 'button';
|
||||
editButton.className = 'channel-action-item';
|
||||
editButton.setAttribute('aria-label', 'Редактировать');
|
||||
editButton.title = 'Редактировать';
|
||||
editButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
||||
`;
|
||||
editButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openEditMessageModal({
|
||||
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
|
||||
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
|
||||
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
||||
});
|
||||
});
|
||||
actions.append(editButton);
|
||||
}
|
||||
card.append(actions);
|
||||
card.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;
|
||||
head.append(reverseWarning);
|
||||
screen.append(reverseWarning);
|
||||
}
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = channelData.isOwnChannel
|
||||
? 'primary-btn channel-main-action'
|
||||
: 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
|
||||
actionButton.className = 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = 'Подписаться на канал';
|
||||
|
||||
const feed = document.createElement('div');
|
||||
feed.className = 'stack channel-feed';
|
||||
@ -789,6 +980,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
onReply: handlers.onReply,
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
});
|
||||
const key = messageRefKey(post.messageRef);
|
||||
if (key) {
|
||||
@ -804,16 +996,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
if (!channelData.isSubscribed) {
|
||||
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
||||
}
|
||||
|
||||
@ -822,13 +1005,15 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
backButton.textContent = 'Назад к каналам';
|
||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
||||
screen.append(head, actionButton, feed, backButton);
|
||||
if (channelData.isOwnChannel) {
|
||||
screen.append(feed);
|
||||
} else if (!channelData.isSubscribed) {
|
||||
screen.append(actionButton, feed, backButton);
|
||||
} else {
|
||||
screen.append(head, feed, backButton);
|
||||
screen.append(feed, backButton);
|
||||
}
|
||||
|
||||
applyPendingScroll(screen, routeKey);
|
||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
|
||||
return () => {
|
||||
// noop
|
||||
};
|
||||
@ -866,6 +1051,17 @@ 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;
|
||||
@ -878,7 +1074,7 @@ export function render({ navigate, route }) {
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||
state.authReturnHash = window.location.pathname || '/channels-list';
|
||||
navigate('login-view');
|
||||
throw new Error('Для этого действия нужно войти');
|
||||
}
|
||||
@ -963,12 +1159,25 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: '',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
}),
|
||||
);
|
||||
const onEditPost = async (messageRef, text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
}
|
||||
await authService.addBlockEditMessage({
|
||||
login,
|
||||
storagePwd,
|
||||
message: messageRef,
|
||||
text,
|
||||
isChannelPost: true,
|
||||
channel: activeSelector,
|
||||
});
|
||||
softHaptic(12);
|
||||
showToast('Сообщение обновлено');
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(header);
|
||||
screen.append(statusBox);
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
@ -979,6 +1188,41 @@ 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) => {
|
||||
@ -1006,6 +1250,14 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
},
|
||||
onShare: onShare,
|
||||
onEdit: async (messageRef, text) => {
|
||||
try {
|
||||
await onEditPost(messageRef, text);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
|
||||
}
|
||||
},
|
||||
onSubscribeChannel: async (event) => {
|
||||
animatePress(event?.currentTarget);
|
||||
try {
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
softHaptic,
|
||||
writeChannelNotificationsState,
|
||||
} from '../services/channels-ux.js';
|
||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
@ -17,7 +18,7 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const TAB_ORDER = ['dialogs', 'feed', 'my'];
|
||||
const TAB_ORDER = ['feed', 'my'];
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
@ -43,12 +44,14 @@ function normalizeLoginInput(value) {
|
||||
}
|
||||
|
||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
||||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||
if (ownerBch && channelName) {
|
||||
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`;
|
||||
}
|
||||
return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`;
|
||||
return makeShineChannelRoute({
|
||||
ownerLogin,
|
||||
ownerBlockchainName: ownerBch,
|
||||
channelName: channelName || fallbackId,
|
||||
});
|
||||
}
|
||||
|
||||
function avatarLetterFromName(name = '') {
|
||||
@ -408,7 +411,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="Например: aid" autocomplete="off" />
|
||||
<input id="channels-find-input" class="input" placeholder="Например: aidar" 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>
|
||||
@ -463,8 +466,12 @@ function openChannelFinderModal({ navigate }) {
|
||||
openBtn.textContent = 'Просмотреть';
|
||||
openBtn.addEventListener('click', () => {
|
||||
close();
|
||||
const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim();
|
||||
navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`);
|
||||
const route = makeShineChannelRoute({
|
||||
ownerLogin: String(item.ownerLogin || '').trim(),
|
||||
ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
|
||||
channelName: String(item.channelName || '').trim(),
|
||||
});
|
||||
if (route) navigate(route);
|
||||
});
|
||||
|
||||
row.style.display = 'flex';
|
||||
@ -582,11 +589,13 @@ function openChannelFinderModal({ navigate }) {
|
||||
function mapMockGroups() {
|
||||
const mapRow = (channel) => ({
|
||||
...channel,
|
||||
route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`,
|
||||
tabCategory: channel.kind === 'own'
|
||||
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'
|
||||
? 'my'
|
||||
: channel.kind === 'own-personal'
|
||||
? 'dialogs'
|
||||
: 'feed',
|
||||
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
||||
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
||||
@ -625,9 +634,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||
const isOwn = bucketKey === 'own';
|
||||
const tabCategory = isOwn
|
||||
? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
|
||||
: 'feed';
|
||||
const tabCategory = isOwn ? 'my' : 'feed';
|
||||
|
||||
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
|
||||
|
||||
@ -687,12 +694,13 @@ 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 {
|
||||
@ -769,7 +777,14 @@ function renderDemoFallback(container, navigate, error, onRetry) {
|
||||
<span class="channel-row-time">—</span>
|
||||
</div>
|
||||
`;
|
||||
row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
|
||||
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);
|
||||
});
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
@ -953,7 +968,7 @@ function renderChannelMain(channel, activeTab) {
|
||||
title.className = 'channel-row-title';
|
||||
title.textContent = activeTab === 'my' ? channel.channelName : channel.title;
|
||||
|
||||
if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) {
|
||||
if (activeTab === 'my' && channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'channel-row-description';
|
||||
desc.textContent = channel.channelDescription;
|
||||
@ -1002,9 +1017,21 @@ 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';
|
||||
@ -1030,21 +1057,19 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
});
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
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);
|
||||
controls.append(menuButton);
|
||||
}
|
||||
controls.append(time, count);
|
||||
|
||||
row.append(avatar, main, controls);
|
||||
row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
|
||||
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);
|
||||
});
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
@ -1062,17 +1087,10 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab === 'dialogs') {
|
||||
button.textContent = 'Новый персональный публичный чат';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => navigate('add-personal-public-chat-view');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab === 'my') {
|
||||
button.textContent = 'Создать канал';
|
||||
button.textContent = 'Найти канал';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => navigate('add-channel-view');
|
||||
button.onclick = () => openChannelFinderModal({ navigate });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1085,8 +1103,20 @@ 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);
|
||||
|
||||
@ -1122,6 +1152,7 @@ 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()
|
||||
@ -1132,18 +1163,24 @@ 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: 'Каналы',
|
||||
dialogs: 'Чаты',
|
||||
my: 'Мои',
|
||||
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' : ''}`;
|
||||
@ -1156,6 +1193,15 @@ 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';
|
||||
|
||||
@ -1163,9 +1209,9 @@ export function render({ navigate, route }) {
|
||||
|
||||
const rerenderList = () => {
|
||||
try {
|
||||
const expectedHash = `#/channels-list/${listState.activeTab}`;
|
||||
if (window.location.hash !== expectedHash) {
|
||||
window.history.replaceState({}, '', expectedHash);
|
||||
const expectedPath = `/channels-list/${listState.activeTab}`;
|
||||
if (window.location.pathname !== expectedPath) {
|
||||
window.history.replaceState({}, '', expectedPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore history errors
|
||||
@ -1183,6 +1229,9 @@ export function render({ navigate, route }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
const showCreate = !isGuest && listState.activeTab === 'my';
|
||||
topActionBtn.style.display = showCreate ? '' : 'none';
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
listState,
|
||||
@ -1216,7 +1265,7 @@ export function render({ navigate, route }) {
|
||||
rerenderList();
|
||||
}, { passive: true });
|
||||
|
||||
screen.append(tabsEl, contentEl, bottomCta);
|
||||
screen.append(topBarEl, contentEl, bottomCta);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
|
||||
@ -10,14 +10,108 @@ 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('|');
|
||||
@ -78,11 +172,14 @@ 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) {
|
||||
function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
@ -122,6 +219,9 @@ function renderLog(list, chatId) {
|
||||
}
|
||||
|
||||
bubble.append(textNode, metaNode);
|
||||
bubble.addEventListener('click', () => {
|
||||
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
||||
});
|
||||
list.append(bubble);
|
||||
});
|
||||
scrollToLatestMessage(list);
|
||||
@ -142,20 +242,42 @@ 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);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
}],
|
||||
@ -168,18 +290,26 @@ 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 {
|
||||
await authService.addCloseFriend(chatId);
|
||||
state.contacts = [...new Set([...(state.contacts || []), chatId])];
|
||||
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 || []);
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'contacts',
|
||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
||||
});
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Добавлено';
|
||||
card.remove();
|
||||
} catch (e) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
@ -202,51 +332,29 @@ export function render({ navigate, route }) {
|
||||
const form = document.createElement('form');
|
||||
form.className = 'chat-input dm-chat-input';
|
||||
form.innerHTML = `
|
||||
<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>
|
||||
<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>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
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();
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const text = String(rawText || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
||||
input.value = '';
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await authService.sendDirectMessage({
|
||||
@ -259,7 +367,18 @@ export function render({ navigate, route }) {
|
||||
messageKey: result?.outgoingKey || '',
|
||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'outgoing-dm',
|
||||
@ -282,14 +401,92 @@ export function render({ navigate, route }) {
|
||||
error: e?.message || 'unknown',
|
||||
},
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const input = form.elements.message;
|
||||
autoResizeComposer(input);
|
||||
input?.addEventListener('input', () => autoResizeComposer(input));
|
||||
input?.addEventListener('focus', () => {
|
||||
scrollToLatestMessage(log);
|
||||
});
|
||||
input?.addEventListener('keydown', async (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
const start = Number(input.selectionStart ?? input.value.length);
|
||||
const end = Number(input.selectionEnd ?? input.value.length);
|
||||
const value = String(input.value || '');
|
||||
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
|
||||
const nextPos = start + 1;
|
||||
try {
|
||||
input.setSelectionRange(nextPos, nextPos);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
autoResizeComposer(input);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const text = String(input.value || '').trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
autoResizeComposer(input);
|
||||
await sendTextMessage(text);
|
||||
});
|
||||
|
||||
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
||||
await openSpeechInputModal({
|
||||
navigate,
|
||||
onTextReady: (text) => {
|
||||
const prev = String(input.value || '').trim();
|
||||
input.value = prev ? `${prev} ${text}` : text;
|
||||
autoResizeComposer(input);
|
||||
},
|
||||
onSendText: async (text) => sendTextMessage(text),
|
||||
onSendQueued: () => {
|
||||
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
autoResizeComposer(input);
|
||||
await sendTextMessage(text);
|
||||
});
|
||||
|
||||
wrap.append(log, form);
|
||||
screen.append(wrap);
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@ -1,7 +1,62 @@
|
||||
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');
|
||||
@ -44,16 +99,17 @@ 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(`user-profile-view/${encodeURIComponent(login)}/contact-search-view`);
|
||||
navigate(makeProfileRoute(login));
|
||||
});
|
||||
resultsList.append(row);
|
||||
});
|
||||
|
||||
@ -184,7 +184,8 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
|
||||
|
||||
async function forceUiUpdateNow() {
|
||||
try {
|
||||
window.location.hash = '#/settings-view';
|
||||
window.history.replaceState({}, '', '/settings-view');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
} catch {}
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
window.location.reload();
|
||||
|
||||
@ -8,8 +8,72 @@ 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');
|
||||
@ -29,20 +93,22 @@ 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="avatar">${item.initials}</div>
|
||||
<div>
|
||||
<div class="row" style="justify-content:flex-start; gap:8px;">
|
||||
<strong>${item.name}</strong>
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-title-wrap">
|
||||
<strong class="dm-row-title">${item.name}</strong>
|
||||
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
|
||||
</div>
|
||||
<p class="meta-muted" style="margin-top:4px;">${item.lastMessage}</p>
|
||||
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p>
|
||||
</div>
|
||||
<div style="display:grid; justify-items:end; gap:6px;">
|
||||
<span class="meta-muted">${item.time}</span>
|
||||
<div class="dm-row-meta-col">
|
||||
${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;
|
||||
}
|
||||
@ -59,12 +125,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: preview?.time || '—',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
unread,
|
||||
notInContacts: false,
|
||||
};
|
||||
@ -81,12 +147,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: 'сейчас',
|
||||
time: formatChatRowTime(lastTimeMs),
|
||||
unread,
|
||||
notInContacts: true,
|
||||
};
|
||||
|
||||
@ -2,6 +2,8 @@ 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: 'Связи' };
|
||||
|
||||
@ -14,6 +16,14 @@ 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();
|
||||
}
|
||||
@ -507,6 +517,7 @@ 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 = [];
|
||||
@ -533,7 +544,7 @@ export function render({ navigate, route }) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) return '';
|
||||
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
||||
return `user-profile-view/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
|
||||
return makeProfileRoute(cleanLogin);
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
@ -551,6 +562,15 @@ 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;
|
||||
@ -568,13 +588,8 @@ 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">Введите логин и нажмите «Искать».</div>
|
||||
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</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>
|
||||
`;
|
||||
@ -585,9 +600,6 @@ 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;
|
||||
@ -607,10 +619,6 @@ 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) => {
|
||||
@ -661,6 +669,8 @@ 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();
|
||||
@ -672,23 +682,11 @@ export function render({ navigate, route }) {
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const button = target.closest('[data-candidate]');
|
||||
if (!(button instanceof HTMLElement)) return;
|
||||
applySelection(String(button.dataset.candidate || ''));
|
||||
});
|
||||
profileBtn?.addEventListener('click', () => {
|
||||
if (!selectedLogin) return;
|
||||
const routeTo = profileInfoRoute(selectedLogin);
|
||||
if (!routeTo) return;
|
||||
const nextLogin = String(button.dataset.candidate || '');
|
||||
applySelection(nextLogin);
|
||||
if (!nextLogin) return;
|
||||
close();
|
||||
navigate(routeTo);
|
||||
});
|
||||
graphBtn?.addEventListener('click', () => {
|
||||
if (!selectedLogin) return;
|
||||
close();
|
||||
void load(selectedLogin, { pushHistory: true });
|
||||
});
|
||||
okBtn?.addEventListener('click', () => {
|
||||
if (!selectedLogin) return;
|
||||
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
|
||||
void load(nextLogin, { pushHistory: true });
|
||||
});
|
||||
|
||||
window.setTimeout(() => inputEl.focus(), 0);
|
||||
@ -765,6 +763,7 @@ 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);
|
||||
@ -839,13 +838,22 @@ export function render({ navigate, route }) {
|
||||
appScreenEl?.classList.remove('network-scroll-lock');
|
||||
};
|
||||
|
||||
if (keepHistory && centerLogin) {
|
||||
if (routeLogin) {
|
||||
centerLogin = routeLogin;
|
||||
centerHistory = [];
|
||||
persistHistory();
|
||||
void load(centerLogin, { pushHistory: false });
|
||||
} else 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);
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ 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: 'Профиль' };
|
||||
|
||||
@ -29,6 +30,40 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -38,15 +73,23 @@ 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="wallet">Кошелёк</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="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';
|
||||
|
||||
@ -61,13 +104,6 @@ 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 = `
|
||||
@ -78,8 +114,6 @@ 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"]');
|
||||
@ -135,6 +169,21 @@ 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) => {
|
||||
@ -155,10 +204,6 @@ 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 : [];
|
||||
@ -168,39 +213,12 @@ export function render({ navigate }) {
|
||||
updateAvatarUi();
|
||||
updateTogglesUi();
|
||||
renderFields(currentFields);
|
||||
if (statusLineEl instanceof HTMLElement) {
|
||||
statusLineEl.className = 'status-line is-available';
|
||||
statusLineEl.textContent = 'Профиль обновлён.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (statusLineEl instanceof HTMLElement) {
|
||||
statusLineEl.className = 'status-line is-unavailable';
|
||||
statusLineEl.textContent = `Ошибка загрузки профиля: ${error?.message || 'unknown'}`;
|
||||
}
|
||||
// ignore status row in profile-view
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
card.append(topRow, badgesRow, listWrap);
|
||||
screen.append(card);
|
||||
|
||||
updateAvatarUi();
|
||||
|
||||
@ -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(2));
|
||||
if (nextHash.startsWith('/')) {
|
||||
navigate(nextHash.slice(1));
|
||||
} else {
|
||||
navigate('profile-view');
|
||||
}
|
||||
|
||||
@ -32,13 +32,19 @@ 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, settingsButton);
|
||||
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
||||
screen.append(logo, title, actions);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@ -6,8 +6,11 @@ import {
|
||||
loadUserProfileCard,
|
||||
} from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
|
||||
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
|
||||
import { navigateBack } from '../router.js';
|
||||
|
||||
export const pageMeta = { id: 'user', title: 'Чужой профиль' };
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
@ -18,6 +21,40 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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 'Мужской';
|
||||
@ -26,28 +63,28 @@ function genderText(value) {
|
||||
}
|
||||
|
||||
function relationButtonLabel(kind, flags) {
|
||||
if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
|
||||
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
|
||||
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
|
||||
return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
|
||||
return flags.outFollow ? 'Отписаться' : 'Подписаться';
|
||||
}
|
||||
|
||||
function relationNextState(kind, flags) {
|
||||
if (kind === 'follow') return !flags.outFollow;
|
||||
if (kind === 'contact') return !flags.outContact;
|
||||
if (kind === 'friend') return !flags.outFriend;
|
||||
return !flags.outContact;
|
||||
return !flags.outFollow;
|
||||
}
|
||||
|
||||
function relationConfirmLabel(kind) {
|
||||
if (kind === 'follow') return 'подписку';
|
||||
if (kind === 'contact') return 'контакт';
|
||||
if (kind === 'friend') return 'статус близкого друга';
|
||||
return 'контакт';
|
||||
return 'подписку';
|
||||
}
|
||||
|
||||
function relationStateText(kind, flags) {
|
||||
if (kind === 'follow') {
|
||||
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
|
||||
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
|
||||
if (flags.inFollow) return 'Этот профиль подписан на вас.';
|
||||
if (kind === 'contact') {
|
||||
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
|
||||
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
|
||||
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
|
||||
return '';
|
||||
}
|
||||
if (kind === 'friend') {
|
||||
@ -56,12 +93,52 @@ function relationStateText(kind, flags) {
|
||||
if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
|
||||
return '';
|
||||
}
|
||||
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
|
||||
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
|
||||
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
|
||||
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
|
||||
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
|
||||
if (flags.inFollow) 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,
|
||||
@ -99,31 +176,88 @@ function renderIdentity(card) {
|
||||
function renderReadOnlyBadges(card) {
|
||||
return `
|
||||
<div class="row wrap-row">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRelations(flags) {
|
||||
const rows = [
|
||||
{ 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) },
|
||||
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
|
||||
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
|
||||
];
|
||||
const opinionItems = opinionItemsFromFlags(flags);
|
||||
const hasOpinion = opinionItems.length > 0;
|
||||
|
||||
return `
|
||||
<div class="card stack user-relations-list">
|
||||
<div class="card stack user-relations-list" data-profile-relations="true">
|
||||
${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 },
|
||||
@ -147,7 +281,6 @@ 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');
|
||||
@ -163,12 +296,14 @@ export function render({ navigate, route }) {
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Профиль пользователя',
|
||||
leftAction: { label: '←', onClick: () => navigate(fromPage) },
|
||||
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
|
||||
leftAction: { label: '←', onClick: () => navigateBack() },
|
||||
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
|
||||
}),
|
||||
status,
|
||||
body,
|
||||
);
|
||||
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
|
||||
linksHeaderBtn?.classList.add('profile-links-header-btn');
|
||||
|
||||
let currentCard = null;
|
||||
let currentFlags = null;
|
||||
@ -178,14 +313,17 @@ 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"]');
|
||||
if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
|
||||
const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]');
|
||||
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
|
||||
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
|
||||
followBtn.textContent = relationButtonLabel('follow', currentFlags);
|
||||
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
|
||||
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
|
||||
followBtn.disabled = Boolean(isSelf);
|
||||
friendBtn.disabled = Boolean(isSelf);
|
||||
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
|
||||
followBtn.textContent = relationButtonLabel('follow', currentFlags);
|
||||
contactBtn.disabled = Boolean(isSelf);
|
||||
friendBtn.disabled = Boolean(isSelf);
|
||||
followBtn.disabled = Boolean(isSelf);
|
||||
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
|
||||
opinionBtn.disabled = Boolean(isSelf);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@ -220,6 +358,10 @@ 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) {
|
||||
@ -242,6 +384,14 @@ 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` +
|
||||
@ -270,13 +420,87 @@ 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;
|
||||
onRelationAction(kind);
|
||||
void onRelationAction(kind);
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
createRandomSolanaWallet,
|
||||
createSolanaWalletFromPrivateBase58,
|
||||
formatSol,
|
||||
getBalanceSol,
|
||||
getTopupSiteUrl,
|
||||
@ -17,6 +19,7 @@ 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');
|
||||
@ -165,6 +168,203 @@ 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('Кошелёк не инициализирован.');
|
||||
@ -265,7 +465,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
content.append(backBtn, card, actions);
|
||||
content.append(backBtn, card, actions, generatedCard);
|
||||
setStatus('Инициализация wallet.key...');
|
||||
|
||||
try {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
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 = [
|
||||
@ -14,10 +16,13 @@ export const PRE_AUTH_PAGES = [
|
||||
];
|
||||
|
||||
export function getRoute() {
|
||||
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||
if (!raw) {
|
||||
return { pageId: '', params: {} };
|
||||
}
|
||||
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 segments = raw.split('/').filter(Boolean);
|
||||
const pageId = segments[0] || '';
|
||||
@ -31,6 +36,73 @@ 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) : '' } };
|
||||
}
|
||||
@ -50,51 +122,16 @@ 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: segments[3] || '',
|
||||
channelOwnerBlockchainName: decodePart(segments[4]),
|
||||
channelRootBlockNumber: segments[5] || '',
|
||||
channelRootBlockHash: segments[6] || '',
|
||||
messageBlockHash: '',
|
||||
channelOwnerBlockchainName: decodePart(segments[3]),
|
||||
channelRootBlockNumber: segments[4] || '',
|
||||
channelRootBlockHash: segments[5] || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -103,39 +140,29 @@ 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) {
|
||||
window.location.hash = `#/${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();
|
||||
}
|
||||
|
||||
export function resolveToolbarActive(pageId) {
|
||||
@ -156,11 +183,9 @@ 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-profile-view') return 'messages-list';
|
||||
if (pageId === 'user') return 'messages-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
45
shine-UI/js/services/auth-required-modal.js
Normal file
45
shine-UI/js/services/auth-required-modal.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -37,7 +37,9 @@ const MSG_TYPE_CONNECTION = 3;
|
||||
|
||||
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||
@ -59,6 +61,9 @@ 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) {
|
||||
@ -366,6 +371,56 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextEditPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockNumber,
|
||||
toBlockHashHex,
|
||||
text,
|
||||
}) {
|
||||
const message = String(text || '').trim();
|
||||
const targetBlockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number for edit post');
|
||||
}
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(lineCode),
|
||||
int32Bytes(prevLineNumber),
|
||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||
int32Bytes(thisLineNumber),
|
||||
int32Bytes(targetBlockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
||||
const message = String(text || '').trim();
|
||||
const targetBlockNumber = Number(toBlockNumber);
|
||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
||||
throw new Error('Invalid target block number for edit reply');
|
||||
}
|
||||
const textBytes = utf8Bytes(message);
|
||||
if (textBytes.length > 65535) {
|
||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
||||
}
|
||||
|
||||
return concatBytes(
|
||||
int32Bytes(targetBlockNumber),
|
||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||
int16Bytes(textBytes.length),
|
||||
textBytes,
|
||||
);
|
||||
}
|
||||
|
||||
function makeConnectionBodyBytes({
|
||||
lineCode = 0,
|
||||
prevLineNumber = -1,
|
||||
@ -955,6 +1010,98 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockEditMessage({
|
||||
login,
|
||||
message,
|
||||
text,
|
||||
storagePwd,
|
||||
isChannelPost = false,
|
||||
channel = null,
|
||||
}) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanText = String(text || '').trim();
|
||||
const target = normalizeMessageRefTarget(message, 'edit');
|
||||
const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`;
|
||||
|
||||
return this.runWriteLocked(lockKey, async () => {
|
||||
if (isChannelPost) {
|
||||
const selector = channel || {};
|
||||
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
|
||||
const lineCode = Number(selector?.channelRootBlockNumber);
|
||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
||||
throw new Error('Invalid channel selector for edit');
|
||||
}
|
||||
|
||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
||||
if (rootHashHex === ZERO64) {
|
||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
|
||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
||||
if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
||||
}
|
||||
|
||||
let prevLineNumber = lineCode;
|
||||
let prevLineHashHex = rootHashHex;
|
||||
let thisLineNumber = 1;
|
||||
try {
|
||||
const latestPayload = await this.getChannelMessages({
|
||||
ownerBlockchainName,
|
||||
channelRootBlockNumber: lineCode,
|
||||
channelRootBlockHash: rootHashHex,
|
||||
}, 1, 'desc', cleanLogin);
|
||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||
const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : [];
|
||||
const latestVersion = latestVersions[latestVersions.length - 1] || null;
|
||||
const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber);
|
||||
const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, '');
|
||||
const latestVersionsTotal = Number(latestMessage?.versionsTotal);
|
||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||
prevLineNumber = latestBlockNumber;
|
||||
prevLineHashHex = latestBlockHash;
|
||||
thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0
|
||||
? Math.max(1, latestVersionsTotal)
|
||||
: 1;
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextEditPostBodyBytes({
|
||||
lineCode,
|
||||
prevLineNumber,
|
||||
prevLineHashHex,
|
||||
thisLineNumber,
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const bodyBytes = makeTextEditReplyBodyBytes({
|
||||
toBlockNumber: target.blockNumber,
|
||||
toBlockHashHex: target.blockHash,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_REPLY,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
||||
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||
@ -1234,10 +1381,15 @@ 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;
|
||||
thisLineNumber = latestBlockNumber + 1;
|
||||
// В line-цепочке thisLineNumber — это номер шага линии, а не глобальный blockNumber.
|
||||
// Для следующего POST берем шаг после последней известной версии сообщения.
|
||||
thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0
|
||||
? Math.max(0, latestVersionsTotal)
|
||||
: 1;
|
||||
}
|
||||
} catch {
|
||||
// fallback to root anchor
|
||||
|
||||
65
shine-UI/js/services/shine-routes.js
Normal file
65
shine-UI/js/services/shine-routes.js
Normal file
@ -0,0 +1,65 @@
|
||||
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)));
|
||||
}
|
||||
@ -6,6 +6,7 @@ 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();
|
||||
@ -37,6 +38,34 @@ 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();
|
||||
|
||||
@ -47,7 +47,23 @@ function toToggleMap(snapshot) {
|
||||
}
|
||||
|
||||
function readArray(payload, key) {
|
||||
const value = 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;
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) ? uniqueLogins(value) : null;
|
||||
}
|
||||
|
||||
@ -75,6 +91,12 @@ async function buildRelationsModel(login) {
|
||||
inChildren: [],
|
||||
outSiblings: [],
|
||||
inSiblings: [],
|
||||
outKnownPersons: [],
|
||||
inKnownPersons: [],
|
||||
outShineConfirmed: [],
|
||||
inShineConfirmed: [],
|
||||
outShineSeen: [],
|
||||
inShineSeen: [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -117,6 +139,12 @@ 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') || [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -151,6 +179,12 @@ export async function loadCurrentRelations() {
|
||||
inChildren: [],
|
||||
outSiblings: [],
|
||||
inSiblings: [],
|
||||
outKnownPersons: [],
|
||||
inKnownPersons: [],
|
||||
outShineConfirmed: [],
|
||||
inShineConfirmed: [],
|
||||
outShineSeen: [],
|
||||
inShineSeen: [],
|
||||
};
|
||||
}
|
||||
return buildRelationsModel(login);
|
||||
@ -170,6 +204,12 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -644,6 +644,8 @@
|
||||
.avatar-image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.avatar-image > .avatar-fallback,
|
||||
@ -1204,6 +1206,10 @@ textarea.input {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-danger-action {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
@ -1635,6 +1641,49 @@ 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);
|
||||
@ -1781,6 +1830,14 @@ 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,
|
||||
@ -2171,21 +2228,47 @@ 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: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
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;
|
||||
@ -2193,7 +2276,7 @@ textarea.input {
|
||||
}
|
||||
|
||||
.channel-message-title {
|
||||
font-size: 20px;
|
||||
font-size: 15px;
|
||||
color: #f5f8ff;
|
||||
}
|
||||
|
||||
@ -2209,8 +2292,16 @@ 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: 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
@ -2290,6 +2381,25 @@ textarea.input {
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.message-edited-marker {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 220, 100, 0.85);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.message-edited-marker:hover,
|
||||
.message-edited-marker:focus-visible {
|
||||
color: rgba(255, 232, 150, 0.95);
|
||||
}
|
||||
|
||||
.channel-action-counter {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
@ -2320,6 +2430,31 @@ 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;
|
||||
@ -2356,10 +2491,15 @@ textarea.input {
|
||||
|
||||
.thread-node-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, 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);
|
||||
@ -2368,11 +2508,22 @@ textarea.input {
|
||||
.thread-block {
|
||||
gap: 8px;
|
||||
border-radius: 15px;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
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;
|
||||
}
|
||||
@ -2439,6 +2590,10 @@ 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;
|
||||
@ -2572,7 +2727,7 @@ textarea.input {
|
||||
|
||||
.channels-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
@ -2580,6 +2735,19 @@ 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;
|
||||
@ -3144,6 +3312,14 @@ 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 {
|
||||
@ -3323,6 +3499,10 @@ textarea.input {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dm-screen .list-item {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dm-screen .meta-muted {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
@ -3345,6 +3525,49 @@ 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;
|
||||
}
|
||||
@ -3381,7 +3604,16 @@ textarea.input {
|
||||
|
||||
.dm-chat-input {
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr auto auto auto;
|
||||
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);
|
||||
}
|
||||
|
||||
.dm-voice-btn {
|
||||
@ -3389,6 +3621,21 @@ 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;
|
||||
@ -3426,6 +3673,35 @@ 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;
|
||||
@ -3545,6 +3821,12 @@ 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);
|
||||
@ -3835,7 +4117,13 @@ textarea.input {
|
||||
|
||||
.profile-top-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.profile-bottom-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@ -3844,13 +4132,18 @@ textarea.input {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
white-space: pre-line;
|
||||
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;
|
||||
|
||||
@ -113,6 +113,21 @@ 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 (обе строки). */
|
||||
|
||||
@ -16,9 +16,13 @@ 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
|
||||
@ -192,7 +196,13 @@ 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_UNSIBLING & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -105,7 +105,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
this.toBlockHash32 = null;
|
||||
}
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text");
|
||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
||||
|
||||
ensureNoTail(bb, "TextLineBody");
|
||||
}
|
||||
@ -129,7 +129,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
}
|
||||
|
||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
this.subType = subType;
|
||||
this.version = VER;
|
||||
@ -165,15 +167,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
|
||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||
} else {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||
throw new IllegalArgumentException("POST must not contain target fields");
|
||||
}
|
||||
@ -184,10 +186,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
int cap;
|
||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||
@ -234,9 +238,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||
}
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
if (len == 0) {
|
||||
if (allowEmpty) return "";
|
||||
throw new IllegalArgumentException(fieldName + " is empty");
|
||||
}
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
@ -248,7 +255,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
return s;
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
|
||||
@ -96,7 +96,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
bb.get(this.toBlockHash32);
|
||||
}
|
||||
|
||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text");
|
||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||
ensureNoTail(bb, "TextReplyBody");
|
||||
}
|
||||
|
||||
@ -113,8 +113,10 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
|
||||
}
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
||||
throw new IllegalArgumentException("message is blank");
|
||||
}
|
||||
|
||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||
|
||||
@ -142,18 +144,18 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
|
||||
if (toBlockGlobalNumber < 0)
|
||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (message == null || message.isBlank())
|
||||
throw new IllegalArgumentException("Text message is blank");
|
||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
||||
} else {
|
||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||
if (toBlockchainName != null)
|
||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
||||
}
|
||||
@ -164,10 +166,12 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
@Override
|
||||
public byte[] toBytes() {
|
||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||
|
||||
int st = subType & 0xFFFF;
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||
throw new IllegalArgumentException("Text payload is empty");
|
||||
}
|
||||
|
||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||
@ -213,9 +217,12 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
/* ====================== helpers ====================== */
|
||||
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||
int len = Short.toUnsignedInt(bb.getShort());
|
||||
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||
if (len == 0) {
|
||||
if (allowEmpty) return "";
|
||||
throw new IllegalArgumentException(fieldName + " is empty");
|
||||
}
|
||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||
|
||||
byte[] bytes = new byte[len];
|
||||
@ -227,7 +234,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||
|
||||
try {
|
||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||
return s;
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||
|
||||
@ -66,6 +66,15 @@ 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");
|
||||
|
||||
@ -198,6 +198,9 @@ 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;
|
||||
@ -206,13 +209,16 @@ 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:
|
||||
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
|
||||
-- 1) если записи нет — создаём
|
||||
INSERT OR IGNORE INTO connections_state (
|
||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||
@ -222,6 +228,12 @@ 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
|
||||
@ -233,9 +245,15 @@ 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)
|
||||
WHERE NEW.msg_sub_type IN (%d, %d, %d, %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
|
||||
@ -256,6 +274,12 @@ 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
|
||||
@ -264,9 +288,15 @@ public final class DatabaseTriggersInstaller {
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d)
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %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
|
||||
@ -277,12 +307,18 @@ public final class DatabaseTriggersInstaller {
|
||||
) IS NOT NULL
|
||||
AND NEW.to_bch_name IS NOT NULL;
|
||||
|
||||
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING:
|
||||
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
|
||||
-- удаляем соответствующее "позитивное" состояние
|
||||
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
|
||||
@ -299,10 +335,19 @@ 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
|
||||
@ -311,11 +356,11 @@ public final class DatabaseTriggersInstaller {
|
||||
ELSE NULL
|
||||
END
|
||||
) IS NOT NULL
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d);
|
||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
|
||||
END;
|
||||
""".formatted(
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING,
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING,
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
||||
|
||||
UNFRIEND, FRIEND,
|
||||
UNCONTACT, CONTACT,
|
||||
@ -324,8 +369,11 @@ 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
|
||||
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -40,8 +40,10 @@ 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
|
||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55
|
||||
* 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
|
||||
*/
|
||||
|
||||
/** Добавить в близкие друзья (close friend). */
|
||||
@ -92,6 +94,24 @@ 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 (обе строки). */
|
||||
|
||||
@ -40,13 +40,15 @@ public final class ConnectionsStateDAO {
|
||||
*/
|
||||
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT u.login AS friend_login
|
||||
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
||||
FROM connections_state cs
|
||||
JOIN solana_users u
|
||||
ON u.login = cs.to_login COLLATE NOCASE
|
||||
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
|
||||
WHERE cs.login = ? COLLATE NOCASE
|
||||
AND cs.rel_type = ?
|
||||
ORDER BY u.login
|
||||
ORDER BY friend_login
|
||||
""";
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
@ -68,19 +70,25 @@ public final class ConnectionsStateDAO {
|
||||
*/
|
||||
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT u.login AS friend_login
|
||||
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
||||
FROM connections_state cs
|
||||
JOIN solana_users u
|
||||
ON u.login = cs.login COLLATE NOCASE
|
||||
WHERE cs.to_login = ? COLLATE NOCASE
|
||||
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)
|
||||
)
|
||||
AND cs.rel_type = ?
|
||||
ORDER BY u.login
|
||||
ORDER BY friend_login
|
||||
""";
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, loginAnyCase);
|
||||
ps.setInt(2, relType);
|
||||
ps.setString(2, loginAnyCase);
|
||||
ps.setInt(3, relType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
String v = rs.getString("friend_login");
|
||||
|
||||
@ -410,10 +410,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
|
||||
// target columns (optional)
|
||||
if (block.body instanceof BodyHasTarget t) {
|
||||
String targetBchName = t.toBchName();
|
||||
int type = block.type & 0xFFFF;
|
||||
int sub = block.subType & 0xFFFF;
|
||||
boolean isTextEdit = type == 1
|
||||
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||
if (isTextEdit && (targetBchName == null || targetBchName.isBlank())) {
|
||||
targetBchName = blockchainName;
|
||||
}
|
||||
|
||||
be.setToLogin(t.toLogin());
|
||||
be.setToBchName(t.toBchName());
|
||||
be.setToBchName(targetBchName);
|
||||
be.setToBlockNumber(t.toBlockGlobalNumber());
|
||||
be.setToBlockHash(t.toBlockHashBytes());
|
||||
|
||||
if (isTextEdit && (be.getToLogin() == null || be.getToLogin().isBlank()) && targetBchName != null && !targetBchName.isBlank()) {
|
||||
be.setToLogin(BlockchainNameUtil.loginFromBlockchainName(targetBchName));
|
||||
}
|
||||
}
|
||||
|
||||
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
||||
|
||||
@ -218,16 +218,17 @@ final class ChannelsReadSupport {
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
AND to_block_number=? AND to_block_hash=?
|
||||
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||
ps.setString(4, ownerBch);
|
||||
ps.setInt(5, originalBlock);
|
||||
ps.setBytes(6, originalHash);
|
||||
ps.setInt(4, originalBlock);
|
||||
ps.setBytes(5, originalHash);
|
||||
ps.setString(6, ownerBch);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
|
||||
@ -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, ownerBch, post.blockNumber, post.blockHash);
|
||||
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
|
||||
|
||||
@ -196,15 +196,16 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||
AND to_block_number=? AND to_block_hash=?
|
||||
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bch);
|
||||
ps.setInt(2, subType);
|
||||
ps.setString(3, bch);
|
||||
ps.setInt(4, targetBlock);
|
||||
ps.setBytes(5, targetHash);
|
||||
ps.setInt(3, targetBlock);
|
||||
ps.setBytes(4, targetHash);
|
||||
ps.setString(5, bch);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostRow> out = new ArrayList<>();
|
||||
while (rs.next()) out.add(mapRow(rs));
|
||||
|
||||
@ -30,10 +30,14 @@ 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;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
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 пользователя");
|
||||
}
|
||||
}
|
||||
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);
|
||||
@ -55,11 +59,18 @@ 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);
|
||||
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
|
||||
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
|
||||
|
||||
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
||||
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
||||
@ -86,6 +97,12 @@ 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));
|
||||
|
||||
@ -21,6 +21,12 @@ 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<>();
|
||||
@ -102,6 +108,18 @@ 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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user