Compare commits
No commits in common. "21413268f3bf58c4a50b5a9f5fb638f9d48a112d01f29012508d2113f65d188d" and "ab31ccf6d8e373e2c19cee6d215d43ccb21e3baf5cfc410c25220c446ea9e9ff" have entirely different histories.
21413268f3
...
ab31ccf6d8
28
AGENTS.md
28
AGENTS.md
@ -13,21 +13,6 @@
|
|||||||
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
|
||||||
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
|
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
|
||||||
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
|
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
|
||||||
- Перед любым изменением формата блокчейна обязательно заранее предупреждать пользователя, что формат будет изменён.
|
|
||||||
- Изменять формат блокчейна можно только после явного подтверждения пользователя (без подтверждения формат не менять).
|
|
||||||
- Добавление любых данных в блокчейн выполнять только через операцию `AddBlock`.
|
|
||||||
- Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока.
|
|
||||||
|
|
||||||
## Документация личных сообщений (DM)
|
|
||||||
- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/README.md`.
|
|
||||||
- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять `Dev_Docs/Personal_Messages/README.md`.
|
|
||||||
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
|
|
||||||
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
|
|
||||||
|
|
||||||
## Известная проблема (временная пометка)
|
|
||||||
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
|
|
||||||
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
|
|
||||||
- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя.
|
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
||||||
@ -37,15 +22,16 @@
|
|||||||
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
- Базовое правило инкремента: `+1` по последнему числовому сегменту (patch), если не оговорено иное.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
|
- Все документы и заметки по деплою хранить в папке `Deploy Server/`.
|
||||||
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`).
|
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
|
||||||
|
- Базовый целевой хост для деплоя по умолчанию: `player@45.136.124.227`.
|
||||||
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
|
||||||
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
|
||||||
- Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`.
|
- Для операций `git push` использовать токен из переменной окружения `$GITEA_TOKEN`.
|
||||||
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
|
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
|
||||||
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
|
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.
|
||||||
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
|
- По умолчанию сначала деплой и проверка на тестовом контуре; на основной (`prod`) деплоить только после явного подтверждения пользователя, что версия проверена и готова.
|
||||||
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
|
- При уточняющем вопросе отдельно предупреждать: деплой на основной выполнять только если точно подтверждена корректная работа.
|
||||||
|
|
||||||
## Логи звонков (установка соединения)
|
## Логи звонков (установка соединения)
|
||||||
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
|
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
|
||||||
|
|||||||
52
DOC/api/PWA_FCM_SETUP.md
Normal file
52
DOC/api/PWA_FCM_SETUP.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Настройка PWA + FCM для веб-клиента (Chrome/Edge/Firefox/Safari iOS)
|
||||||
|
|
||||||
|
## 1) Что нужно создать в Firebase
|
||||||
|
1. Создать проект Firebase.
|
||||||
|
2. Включить Cloud Messaging.
|
||||||
|
3. Создать Web App и получить конфиг:
|
||||||
|
- apiKey
|
||||||
|
- authDomain
|
||||||
|
- projectId
|
||||||
|
- messagingSenderId
|
||||||
|
- appId
|
||||||
|
4. В Cloud Messaging -> Web Push certificates сгенерировать VAPID key.
|
||||||
|
5. Для серверной отправки взять **Server key** (legacy) или настроить HTTP v1 (service account).
|
||||||
|
|
||||||
|
## 2) Куда вставить токены в клиенте
|
||||||
|
Файл: `shine-UI/index.html` и `shine-UI/firebase-messaging-sw.js`.
|
||||||
|
|
||||||
|
Заполнить:
|
||||||
|
- `window.__SHINE_FIREBASE_CONFIG__`
|
||||||
|
- `window.__SHINE_FIREBASE_VAPID_KEY__`
|
||||||
|
- `FIREBASE_CONFIG` (в service worker)
|
||||||
|
|
||||||
|
## 3) Куда вставить серверный ключ FCM
|
||||||
|
Файл: `src/main/resources/application.properties`
|
||||||
|
|
||||||
|
Добавить:
|
||||||
|
```
|
||||||
|
fcm.server.key=YOUR_FCM_SERVER_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) PWA требования
|
||||||
|
1. Открывать сайт только по HTTPS (или localhost).
|
||||||
|
2. Разрешить уведомления в браузере.
|
||||||
|
3. Убедиться, что `manifest.webmanifest` доступен.
|
||||||
|
4. Убедиться, что `firebase-messaging-sw.js` зарегистрирован.
|
||||||
|
|
||||||
|
## 5) Safari / iPhone (iOS)
|
||||||
|
- Нужен iOS 16.4+.
|
||||||
|
- Пользователь должен добавить сайт на Home Screen.
|
||||||
|
- После запуска PWA с Home Screen дать разрешение на уведомления.
|
||||||
|
- Без Home Screen web push в Safari iOS не работает.
|
||||||
|
|
||||||
|
## 6) Проверка
|
||||||
|
1. Логин в приложении.
|
||||||
|
2. Клиент вызывает `UpsertPushToken` и отправляет FCM токен на сервер.
|
||||||
|
3. Вызов `SendDirectMessage` пользователю без активной WS доставки.
|
||||||
|
4. Сервер шлет push через FCM.
|
||||||
|
|
||||||
|
## 7) Поддержка разных браузеров
|
||||||
|
- Chrome/Edge/Opera/Android Browser: FCM web push поддерживается нативно.
|
||||||
|
- Firefox: поддержка web push есть, но тестировать отдельно (поведение токенов отличается).
|
||||||
|
- Safari macOS/iOS: web push есть, но требуется PWA режим и Apple-ограничения.
|
||||||
@ -107,20 +107,6 @@
|
|||||||
- `CONNECTION_UNCONTACT (21)`
|
- `CONNECTION_UNCONTACT (21)`
|
||||||
- `CONNECTION_FOLLOW (30)`
|
- `CONNECTION_FOLLOW (30)`
|
||||||
- `CONNECTION_UNFOLLOW (31)`
|
- `CONNECTION_UNFOLLOW (31)`
|
||||||
- `CONNECTION_SPOUSE (40)`
|
|
||||||
- `CONNECTION_UNSPOUSE (41)`
|
|
||||||
- `CONNECTION_PARENT (50)`
|
|
||||||
- `CONNECTION_UNPARENT (51)`
|
|
||||||
- `CONNECTION_CHILD (52)`
|
|
||||||
- `CONNECTION_UNCHILD (53)`
|
|
||||||
- `CONNECTION_SIBLING (54)`
|
|
||||||
- `CONNECTION_UNSIBLING (55)`
|
|
||||||
- `CONNECTION_KNOWN_PERSON (60)`
|
|
||||||
- `CONNECTION_UNKNOWN_PERSON (61)`
|
|
||||||
- `CONNECTION_SHINE_CONFIRMED (70)`
|
|
||||||
- `CONNECTION_SHINE_UNCONFIRMED (71)`
|
|
||||||
- `CONNECTION_SHINE_SEEN (74)`
|
|
||||||
- `CONNECTION_SHINE_UNSEEN (75)`
|
|
||||||
|
|
||||||
5. **USER_PARAM (type=4)**
|
5. **USER_PARAM (type=4)**
|
||||||
- `USER_PARAM_TEXT_TEXT (1)`
|
- `USER_PARAM_TEXT_TEXT (1)`
|
||||||
|
|||||||
@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
||||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY.
|
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY.
|
||||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
- `type=2` — REACTION: LIKE.
|
||||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW и обратные операции.
|
||||||
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
- `type=4` — USER_PARAM: key/value-параметры пользователя.
|
||||||
|
|
||||||
## Примечание
|
## Примечание
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Типы каналов и CreateChannel
|
# Типы каналов и CreateChannel
|
||||||
|
|
||||||
## 1. Формат `CreateChannelBody` (`msg_type=0`, `subType=1`, `version=1`)
|
## 1. Формат `CreateChannelBody`
|
||||||
Payload включает:
|
Формат `TECH_CREATE_CHANNEL` поддерживает единственный текущий `version=1` и включает:
|
||||||
|
|
||||||
1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`);
|
1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`);
|
||||||
2. `channelName`;
|
2. `channelName`;
|
||||||
@ -17,10 +17,6 @@ Payload включает:
|
|||||||
|
|
||||||
Версия типа (`channelTypeVersion`) сейчас используется со значением `1`.
|
Версия типа (`channelTypeVersion`) сейчас используется со значением `1`.
|
||||||
|
|
||||||
Важно для MVP:
|
|
||||||
- `100` и `200` в формате поддерживаются, но в текущем UI не используются.
|
|
||||||
- В MVP рабочий UI-флоу — каналы `0` и `1`.
|
|
||||||
|
|
||||||
## 3. Имя root-канала
|
## 3. Имя root-канала
|
||||||
- Root-канал (`line_code = 0`) в API/чтении отображается как `stories`.
|
- Root-канал (`line_code = 0`) в API/чтении отображается как `stories`.
|
||||||
- Публикации в `stories` разрешены владельцу собственного блокчейна.
|
- Публикации в `stories` разрешены владельцу собственного блокчейна.
|
||||||
|
|||||||
@ -24,21 +24,7 @@
|
|||||||
- связей и подписок;
|
- связей и подписок;
|
||||||
- пользовательских параметров.
|
- пользовательских параметров.
|
||||||
|
|
||||||
## 3. Правила line-полей (фактическая серверная валидация)
|
## 3. Root-идея для каналов и подписок
|
||||||
|
|
||||||
Line-поля: `lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`.
|
|
||||||
|
|
||||||
- Line-поля разрешены только для `msg_type`: `0`, `1`, `3`, `4`.
|
|
||||||
- Если передано хотя бы одно line-поле, должны быть переданы все 4.
|
|
||||||
- `prevLineNumber/prevLineHash32` должны указывать на существующий блок этой же цепочки.
|
|
||||||
- Для первого шага после root (`prevLineNumber == lineCode`):
|
|
||||||
- `TEXT (msg_type=1)`: `thisLineNumber = 0`;
|
|
||||||
- `TECH/CONNECTION/USER_PARAM (0/3/4)`: `thisLineNumber = 1`.
|
|
||||||
- Для обычного шага:
|
|
||||||
- `TEXT`: `thisLineNumber` допускает `same` или `+1` от предыдущего блока линии;
|
|
||||||
- `TECH/CONNECTION/USER_PARAM`: строго `+1`.
|
|
||||||
|
|
||||||
## 4. Root-идея для каналов и подписок
|
|
||||||
|
|
||||||
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
|
Для ссылок вида follow/friend/contact принято ссылаться на корневые блоки:
|
||||||
- `HEADER` для базовой сущности пользователя/канала `0`;
|
- `HEADER` для базовой сущности пользователя/канала `0`;
|
||||||
|
|||||||
@ -27,7 +27,3 @@
|
|||||||
- Команды передаются как обычные `TEXT_POST` сообщения.
|
- Команды передаются как обычные `TEXT_POST` сообщения.
|
||||||
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
|
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
|
||||||
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
|
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.
|
||||||
|
|
||||||
## 4. Статус для MVP
|
|
||||||
- В текущем UI каналы `type=100` и `type=200` не используются.
|
|
||||||
- Соответственно, `/.add` и `/.remove` считаются запланированными и пока не участвуют в рабочем UI-сценарии.
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ TECH-тип покрывает системные записи цепочки.
|
|||||||
|
|
||||||
2. `subType=1` — `TECH_CREATE_CHANNEL`
|
2. `subType=1` — `TECH_CREATE_CHANNEL`
|
||||||
- создание нового канала;
|
- создание нового канала;
|
||||||
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
- хранит line-поля + `channelName`.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
|
|||||||
@ -19,14 +19,7 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||||
- редактирование ответа;
|
- редактирование ответа;
|
||||||
- target на исходный REPLY + новый текст.
|
- target на исходный REPLY + новый текст.
|
||||||
- допускается пустой `text` для логического удаления сообщения (без физического удаления блока).
|
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||||
|
|
||||||
## Пустой text в edit
|
|
||||||
|
|
||||||
- Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`.
|
|
||||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
|
||||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
|
||||||
|
|||||||
@ -5,9 +5,6 @@
|
|||||||
1. `subType=1` — `REACTION_LIKE`
|
1. `subType=1` — `REACTION_LIKE`
|
||||||
- лайк на целевой блок;
|
- лайк на целевой блок;
|
||||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||||
2. `subType=2` — `REACTION_UNLIKE`
|
|
||||||
- снятие лайка с целевого блока;
|
|
||||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
|
|||||||
@ -4,16 +4,12 @@ CONNECTION-тип описывает социальные связи и подп
|
|||||||
|
|
||||||
## Подтипы
|
## Подтипы
|
||||||
|
|
||||||
• `10/11` — `close_friend / unclose_friend` (близкий друг)
|
1. `subType=10` — `CONNECTION_FRIEND`
|
||||||
• `20/21` — `contact / uncontact` (контакт)
|
2. `subType=11` — `CONNECTION_UNFRIEND`
|
||||||
• `30/31` — `follow / unfollow` (подписан)
|
3. `subType=20` — `CONNECTION_CONTACT`
|
||||||
• `40/41` — `spouse / unspouse` (супруг/супруга)
|
4. `subType=21` — `CONNECTION_UNCONTACT`
|
||||||
• `50/51` — `parent / unparent` (родитель)
|
5. `subType=30` — `CONNECTION_FOLLOW`
|
||||||
• `52/53` — `child / unchild` (ребёнок)
|
6. `subType=31` — `CONNECTION_UNFOLLOW`
|
||||||
• `54/55` — `sibling / unsibling` (брат/сестра)
|
|
||||||
• `60/61` — `known_person / unknown_person` (знаю этого человека)
|
|
||||||
• `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий)
|
|
||||||
• `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим)
|
|
||||||
|
|
||||||
## Общий формат payload
|
## Общий формат payload
|
||||||
|
|
||||||
@ -26,4 +22,3 @@ CONNECTION-тип описывает социальные связи и подп
|
|||||||
- FOLLOW указывает на root канала:
|
- FOLLOW указывает на root канала:
|
||||||
- `HEADER` для канала `0`;
|
- `HEADER` для канала `0`;
|
||||||
- `CREATE_CHANNEL` для пользовательского канала.
|
- `CREATE_CHANNEL` для пользовательского канала.
|
||||||
- Для остальных типов связи (`SPOUSE/PARENT/CHILD/SIBLING`) используется тот же target-формат.
|
|
||||||
|
|||||||
@ -1,27 +1,5 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
## 2026-05-20 11:34:17 +0300
|
|
||||||
- Базовый коммит-ориентир: `a53444b`.
|
|
||||||
- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы:
|
|
||||||
- `60/61` — `known_person / unknown_person` (знаю этого человека);
|
|
||||||
- `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий);
|
|
||||||
- `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим).
|
|
||||||
- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`.
|
|
||||||
|
|
||||||
## 2026-05-19 20:30:21 +0300
|
|
||||||
- Базовый коммит-ориентир: `7986184`.
|
|
||||||
- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения.
|
|
||||||
- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком.
|
|
||||||
|
|
||||||
## 2026-05-19 00:22:46 +0300
|
|
||||||
- Базовый коммит-ориентир: `c27da63a3e65`.
|
|
||||||
- Актуализирован `README.md` как точка входа для MVP-документации по протоколу.
|
|
||||||
- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI.
|
|
||||||
- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`.
|
|
||||||
- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции.
|
|
||||||
- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей.
|
|
||||||
- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением.
|
|
||||||
|
|
||||||
## 2026-05-13 00:02:32 +0300
|
## 2026-05-13 00:02:32 +0300
|
||||||
- Базовый коммит-ориентир: `f63f40f1eb2f`.
|
- Базовый коммит-ориентир: `f63f40f1eb2f`.
|
||||||
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.
|
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.
|
||||||
|
|||||||
@ -1,33 +1,16 @@
|
|||||||
# Документация блокчейна SHiNE (MVP)
|
# Blockchain Docs (Актуально)
|
||||||
|
|
||||||
Этот каталог описывает только текущий рабочий формат протокола для MVP.
|
## Назначение
|
||||||
|
Этот набор файлов — актуальная документация по текущему формату блокчейна SHiNE для каналов, их типов и командных сообщений.
|
||||||
|
|
||||||
## Основные документы
|
## Оглавление
|
||||||
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
|
1. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||||
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
|
Текущий формат `CreateChannelBody`, типы каналов, уникальность имён и правила `stories`.
|
||||||
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
|
2. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||||
Виды цепочек и правила line-полей.
|
Командные сообщения в каналах: `/.desc`, `/.add`, `/.remove`.
|
||||||
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
|
3. [CHANGELOG.md](./CHANGELOG.md)
|
||||||
Системные блоки (`msg_type=0`).
|
История изменений документации и блокчейн-правил.
|
||||||
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
|
|
||||||
Текстовые блоки (`msg_type=1`).
|
|
||||||
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
|
|
||||||
Реакции (`msg_type=2`).
|
|
||||||
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
|
|
||||||
Социальные связи (`msg_type=3`).
|
|
||||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
|
||||||
Параметры пользователя (`msg_type=4`).
|
|
||||||
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
|
||||||
Типы каналов и формат `CreateChannelBody`.
|
|
||||||
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
|
||||||
Команды в текстовых сообщениях каналов.
|
|
||||||
10. [CHANGELOG.md](./CHANGELOG.md)
|
|
||||||
Журнал изменений документации.
|
|
||||||
|
|
||||||
## Важные ограничения MVP
|
## Обязательное правило сопровождения
|
||||||
- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI.
|
- Любое изменение блокчейн-кода (форматы, типы, правила чтения/записи, команды) должно сопровождаться обновлением файлов из этого каталога.
|
||||||
- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`.
|
- Изменение обязательно фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
||||||
|
|
||||||
## Обязательное сопровождение
|
|
||||||
- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений.
|
|
||||||
- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.
|
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Уведомления: продуктовые заглушки + правило intake в AGENTS
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- На вкладке `Уведомления` удалены демонстрационные карточки из разделов `Ответы` и `События`.
|
||||||
|
- В каждом табе добавлена отдельная продуктовая заглушка:
|
||||||
|
- `Ответы`: про ответы и комментарии на сообщения в публичных каналах;
|
||||||
|
- `События`: про подписки, добавления, лайки и прочие действия.
|
||||||
|
- В обоих табах добавлено явное сообщение, что раздел находится в разработке.
|
||||||
|
- В `AGENTS.md` добавлен обязательный блок: при новом задании сначала пересказ, вопросы, при необходимости идеи, оценка фичи и обязательное подтверждение перед началом реализации.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- Открыть `Уведомления` и проверить, что в `Ответы` отображается только заглушка (без примеров карточек).
|
||||||
|
- Переключить на `События` и проверить отдельную заглушку с текстом про события.
|
||||||
|
- Убедиться, что в обоих табах присутствует сообщение о разработке и будущем добавлении функционала.
|
||||||
|
- Проверить наличие нового блока `Коммуникация по новым задачам (обязательно)` в `AGENTS.md`.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- Вкладка уведомлений содержит только две заглушки по табам и не показывает тестовые данные.
|
||||||
|
- Правило работы с новыми задачами зафиксировано в `AGENTS.md`.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -1,36 +0,0 @@
|
|||||||
## Краткое описание
|
|
||||||
|
|
||||||
На экране `Кошелёк -> Solana кошелёк` добавлен блок создания нового Solana-кошелька:
|
|
||||||
- генерация случайного кошелька;
|
|
||||||
- генерация публичного ключа из введённого приватного ключа Base58 (32 байта).
|
|
||||||
|
|
||||||
Добавлены:
|
|
||||||
- валидация формата Base58;
|
|
||||||
- проверка точной длины приватного ключа (ровно 32 байта после декодирования);
|
|
||||||
- запрет ввода слишком длинного значения (`maxlength=44`);
|
|
||||||
- статус `Подходит` для валидного ввода;
|
|
||||||
- нередактируемое поле публичного ключа с возможностью копирования.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
1. Открыть `Кошелёк -> Solana кошелёк`.
|
|
||||||
2. В блоке создания кошелька нажать `Сгенерировать случайный кошелёк`.
|
|
||||||
3. Проверить, что появились:
|
|
||||||
- приватный ключ Base58;
|
|
||||||
- публичный ключ Base58 (в нередактируемом поле).
|
|
||||||
4. Нажать `Копировать приватный` и `Копировать публичный` — убедиться, что значения копируются.
|
|
||||||
5. Ввести невалидный приватный ключ (символы не из Base58) — увидеть ошибку формата.
|
|
||||||
6. Ввести слишком короткий ключ — увидеть сообщение, что значение слишком короткое.
|
|
||||||
7. Ввести валидный Base58-ключ на 32 байта — увидеть статус `Подходит`.
|
|
||||||
8. Нажать `Сгенерировать из приватного ключа` — публичный ключ должен сгенерироваться.
|
|
||||||
9. Проверить, что в поле ввода приватного ключа нельзя вставить/ввести более 44 символов.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- Оба сценария генерации работают стабильно.
|
|
||||||
- Для невалидного ввода показываются корректные сообщения.
|
|
||||||
- Поле публичного ключа не редактируется, но значение можно скопировать.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
`pending`
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Навигация по тредам и история сообщения
|
|
||||||
|
|
||||||
Статус: `pending`
|
|
||||||
|
|
||||||
## Краткое описание
|
|
||||||
В экране треда добавлен явный переход `🧵 В тред` для каждого сообщения (включая ответы), чтобы можно было углубляться в любую ветку обсуждения.
|
|
||||||
Также уточнены заголовки блоков: сверху история сообщений, отдельно текущее сообщение.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. Открыть любой канал и перейти в тред сообщения.
|
|
||||||
2. Нажать `🧵 В тред` у одного из ответов.
|
|
||||||
3. Убедиться, что открывается тред выбранного ответа, а не исходного сообщения.
|
|
||||||
4. Проверить, что в новом треде сверху показывается блок истории (`История выше...`), затем блок `Текущее сообщение`, затем `Ответы`.
|
|
||||||
5. Проверить на мобильной ширине, что кнопки действий в карточке не ломают верстку.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
- Переход в тред ответа работает стабильно для всех узлов дерева.
|
|
||||||
- Пользователь видит структуру треда в логичном порядке: предки → текущее сообщение → потомки.
|
|
||||||
- UI остаётся читаемым на мобильных экранах.
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Короткая ссылка на сообщение `#/m/{blockchainName}/{blockNumber}`
|
|
||||||
|
|
||||||
Статус: `pending`
|
|
||||||
|
|
||||||
## Краткое описание
|
|
||||||
Добавлен короткий роут сообщения `#/m/{blockchainName}/{blockNumber}` (поддерживает и вариант с hash).
|
|
||||||
Переходы в тред из канала и из треда теперь формируются через `#/m/...`, а не через длинный путь канала.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. Открыть сообщение в канале и перейти в тред — адрес должен быть формата `#/m/...`.
|
|
||||||
2. Скопировать ссылку на тред сообщения и открыть в новой вкладке.
|
|
||||||
3. Для ответа (reply) нажать `🧵 В тред` и убедиться, что тред открывается без ошибок `BAD_FIELDS`/`Не удалось определить hash`.
|
|
||||||
4. Проверить шапку треда: UI должен попытаться восстановить красивый заголовок канала (`owner/channel`).
|
|
||||||
5. Проверить, что старый маршрут `#/channel-thread-view/...` тоже продолжает работать.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
- Короткий роут работает стабильно для постов и ответов.
|
|
||||||
- Тред открывается даже если в URL нет hash (опциональный случай).
|
|
||||||
- Ошибка про невозможность определить hash для открытия треда не воспроизводится.
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
# Переход на history-router без `#` в URL
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- UI переведён с hash-router на history API роутинг.
|
|
||||||
- Ссылки на треды переведены в формат без hash сообщения: `/m/{blockchainName}/{blockNumber}`.
|
|
||||||
- Навигация и шаринг-ссылки обновлены под `pathname`.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
- Открытие UI с корня (`/`) и переход на стартовую страницу без тёмного экрана.
|
|
||||||
- Навигация между основными экранами (сообщения, каналы, профиль, настройки).
|
|
||||||
- Переход в канал, открытие треда, ответ/лайк, шаринг ссылки.
|
|
||||||
- Прямое открытие URL формата `/m/{blockchain}/{number}`.
|
|
||||||
- Поведение после refresh (F5) при настроенном серверном fallback на `index.html`.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Приложение работает без `#` в адресе.
|
|
||||||
- Треды открываются и действия по сообщению (reply/like/share) работают корректно.
|
|
||||||
- Нет зависания на пустом/тёмном экране при входе.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# Карточка автора в сообщении канала и стрелка «назад» по истории
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- В `channel-view` в карточке сообщения добавлена вложенная плитка автора (аватар, логин, номер сообщения, дата/время).
|
|
||||||
- Клик по плитке автора открывает профиль пользователя.
|
|
||||||
- Клик по области сообщения (вне плитки автора и вне action-кнопок) открывает тред, как кнопка `Тред`.
|
|
||||||
- Стрелка `назад` в `channel-view`, `channel-thread-view` и профиле переведена на реальную навигацию `history.back()`.
|
|
||||||
- Маршрут профиля переименован с `user-profile-view` на `user`.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
- В канале у каждого сообщения сверху есть вложенная плитка автора.
|
|
||||||
- Клик по вложенной плитке открывает профиль автора.
|
|
||||||
- Клик по тексту/телу сообщения открывает тред.
|
|
||||||
- Кнопки `Лайк`, `Ответить`, `Тред`, `Отправить` работают отдельно и не конфликтуют с кликом по карточке.
|
|
||||||
- Стрелка `назад` возвращает на предыдущий экран по реальной истории переходов.
|
|
||||||
- При отсутствии истории стрелка `назад` не делает переход.
|
|
||||||
- Переходы на профиль работают по новому маршруту `user/{login}/...`.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Навигация в каналах и тредах соответствует ожидаемому UX.
|
|
||||||
- Переходы в профиль и назад по истории работают стабильно.
|
|
||||||
- Старый маршрут `user-profile-view` больше не используется.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
# Шапка канала и унификация карточек в треде
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- В `channel-view` убрана отдельная кнопка `О канале` из тела экрана.
|
|
||||||
- В шапке добавлена одна кнопка-лейбл формата `owner/channel` на уровне стрелки назад.
|
|
||||||
- Нажатие по кнопке `owner/channel` открывает тот же модал «О канале».
|
|
||||||
- В `channel-thread-view` карточки сообщений приведены к виду, аналогичному карточкам в канале:
|
|
||||||
- верхняя плитка автора (аватар, логин, номер, время),
|
|
||||||
- действия `Лайк`, `Ответить`, `Тред`, `Отправить`.
|
|
||||||
- Клик по телу карточки в треде теперь открывает вложенный (более глубокий) тред этого сообщения.
|
|
||||||
- Уменьшены отступы между карточками/блоками в треде.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
- В канале в шапке справа отображается единая кнопка `owner/channel`.
|
|
||||||
- Кнопка `owner/channel` открывает модал «О канале».
|
|
||||||
- Старой кнопки `О канале` в контенте экрана нет.
|
|
||||||
- В треде визуал карточек совпадает по паттерну с каналом.
|
|
||||||
- В треде клик по телу сообщения ведёт глубже в тред.
|
|
||||||
- Клик по плитке автора в треде ведёт в профиль пользователя.
|
|
||||||
- Межкарточные отступы в треде компактнее.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Шапка канала и карточки треда выглядят и работают единообразно.
|
|
||||||
- Навигация по вложенным тредам выполняется кликом по сообщению.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
# Поднятие верхней фиксированной шапки (канал и тред)
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- В `channel-view` и `channel-thread-view` верхняя фиксированная шапка (стрелка назад + центральная кнопка с названием) поднята выше к верхней границе экрана.
|
|
||||||
- Центральная кнопка и стрелка дополнительно подняты внутри шапки для более плотного позиционирования.
|
|
||||||
- Поведение hover/focus сохранено без визуального «прыжка» центральной кнопки.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
- В канале и в треде верхняя шапка визуально выше, чем до правки.
|
|
||||||
- Кнопка по центру и стрелка назад подняты и находятся на одной линии.
|
|
||||||
- При наведении курсора центральная кнопка не смещается.
|
|
||||||
- Шапка остаётся фиксированной при прокрутке.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Верхняя навигационная область выглядит компактнее и стабильнее.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
# Профиль: упрощение + чат: UX меню и голосовой ввод
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- В `profile-view` убрана кнопка `Обновить` и статусная строка (`Профиль обновлён`/ошибки).
|
|
||||||
- Кнопка `Изменить профиль` переименована в `Редактировать профиль`.
|
|
||||||
- В личном чате обновлены UX-сценарии:
|
|
||||||
- контекстное меню на сообщении (`Копировать`, `Прочесть`) с закрытием кликом вне меню;
|
|
||||||
- тост `Сообщение скопированно` при копировании;
|
|
||||||
- обновлённый модал голосового ввода (`Отмена`, `OK`, `Распознать и сразу отправить сообщение`);
|
|
||||||
- фоновое распознавание и авто-отправка для сценария «сразу отправить»;
|
|
||||||
- для `OK` отображается режим ожидания распознавания с последующей вставкой текста в поле ввода.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
- На вкладке профиля отсутствуют кнопка `Обновить` и зелёный статус `Профиль обновлён`.
|
|
||||||
- Кнопка вверху профиля называется `Редактировать профиль`.
|
|
||||||
- В чате по клику на сообщение открывается компактное меню с двумя пунктами.
|
|
||||||
- Копирование текста сообщения работает и показывает короткий тост.
|
|
||||||
- Прочтение сообщения вслух запускается сразу.
|
|
||||||
- Голосовой ввод корректно работает в двух режимах: вставка текста и авто-отправка после распознавания.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Профиль выглядит чище, без лишних статусов и ручной перезагрузки.
|
|
||||||
- В личных сообщениях управление сообщениями и голосовым вводом работает стабильно и предсказуемо.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
# DM: Ctrl+Enter, автоскролл и время в списке
|
|
||||||
|
|
||||||
- Статус: `pending`
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
|
|
||||||
- Исправлено поведение ввода в чате:
|
|
||||||
- `Enter` отправляет сообщение;
|
|
||||||
- `Ctrl+Enter` добавляет перенос строки в поле ввода.
|
|
||||||
- В списке личных сообщений время последнего сообщения всегда отображается в правой колонке снизу.
|
|
||||||
- Бейдж непрочитанных сообщений (если есть) отображается над временем, не заменяя его.
|
|
||||||
- Обновлены стили карточки диалога для компактного и стабильного выравнивания.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- В чате:
|
|
||||||
- нажать `Ctrl+Enter` в середине текста и убедиться, что вставляется новая строка;
|
|
||||||
- нажать `Enter` и убедиться, что сообщение отправляется.
|
|
||||||
- В списке диалогов:
|
|
||||||
- при `unread=0` справа снизу показывается время;
|
|
||||||
- при `unread>0` сверху бейдж, снизу всё равно показывается время;
|
|
||||||
- длинный текст последнего сообщения обрезается многоточием и не наезжает на время.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- Управление вводом работает как в постановке.
|
|
||||||
- Время в карточке диалога не исчезает при наличии непрочитанных сообщений.
|
|
||||||
- Верстка карточки остаётся компактной и без сдвигов.
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# Личные сообщения: правая мета-колонка и Enter/Ctrl+Enter
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
- В списке `Личные сообщения` обновлена правая колонка карточки диалога:
|
|
||||||
- сверху отображается бейдж количества непрочитанных (если есть);
|
|
||||||
- снизу маленьким шрифтом отображается дата/время последнего сообщения;
|
|
||||||
- если сообщений нет, вместо времени отображается `-`.
|
|
||||||
- В экране чата нижний блок ввода закреплён (sticky) и остаётся на месте при прокрутке.
|
|
||||||
- В поле ввода чата изменено поведение клавиш:
|
|
||||||
- `Enter` отправляет сообщение;
|
|
||||||
- `Ctrl+Enter` добавляет перенос строки и не отправляет сообщение.
|
|
||||||
|
|
||||||
- Что проверять:
|
|
||||||
- В карточках диалогов справа корректно показываются непрочитанные/время/прочерк.
|
|
||||||
- В чате нижний блок ввода не уезжает при прокрутке истории.
|
|
||||||
- `Enter` отправляет сообщение из textarea.
|
|
||||||
- `Ctrl+Enter` вставляет новую строку в textarea.
|
|
||||||
|
|
||||||
- Ожидаемый результат:
|
|
||||||
- Список диалогов показывает полезную мета-информацию в стабильном формате.
|
|
||||||
- Ввод сообщений в чате работает в привычной схеме Enter/многострочность.
|
|
||||||
|
|
||||||
- Статус:
|
|
||||||
- `pending`
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
# Деплой на `93.170.12.154`: Caddy + systemd
|
|
||||||
|
|
||||||
- Статус: `pending`
|
|
||||||
|
|
||||||
## Что сделано
|
|
||||||
|
|
||||||
- Выполнен деплой UI и серверной части на `player@93.170.12.154`.
|
|
||||||
- Создана структура:
|
|
||||||
- `/home/player/SHiNE/caddy`
|
|
||||||
- `/home/player/SHiNE/SHiNE-server`
|
|
||||||
- `/home/player/SHiNE/SHiNE-UI`
|
|
||||||
- Перенесены локальные данные:
|
|
||||||
- `data/shine.sqlite`
|
|
||||||
- `data/*.bch`
|
|
||||||
- Настроен `shine-server.service` через `systemd`.
|
|
||||||
- Настроен `Caddy`:
|
|
||||||
- no-cache заголовки;
|
|
||||||
- SPA fallback на `index.html`;
|
|
||||||
- проксирование `/ws` на `127.0.0.1:7070`.
|
|
||||||
- Добавлена документация в `Dev_Docs/deploy/` и файл по legacy-серверу `45.136.124.227`.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
|
|
||||||
- Открыть `https://shineup.me/start-view`.
|
|
||||||
- Обновить страницу (`Ctrl+F5`) на роуте вида `/start-view` и убедиться, что нет 404.
|
|
||||||
- Проверить авторизацию и базовые действия в UI.
|
|
||||||
- Проверить, что вебсокет соединение устанавливается.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
|
|
||||||
- UI и сервер доступны на новом хосте.
|
|
||||||
- Сервисы `shine-server` и `caddy` в статусе `active`.
|
|
||||||
- Маршруты SPA и no-cache работают как ожидается.
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# Редактирование сообщений: история и delete через пустой edit
|
|
||||||
|
|
||||||
Статус: `pending`
|
|
||||||
|
|
||||||
## Краткое описание
|
|
||||||
- Исправлено применение edit-блоков в чтении канала/треда (актуальный текст и версии).
|
|
||||||
- Для удаления сообщения используется edit с пустым `text` (`textLen=0`).
|
|
||||||
- В UI добавлена метка `изменено N`, по нажатию открывается история версий.
|
|
||||||
- Кнопка редактирования оставлена как иконка карандаша без текста.
|
|
||||||
- В модалке редактирования: сверху `Отмена` и `ОК`, снизу отдельная `Удалить`.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. В канале отредактировать свой пост обычным текстом.
|
|
||||||
2. Убедиться, что текст сообщения сразу обновился и появилась метка `изменено 1`.
|
|
||||||
3. Нажать на метку `изменено 1` и проверить историю: сверху оригинал, ниже изменения, последнее внизу.
|
|
||||||
4. Нажать `Удалить` в модалке редактирования, убедиться, что сообщение отображается как `удалено`.
|
|
||||||
5. Повторно отредактировать удалённое сообщение непустым текстом и проверить, что текст снова отображается.
|
|
||||||
6. Повторить пп.1-5 в экране треда.
|
|
||||||
7. Проверить личный канал (пара A↔B), что edit и история корректно видны для сообщений владельца.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
- Edit всегда влияет на отображаемый текст сообщения.
|
|
||||||
- История версий открывается из метки `изменено N` и содержит полный хронологический список версий.
|
|
||||||
- Удаление работает как edit с пустым текстом, без физического удаления блока.
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Каналы: убрать кнопку тред, две вкладки, и автоскролл DM
|
|
||||||
|
|
||||||
Статус: `pending`
|
|
||||||
|
|
||||||
## Краткое описание
|
|
||||||
- В карточках сообщений канала и треда убрана кнопка `Тред` (`#`).
|
|
||||||
- В `channels-list` оставлены только две вкладки: `Каналы` и `Мои каналы` (вкладка `Чаты` удалена).
|
|
||||||
- Удалённые сообщения в канале и треде отображаются с красной пометкой `Сообщение удалено`.
|
|
||||||
- В личных сообщениях усилен автоскролл вниз: при открытии чата и после отправки нового сообщения.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. Открыть канал, проверить, что в действиях сообщения нет кнопки `Тред`.
|
|
||||||
2. Открыть тред, проверить, что в действиях сообщения также нет кнопки `Тред`.
|
|
||||||
3. В списке каналов сверху проверить только 2 вкладки: `Каналы` и `Мои каналы`.
|
|
||||||
4. Удалить сообщение edit-ом в канале и в треде, убедиться, что видно красную пометку `Сообщение удалено`.
|
|
||||||
5. Открыть любой личный чат, убедиться, что экран сразу внизу ленты.
|
|
||||||
6. Отправить новое сообщение в личке, убедиться, что лента остаётся прокрученной вниз и сообщение видно сразу.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
- Лишняя кнопка `Тред` отсутствует.
|
|
||||||
- Вкладка `Чаты` отсутствует, навигация в списке каналов состоит из 2 вкладок.
|
|
||||||
- Удалённые сообщения визуально выделены красным в канале и в треде.
|
|
||||||
- В DM нет ручной прокрутки после входа в чат и после отправки новых сообщений.
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
## Краткое описание
|
|
||||||
Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
|
||||||
2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
|
||||||
3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан).
|
|
||||||
4. Если аватар не задан или недоступен, корректно остаётся fallback (буква).
|
|
||||||
5. Форма и размер остаются круглыми и визуально не ломают карточки.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
`pending`
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
## Краткое описание
|
|
||||||
Добавлены новые типы connection-связей в блокчейне и API:
|
|
||||||
- `known_person` (`60/61`)
|
|
||||||
- `shine_confirmed` (`70/71`)
|
|
||||||
- `shine_seen` (`74/75`)
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. `AddBlock` принимает новые `msg_sub_type` для `type=3`.
|
|
||||||
2. Связи корректно попадают в `connections_state`:
|
|
||||||
- ON создаёт/обновляет запись;
|
|
||||||
- OFF удаляет запись соответствующего ON-типа.
|
|
||||||
3. `GetUserConnectionsGraph` возвращает новые поля:
|
|
||||||
- `outKnownPersons`, `inKnownPersons`
|
|
||||||
- `outShineConfirmed`, `inShineConfirmed`
|
|
||||||
- `outShineSeen`, `inShineSeen`
|
|
||||||
4. Клиент `setUserRelation` принимает `kind`:
|
|
||||||
- `known_person`, `shine_confirmed`, `shine_seen`.
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
Новые связи работают как обычные ON/OFF relation-типы, но не ломают текущие friend/contact/follow и остальные существующие связи.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
`pending`
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
## Краткое описание
|
|
||||||
Перестроен блок связей в профиле чужого пользователя и добавлен UI для одностороннего "мнения" (`known_person` / `shine_confirmed` / `shine_seen`) с взаимным исключением на уровне UI.
|
|
||||||
|
|
||||||
## Что проверять
|
|
||||||
1. Порядок базовых строк в профиле:
|
|
||||||
- Контакт
|
|
||||||
- Близкий друг
|
|
||||||
- Подписка
|
|
||||||
2. Под этими строками отображается блок мнений:
|
|
||||||
- при отсутствии мнения кнопка `Добавить связь`;
|
|
||||||
- при наличии мнения кнопка `Изменить связи`;
|
|
||||||
- показываются текстовые формулировки для активного мнения.
|
|
||||||
3. В модальном меню:
|
|
||||||
- варианты добавления (синие);
|
|
||||||
- `Убрать мнение` (красная).
|
|
||||||
4. При смене мнения отправляется последовательность:
|
|
||||||
- OFF старой связи,
|
|
||||||
- ON новой связи.
|
|
||||||
5. Для новых мнений показываются только исходящие (`out*`) оценки текущего пользователя (односторонняя логика).
|
|
||||||
|
|
||||||
## Ожидаемый результат
|
|
||||||
Пользователь управляет одним активным мнением через UI, состояние читается корректно и не ломает существующие friend/contact/follow кнопки.
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
`pending`
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
# Личные сообщения (DM): как это устроено
|
|
||||||
|
|
||||||
## Коротко (для быстрого понимания)
|
|
||||||
|
|
||||||
Личные сообщения в SHiNE сейчас работают как пара **подписанных клиентом блоков** в формате `SHiNE_dm2`:
|
|
||||||
|
|
||||||
- тип `1` — входящее сообщение для собеседника;
|
|
||||||
- тип `2` — исходящая копия того же сообщения для автора.
|
|
||||||
|
|
||||||
Оба блока отправляются вместе одной операцией (`SendMessagePair` / `ReceiveOutcomingMessage`) и либо сохраняются оба, либо не сохраняются вовсе.
|
|
||||||
Дальше сервер доставляет их по активным сессиям целевого логина событием `SignedMessageArrived`, а клиент подтверждает доставку на конкретную сессию через `AckSessionDelivery`.
|
|
||||||
|
|
||||||
Подтверждение прочтения также идёт парой блоков:
|
|
||||||
|
|
||||||
- тип `3` — «прочитано» для исходящего сообщения автора;
|
|
||||||
- тип `4` — зеркальная копия для второй стороны.
|
|
||||||
|
|
||||||
UI чата строится на этих типах: текстовые сообщения (1/2), read-receipt (3/4), непрочитанные, галочки и история.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Подробно
|
|
||||||
|
|
||||||
## 1) Общая схема потока
|
|
||||||
|
|
||||||
1. Клиент формирует текст сообщения и строит **2 подписанных блока** (`type=1` и `type=2`) с одинаковыми `fromLogin/toLogin/timeMs/nonce`.
|
|
||||||
2. Клиент отправляет оба блока в одном RPC: `SendMessagePair` (алиас: `ReceiveOutcomingMessage`).
|
|
||||||
3. Сервер:
|
|
||||||
- парсит оба блока;
|
|
||||||
- валидирует пару;
|
|
||||||
- проверяет существование `from/to` пользователей и подписи;
|
|
||||||
- атомарно сохраняет пару в `signed_messages_v2`.
|
|
||||||
4. Сервер доставляет блоки в активные сессии целевого логина событием `SignedMessageArrived`.
|
|
||||||
5. Клиент, получив событие, кладёт сообщение в локальный чат и отправляет `AckSessionDelivery(messageKey)`.
|
|
||||||
6. При открытии чата клиент отправляет read-receipt (пара `type=3/4`) для непрочитанных входящих.
|
|
||||||
|
|
||||||
## 2) Формат signed DM-блока (`SHiNE_dm2`)
|
|
||||||
|
|
||||||
Префикс: `SHiNE_dm2` (ASCII).
|
|
||||||
|
|
||||||
Далее поля (big-endian):
|
|
||||||
|
|
||||||
1. `toLoginLen` (`u8`) + `toLogin` (ASCII, 1..60);
|
|
||||||
2. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, 1..60);
|
|
||||||
3. `timeMs` (`u64`);
|
|
||||||
4. `nonce` (`u32`);
|
|
||||||
5. `messageType` (`u16`);
|
|
||||||
6. `payloadLen` (`u16`);
|
|
||||||
7. `payloadBytes` (`1..4096`);
|
|
||||||
8. `signature` (`64 bytes`, Ed25519).
|
|
||||||
|
|
||||||
Ограничения:
|
|
||||||
|
|
||||||
- полный пакет: до `8192` байт;
|
|
||||||
- `messageType` сейчас допустим только `1..4`.
|
|
||||||
|
|
||||||
## 3) Типы DM-сообщений
|
|
||||||
|
|
||||||
- `1` (`TYPE_INCOMING_TEXT`) — входящий текст для получателя.
|
|
||||||
- `2` (`TYPE_OUTGOING_COPY`) — исходящая копия в истории автора.
|
|
||||||
- `3` (`TYPE_READ_INCOMING`) — read-receipt (входящий тип для пары квитанции).
|
|
||||||
- `4` (`TYPE_READ_OUTGOING_COPY`) — зеркальная копия read-receipt.
|
|
||||||
|
|
||||||
Правило пары:
|
|
||||||
|
|
||||||
- первый блок должен быть нечётным (`1` или `3`);
|
|
||||||
- второй должен быть ровно `+1` (`2` или `4`);
|
|
||||||
- ключевые поля пары совпадают: `toLogin/fromLogin/timeMs/nonce`.
|
|
||||||
|
|
||||||
## 4) Ключи сообщений
|
|
||||||
|
|
||||||
- `baseKey = from|to|timeMs|nonce`
|
|
||||||
- `messageKey = baseKey|messageType`
|
|
||||||
|
|
||||||
Эти ключи используются:
|
|
||||||
|
|
||||||
- для дедупликации;
|
|
||||||
- для связи read-receipt с исходным сообщением;
|
|
||||||
- для ACK доставки по сессии.
|
|
||||||
|
|
||||||
## 5) RPC и события
|
|
||||||
|
|
||||||
## `SendMessagePair` (алиас `ReceiveOutcomingMessage`)
|
|
||||||
|
|
||||||
Запрос:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "SendMessagePair",
|
|
||||||
"requestId": "req-1",
|
|
||||||
"payload": {
|
|
||||||
"incomingBlobB64": "<base64 signed block type 1 or 3>",
|
|
||||||
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успешный ответ:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "SendMessagePair",
|
|
||||||
"requestId": "req-1",
|
|
||||||
"status": 200,
|
|
||||||
"ok": true,
|
|
||||||
"payload": {
|
|
||||||
"baseKey": "from|to|time|nonce",
|
|
||||||
"incomingKey": "from|to|time|nonce|1",
|
|
||||||
"outgoingKey": "from|to|time|nonce|2",
|
|
||||||
"deliveredWsSessions": 2,
|
|
||||||
"deliveredWebPushSessions": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `SignedMessageArrived` (server event)
|
|
||||||
|
|
||||||
Событие в сессию получателя содержит:
|
|
||||||
|
|
||||||
- `messageKey`, `baseKey`;
|
|
||||||
- `fromLogin`, `toLogin`, `targetLogin`;
|
|
||||||
- `messageType`, `timeMs`, `nonce`;
|
|
||||||
- `blobB64`;
|
|
||||||
- `backlog` (признак догрузки из очереди).
|
|
||||||
|
|
||||||
## `AckSessionDelivery`
|
|
||||||
|
|
||||||
Запрос:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"op": "AckSessionDelivery",
|
|
||||||
"requestId": "ack-1",
|
|
||||||
"payload": {
|
|
||||||
"messageKey": "from|to|time|nonce|1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ответ: `status=200`, echo `messageKey`.
|
|
||||||
|
|
||||||
## 6) Хранение на сервере (SQLite)
|
|
||||||
|
|
||||||
Основные таблицы:
|
|
||||||
|
|
||||||
1. `signed_messages_v2` — сами DM-блоки типов `1/2/3/4`:
|
|
||||||
- `message_key` (PK),
|
|
||||||
- `base_key`,
|
|
||||||
- `target_login`,
|
|
||||||
- `from_login`, `to_login`,
|
|
||||||
- `time_ms`, `nonce`, `message_type`,
|
|
||||||
- `raw_block`,
|
|
||||||
- `source_api`, `origin_session_id`,
|
|
||||||
- `receipt_ref_base_key`, `receipt_ref_type`.
|
|
||||||
2. `signed_message_session_delivery` — доставка по сессиям:
|
|
||||||
- составной PK `(message_key, session_id)`,
|
|
||||||
- `delivered` (0/1),
|
|
||||||
- `delivered_at_ms`, `created_at_ms`.
|
|
||||||
|
|
||||||
Примечание: историческая таблица `signed_direct_messages_history` в БД присутствует как legacy-слой, но текущий рабочий поток DM v2 опирается на `signed_messages_v2` + `signed_message_session_delivery`.
|
|
||||||
|
|
||||||
## 7) Доставка и backlog
|
|
||||||
|
|
||||||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
|
||||||
- Для офлайн/недоступных сессий остаётся pending-запись доставки.
|
|
||||||
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`.
|
|
||||||
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`.
|
|
||||||
|
|
||||||
## 8) Read-receipt логика
|
|
||||||
|
|
||||||
Когда клиент открывает чат:
|
|
||||||
|
|
||||||
1. ищет входящие `messageType=1` без `readReceiptSent`;
|
|
||||||
2. для каждого отправляет read-receipt как пару `type=3/4`;
|
|
||||||
3. после успешной отправки помечает `readReceiptSent`.
|
|
||||||
|
|
||||||
Сервер для read-receipt хранит ссылку на исходное сообщение:
|
|
||||||
|
|
||||||
- `receipt_ref_base_key`;
|
|
||||||
- `receipt_ref_type`.
|
|
||||||
|
|
||||||
Есть уникальность, чтобы не плодить дубликаты receipt на один и тот же `baseKey` для одного `target_login`.
|
|
||||||
|
|
||||||
## 9) Логика UI-клиента
|
|
||||||
|
|
||||||
В UI:
|
|
||||||
|
|
||||||
- чат хранится в `state.chats[chatId]`;
|
|
||||||
- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`;
|
|
||||||
- непрочитанные считаются по `from='in' && unread=true`;
|
|
||||||
- доставка/прочтение исходящих:
|
|
||||||
- `firstTick` — сообщение принято в парный поток,
|
|
||||||
- `secondTick` — пришло подтверждение прочтения;
|
|
||||||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
|
||||||
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости;
|
|
||||||
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
|
|
||||||
|
|
||||||
## 10) Инварианты (обязательно соблюдать при доработках)
|
|
||||||
|
|
||||||
1. Пара блоков (1/2 или 3/4) должна оставаться атомарной.
|
|
||||||
2. `messageKey`/`baseKey` формат должен быть совместим с текущей логикой дедупликации и receipt.
|
|
||||||
3. Доставка должна оставаться **по сессиям** с явным `AckSessionDelivery`.
|
|
||||||
4. Read-receipt не должен отправляться многократно на один и тот же `baseKey`.
|
|
||||||
5. Любые изменения DM-логики в коде должны сразу отражаться в этом документе.
|
|
||||||
|
|
||||||
## 11) Ключевые файлы реализации
|
|
||||||
|
|
||||||
- UI:
|
|
||||||
- `shine-UI/js/services/auth-service.js`
|
|
||||||
- `shine-UI/js/app.js`
|
|
||||||
- `shine-UI/js/state.js`
|
|
||||||
- `shine-UI/js/pages/chat-view.js`
|
|
||||||
- Сервер:
|
|
||||||
- `shine-server-net-protocol/.../messages/SignedMessageBlock.java`
|
|
||||||
- `shine-server-net-protocol/.../messages/SignedMessagesCore.java`
|
|
||||||
- `shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.java`
|
|
||||||
- `shine-server-net-protocol/.../messages/SignedMessagesRealtime.java`
|
|
||||||
- `shine-server-net-protocol/.../messages/Net_AckSessionDelivery_Handler.java`
|
|
||||||
- БД:
|
|
||||||
- `shine-server-db/src/main/java/shine/db/DatabaseInitializer.java`
|
|
||||||
- `shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java`
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# TODO: доработка персональных сообщений для агентов
|
|
||||||
|
|
||||||
Статус: отложено.
|
|
||||||
|
|
||||||
## Что хотели сделать
|
|
||||||
|
|
||||||
Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы:
|
|
||||||
|
|
||||||
- отличать сообщения человеку от сообщений агенту;
|
|
||||||
- отличать сообщения от человека и от агента;
|
|
||||||
- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`);
|
|
||||||
- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина.
|
|
||||||
|
|
||||||
## Базовая идея формата (черновик)
|
|
||||||
|
|
||||||
Пример префикса:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"}
|
|
||||||
Текст сообщения...
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример ответа агента:
|
|
||||||
|
|
||||||
```text
|
|
||||||
@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"}
|
|
||||||
Ответ агента...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Почему отложено
|
|
||||||
|
|
||||||
- нужно отдельно согласовать финальный формат инструкции;
|
|
||||||
- нужно определить строгие правила UI-фильтрации и fallback;
|
|
||||||
- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий.
|
|
||||||
|
|
||||||
## Что сделать при возвращении к задаче
|
|
||||||
|
|
||||||
1. Зафиксировать окончательный формат префикса и JSON-полей.
|
|
||||||
2. Описать правила парсинга/валидации (включая битые/неполные префиксы).
|
|
||||||
3. Добавить UI-логику показа/скрытия agent-сообщений.
|
|
||||||
4. Добавить маркировку «ответ агента» в диалоге.
|
|
||||||
5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина.
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Деплой SHiNE (шаблон)
|
|
||||||
|
|
||||||
Этот раздел хранит актуальные инструкции по деплою.
|
|
||||||
|
|
||||||
## Базовый сервер
|
|
||||||
|
|
||||||
- SSH: `player@45.136.124.227`
|
|
||||||
- Домен: `shineup.me`
|
|
||||||
- Базовый путь: `/home/player`
|
|
||||||
|
|
||||||
## Локальные команды
|
|
||||||
|
|
||||||
- Деплой сервера: `./gradlew deployServer`
|
|
||||||
- Деплой UI: `./gradlew deployUI`
|
|
||||||
- Локальный запуск: `./gradlew startLocal`
|
|
||||||
|
|
||||||
## UI-деплой и Caddy (обязательно)
|
|
||||||
|
|
||||||
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
|
|
||||||
- `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`.
|
|
||||||
- В `deploy_shine-PWA.sh` добавлена проверка: если `root` в `Caddyfile` не совпадает, деплой прерывается с ошибкой.
|
|
||||||
- Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`.
|
|
||||||
- При необходимости можно явно переопределить путь деплоя:
|
|
||||||
- `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI`
|
|
||||||
- `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI`
|
|
||||||
|
|
||||||
### Важно для локального UI (history-router / Ctrl+F5)
|
|
||||||
|
|
||||||
- Локальный UI **обязательно** поднимать только через `./gradlew startLocal`.
|
|
||||||
- Эта задача запускает `scripts/local_spa_server.py`, который делает SPA fallback: любой неизвестный путь (`/m/...`, `/channel/...`) возвращает `index.html`.
|
|
||||||
- Это обязательно для корректной работы `Ctrl+F5` на внутренних роутов без `404`.
|
|
||||||
- Рабочий URL выводится задачей в консоль в формате: `http://localhost:<WEB_PORT>/?localWsPort=<WS_PORT>`.
|
|
||||||
|
|
||||||
## Обязательные правила
|
|
||||||
|
|
||||||
1. Перед серверным деплоем проверить локально.
|
|
||||||
2. При нестандартном деплое (другой хост, другая структура, ручные шаги) обязательно уточнить у пользователя, нужно ли обновить этот шаблон.
|
|
||||||
3. Если деплой-процесс изменился, этот файл и файлы в `servers/` обновлять в том же коммите.
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Сервер `45.136.124.227` (`shineup.me`) — основной
|
|
||||||
|
|
||||||
- Пользователь: `player`
|
|
||||||
- Базовый путь: `/home/player`
|
|
||||||
- Каталог SHiNE: `/home/player/SHiNE`
|
|
||||||
- UI публикация: `/home/player/SHiNE/shine-ui`
|
|
||||||
- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
|
|
||||||
- Данные: `/home/player/SHiNE/shine-server/data/`
|
|
||||||
- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
|
|
||||||
|
|
||||||
## Сервисы
|
|
||||||
|
|
||||||
- `shine-server.service` (systemd)
|
|
||||||
- `caddy.service` (systemd)
|
|
||||||
|
|
||||||
## Caddy
|
|
||||||
|
|
||||||
- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile`
|
|
||||||
- Для UI:
|
|
||||||
- `root * /home/player/SHiNE/shine-ui`
|
|
||||||
- `try_files {path} /index.html` (SPA fallback)
|
|
||||||
- no-cache заголовки
|
|
||||||
- `reverse_proxy /ws* -> 127.0.0.1:7070`
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# Сервер `93.170.12.154` — резервный
|
|
||||||
|
|
||||||
- Пользователь: `player`
|
|
||||||
- Каталог SHiNE: `/home/player/SHiNE`
|
|
||||||
- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI`
|
|
||||||
- UI публикация для Caddy: `/var/www/shine-ui`
|
|
||||||
- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar`
|
|
||||||
- Данные: `/home/player/SHiNE/SHiNE-server/data/`
|
|
||||||
- `shine.sqlite`
|
|
||||||
- `*.bch`
|
|
||||||
- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log`
|
|
||||||
|
|
||||||
## Сервисы
|
|
||||||
|
|
||||||
- `shine-server.service` (systemd)
|
|
||||||
- `caddy.service` (systemd)
|
|
||||||
|
|
||||||
## Статус
|
|
||||||
|
|
||||||
- Резервный сервер для SHiNE.
|
|
||||||
- Основной прод-сервер: `45.136.124.227` (`shineup.me`).
|
|
||||||
|
|
||||||
## Caddy
|
|
||||||
|
|
||||||
- Конфиг: `/etc/caddy/Caddyfile`
|
|
||||||
- Настройки:
|
|
||||||
- `no-store/no-cache` заголовки;
|
|
||||||
- `try_files {path} /index.html` (SPA fallback);
|
|
||||||
- `reverse_proxy /ws* -> 127.0.0.1:7070`.
|
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.80
|
client.version=1.2.57
|
||||||
server.version=1.2.74
|
server.version=1.2.51
|
||||||
|
|||||||
64
build.gradle
64
build.gradle
@ -182,21 +182,66 @@ tasks.register('deployServer', JavaExec) {
|
|||||||
// можно переопределить при запуске:
|
// можно переопределить при запуске:
|
||||||
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||||
dependsOn shadowJar
|
dependsOn shadowJar
|
||||||
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227")
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
||||||
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
|
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
||||||
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server")
|
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
||||||
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
||||||
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||||
|
|
||||||
dependsOn testClasses
|
dependsOn testClasses
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register('deployUI', Exec) {
|
tasks.register('deployServerWithBackupCleanAndTests') {
|
||||||
group = "!!deployment"
|
group = "!!deployment"
|
||||||
description = "Deploy WEB UI (production: shineup.me)"
|
description = "BLOCKED: удаление БД на проде запрещено, используйте только миграции"
|
||||||
workingDir = rootDir
|
|
||||||
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
|
doLast {
|
||||||
|
def msg = """
|
||||||
|
[BLOCKED] Удаление базы данных на продакшен-сервере отключено.
|
||||||
|
Причина: в базе уже есть пользовательские сообщения.
|
||||||
|
Дальше используйте только миграции схемы БД.
|
||||||
|
Задача остановлена намеренно.
|
||||||
|
""".stripIndent().trim()
|
||||||
|
println msg
|
||||||
|
throw new GradleException(msg)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('deployServerNoCleanNoTests', JavaExec) {
|
||||||
|
group = "!!deployment"
|
||||||
|
description = "Build → upload to server → restart service (no data clean, no IT tests)"
|
||||||
|
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain"
|
||||||
|
|
||||||
|
// можно переопределить при запуске:
|
||||||
|
// ./gradlew deployServerNoCleanNoTests -Dit.remoteHost=...
|
||||||
|
dependsOn shadowJar
|
||||||
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
||||||
|
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
||||||
|
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
||||||
|
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
||||||
|
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||||
|
|
||||||
|
dependsOn testClasses
|
||||||
|
}
|
||||||
|
|
||||||
|
def registerWebDeployTask = { String taskName, String target, String descriptionText ->
|
||||||
|
tasks.register(taskName, Exec) {
|
||||||
|
group = "!!deployment"
|
||||||
|
description = descriptionText
|
||||||
|
workingDir = rootDir
|
||||||
|
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath, target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerWebDeployTask('deployWEB_Production', 'prod', 'Deploy WEB (production: shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_ui_1', 'ui_1', 'Deploy WEB (ui-1.shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_ui_2', 'ui_2', 'Deploy WEB (ui-2.shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_ui_3', 'ui_3', 'Deploy WEB (ui-3.shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_DrygMira', 'ui_drygmira', 'Deploy WEB (ui-drygmira.shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_Milana', 'ui_milana', 'Deploy WEB (ui-milana.shineup.me)')
|
||||||
|
registerWebDeployTask('deployWEB_Aidar', 'ui_aidar', 'Deploy WEB (ui-aidar.shineup.me)')
|
||||||
|
|
||||||
tasks.register('startLocal', Exec) {
|
tasks.register('startLocal', Exec) {
|
||||||
group = "!!run"
|
group = "!!run"
|
||||||
@ -258,11 +303,10 @@ tasks.register('startLocal', Exec) {
|
|||||||
echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL"
|
echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SPA_SERVER_SCRIPT="${file('scripts/local_spa_server.py').absolutePath}"
|
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
SHINE_UI_PORT="\$WEB_PORT" python3 "\$SPA_SERVER_SCRIPT"
|
(cd "\$UI_DIR" && python3 -m http.server "\$WEB_PORT")
|
||||||
else
|
else
|
||||||
SHINE_UI_PORT="\$WEB_PORT" python "\$SPA_SERVER_SCRIPT"
|
(cd "\$UI_DIR" && python -m http.server "\$WEB_PORT")
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SRC_DIR="shine-UI"
|
SRC_DIR="shine-UI"
|
||||||
REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}"
|
REMOTE_HOST="${REMOTE_HOST:-player@shineup.me}"
|
||||||
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
REMOTE_BASE_DIR="${REMOTE_BASE_DIR:-/home/player/SHiNE}"
|
||||||
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}"
|
|
||||||
ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}"
|
|
||||||
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
|
BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)"
|
||||||
VERSION_FILE="VERSION.properties"
|
VERSION_FILE="VERSION.properties"
|
||||||
export BUILD_VERSION
|
export BUILD_VERSION
|
||||||
TMP_DIR="$(mktemp -d)"
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
TARGET="${1:-prod}"
|
||||||
|
|
||||||
if [[ ! -f "$VERSION_FILE" ]]; then
|
if [[ ! -f "$VERSION_FILE" ]]; then
|
||||||
echo "ERROR: version file not found: $VERSION_FILE" >&2
|
echo "ERROR: version file not found: $VERSION_FILE" >&2
|
||||||
@ -23,8 +22,44 @@ if [[ -z "$CLIENT_VERSION" ]]; then
|
|||||||
fi
|
fi
|
||||||
export CLIENT_VERSION
|
export CLIENT_VERSION
|
||||||
|
|
||||||
|
TARGET_DIR="shine-UI"
|
||||||
TARGET_URL="https://shineup.me"
|
TARGET_URL="https://shineup.me"
|
||||||
REMOTE_DIR="${REMOTE_UI_DIR}"
|
case "$TARGET" in
|
||||||
|
prod|production|main|shineup|shineup.me|shine-UI)
|
||||||
|
TARGET_DIR="shine-UI"
|
||||||
|
TARGET_URL="https://shineup.me"
|
||||||
|
;;
|
||||||
|
ui_1|ui-1|1|shine-UI_1)
|
||||||
|
TARGET_DIR="test-UI/shine-UI_1"
|
||||||
|
TARGET_URL="https://ui-1.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_2|ui-2|2|shine-UI_2)
|
||||||
|
TARGET_DIR="test-UI/shine-UI_2"
|
||||||
|
TARGET_URL="https://ui-2.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_3|ui-3|3|shine-UI_3)
|
||||||
|
TARGET_DIR="test-UI/shine-UI_3"
|
||||||
|
TARGET_URL="https://ui-3.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_drygmira|ui-drygmira|drygmira|shine-UI_drygmira)
|
||||||
|
TARGET_DIR="test-UI/shine-UI_drygmira"
|
||||||
|
TARGET_URL="https://ui-drygmira.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_milana|ui-milana|milana|shine-UI_milana)
|
||||||
|
TARGET_DIR="test-UI/shine-UI_milana"
|
||||||
|
TARGET_URL="https://ui-milana.shineup.me"
|
||||||
|
;;
|
||||||
|
ui_aidar|ui-aidar|aidar|shine-UI_aidar)
|
||||||
|
TARGET_DIR="test-UI/shine-UI_aidar"
|
||||||
|
TARGET_URL="https://ui-aidar.shineup.me"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: unknown target '$TARGET'" >&2
|
||||||
|
echo "Available targets: prod, ui_1, ui_2, ui_3, ui_drygmira, ui_milana, ui_aidar" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
REMOTE_DIR="${REMOTE_BASE_DIR}/${TARGET_DIR}"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$TMP_DIR"
|
rm -rf "$TMP_DIR"
|
||||||
@ -38,7 +73,7 @@ fi
|
|||||||
|
|
||||||
echo "==> Preparing staged UI copy with build version: $BUILD_VERSION"
|
echo "==> Preparing staged UI copy with build version: $BUILD_VERSION"
|
||||||
echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION"
|
echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION"
|
||||||
echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)"
|
echo "==> Deploy target: $TARGET_URL ($TARGET_DIR)"
|
||||||
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
rsync -a "$SRC_DIR"/ "$TMP_DIR"/
|
||||||
|
|
||||||
INDEX_FILE="$TMP_DIR/index.html"
|
INDEX_FILE="$TMP_DIR/index.html"
|
||||||
@ -53,30 +88,10 @@ perl -0pi -e 's/window\.__SHINE_CLIENT_VERSION__\s*=\s*'\''[^'\'']*'\'';/window.
|
|||||||
echo "==> Checking SSH connectivity to $REMOTE_HOST"
|
echo "==> Checking SSH connectivity to $REMOTE_HOST"
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null
|
||||||
|
|
||||||
echo "==> Validating Caddy UI root"
|
|
||||||
CADDY_CONFIG_PATH="$(ssh "$REMOTE_HOST" "set -e; \
|
|
||||||
exec_line=\$(systemctl show -p ExecStart caddy --value 2>/dev/null || true); \
|
|
||||||
cfg=\$(printf '%s' \"\$exec_line\" | sed -n 's/.*--config \\([^ ]*\\).*/\\1/p' | head -n 1); \
|
|
||||||
if [ -z \"\$cfg\" ]; then cfg=/etc/caddy/Caddyfile; fi; \
|
|
||||||
printf '%s' \"\$cfg\"")"
|
|
||||||
CADDY_ROOT_LINE="$(ssh "$REMOTE_HOST" "sudo grep -n 'root \\* ' '$CADDY_CONFIG_PATH' | head -n 1 || true")"
|
|
||||||
if [[ -n "$CADDY_ROOT_LINE" && "$CADDY_ROOT_LINE" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then
|
|
||||||
echo "ERROR: Caddy root mismatch. Found: $CADDY_ROOT_LINE" >&2
|
|
||||||
echo "Caddy config: $CADDY_CONFIG_PATH" >&2
|
|
||||||
echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2
|
|
||||||
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then
|
|
||||||
echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Preparing remote directory: $REMOTE_DIR"
|
echo "==> Preparing remote directory: $REMOTE_DIR"
|
||||||
ssh "$REMOTE_HOST" "sudo mkdir -p '$REMOTE_DIR'"
|
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
|
||||||
|
|
||||||
echo "==> Syncing staged files to $REMOTE_DIR"
|
echo "==> Syncing staged files to $REMOTE_DIR"
|
||||||
rsync -rlvz --delete --omit-dir-times --no-perms --no-owner --no-group \
|
rsync -avz --delete "$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
||||||
--rsync-path="sudo rsync" \
|
|
||||||
"$TMP_DIR"/ "$REMOTE_HOST":"$REMOTE_DIR"/
|
|
||||||
|
|
||||||
echo "Всё хорошо: $TARGET_URL"
|
echo "Всё хорошо: $TARGET_URL"
|
||||||
|
|||||||
@ -1,19 +1,38 @@
|
|||||||
# UI deploy
|
# Деплой UI по окружениям (Caddy sites)
|
||||||
|
|
||||||
Актуальный UI-деплой выполняется одной командой:
|
## Куда деплоит скрипт
|
||||||
|
- Базовая директория на сервере: `/home/user/docker/caddyFile/sites`
|
||||||
|
- По умолчанию деплой идёт на production (`shineup.me`) в папку `shine-UI`.
|
||||||
|
|
||||||
```bash
|
## Gradle-команды
|
||||||
./gradlew deployUI
|
- Продакшен (`shineup.me`): `./gradlew deployWEB_Production`
|
||||||
```
|
- `ui-1.shineup.me`: `./gradlew deployWEB_ui_1`
|
||||||
|
- `ui-2.shineup.me`: `./gradlew deployWEB_ui_2`
|
||||||
|
- `ui-3.shineup.me`: `./gradlew deployWEB_ui_3`
|
||||||
|
- `ui-drygmira.shineup.me`: `./gradlew deployWEB_DrygMira`
|
||||||
|
- `ui-milana.shineup.me`: `./gradlew deployWEB_Milana`
|
||||||
|
- `ui-aidar.shineup.me`: `./gradlew deployWEB_Aidar`
|
||||||
|
|
||||||
По умолчанию:
|
## Прямой запуск скрипта
|
||||||
|
- `bash deploy_shine-PWA.sh prod`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_1`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_2`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_3`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_drygmira`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_milana`
|
||||||
|
- `bash deploy_shine-PWA.sh ui_aidar`
|
||||||
|
|
||||||
- хост: `player@93.170.12.154`
|
Также поддерживаются алиасы с дефисом:
|
||||||
- домен: `https://shineup.me`
|
- `bash deploy_shine-PWA.sh ui-1`
|
||||||
- путь: `/home/player/SHiNE/SHiNE-UI`
|
- `bash deploy_shine-PWA.sh ui-2`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-3`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-drygmira`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-milana`
|
||||||
|
- `bash deploy_shine-PWA.sh ui-aidar`
|
||||||
|
|
||||||
Переопределение при необходимости:
|
## Поддержка переопределения
|
||||||
|
- `REMOTE_HOST` (по умолчанию `user@194.87.0.247`)
|
||||||
|
- `REMOTE_BASE_DIR` (по умолчанию `/home/user/docker/caddyFile/sites`)
|
||||||
|
|
||||||
```bash
|
Пример:
|
||||||
REMOTE_HOST=player@93.170.12.154 REMOTE_BASE_DIR=/home/player/SHiNE bash deploy_shine-PWA.sh
|
`REMOTE_HOST=user@194.87.0.247 REMOTE_BASE_DIR=/home/user/docker/caddyFile/sites bash deploy_shine-PWA.sh ui_2`
|
||||||
```
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1] / "shine-UI"
|
|
||||||
PORT = int(os.environ.get("SHINE_UI_PORT", "8088"))
|
|
||||||
|
|
||||||
|
|
||||||
class SpaHandler(SimpleHTTPRequestHandler):
|
|
||||||
def translate_path(self, path):
|
|
||||||
translated = super().translate_path(path)
|
|
||||||
rel = Path(translated).relative_to(Path.cwd())
|
|
||||||
return str(ROOT / rel)
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
file_path = Path(self.translate_path(self.path.split("?", 1)[0]))
|
|
||||||
if file_path.exists() and file_path.is_file():
|
|
||||||
return super().do_GET()
|
|
||||||
self.path = "/index.html"
|
|
||||||
return super().do_GET()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
os.chdir(ROOT)
|
|
||||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), SpaHandler)
|
|
||||||
print(f"SHiNE SPA server: http://localhost:{PORT}")
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -101,7 +101,7 @@ const routes = {
|
|||||||
'messages-list': messagesList,
|
'messages-list': messagesList,
|
||||||
'contact-search-view': contactSearchView,
|
'contact-search-view': contactSearchView,
|
||||||
'chat-view': chatView,
|
'chat-view': chatView,
|
||||||
user: userProfileView,
|
'user-profile-view': userProfileView,
|
||||||
'channels-list': channelsList,
|
'channels-list': channelsList,
|
||||||
'channel-view': channelView,
|
'channel-view': channelView,
|
||||||
'channel-thread-view': channelThreadView,
|
'channel-thread-view': channelThreadView,
|
||||||
@ -136,16 +136,6 @@ let pwaUpdateCheckAttempted = false;
|
|||||||
let uiVersionCheckInFlight = false;
|
let uiVersionCheckInFlight = false;
|
||||||
let uiVersionPeriodicIntervalId = null;
|
let uiVersionPeriodicIntervalId = null;
|
||||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
const GUEST_ALLOWED_PAGES = new Set([
|
|
||||||
'start-view',
|
|
||||||
'entry-settings-view',
|
|
||||||
'network-view',
|
|
||||||
'channels-list',
|
|
||||||
'channel-view',
|
|
||||||
'channel-thread-view',
|
|
||||||
'user',
|
|
||||||
'contact-search-view',
|
|
||||||
]);
|
|
||||||
|
|
||||||
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
||||||
setClientErrorSentNotifier((payload) => {
|
setClientErrorSentNotifier((payload) => {
|
||||||
@ -299,7 +289,7 @@ function consumeCallPushActionFromUrlIfAny() {
|
|||||||
params.delete('callPushAction');
|
params.delete('callPushAction');
|
||||||
params.delete('callPushPayload');
|
params.delete('callPushPayload');
|
||||||
const nextQuery = params.toString();
|
const nextQuery = params.toString();
|
||||||
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`;
|
const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}${window.location.hash || ''}`;
|
||||||
window.history.replaceState({}, '', nextUrl);
|
window.history.replaceState({}, '', nextUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore URL parsing errors
|
// ignore URL parsing errors
|
||||||
@ -644,7 +634,7 @@ function renderPageFailureFallback(pageId, error) {
|
|||||||
stack: error?.stack || '',
|
stack: error?.stack || '',
|
||||||
context: {
|
context: {
|
||||||
pageId,
|
pageId,
|
||||||
routeHash: window.location.pathname || '',
|
routeHash: window.location.hash || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -681,7 +671,7 @@ function renderApp() {
|
|||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
|
|
||||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) {
|
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1035,26 +1025,19 @@ async function init() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"),
|
|
||||||
// а сетевые/авторизационные шаги выполняем фоном.
|
|
||||||
if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
|
||||||
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
|
|
||||||
}
|
|
||||||
renderApp();
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await tryAutoLogin();
|
await tryAutoLogin();
|
||||||
await hydrateMessagesFromStore();
|
await hydrateMessagesFromStore();
|
||||||
startConnectionMonitor();
|
startConnectionMonitor();
|
||||||
startPeriodicUiVersionCheck();
|
startPeriodicUiVersionCheck();
|
||||||
await ensureSessionRuntimeStarted();
|
await ensureSessionRuntimeStarted();
|
||||||
} finally {
|
|
||||||
|
if (!window.location.hash) {
|
||||||
|
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
|
} else {
|
||||||
renderApp();
|
renderApp();
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
window.addEventListener('popstate', renderApp);
|
window.addEventListener('hashchange', renderApp);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState !== 'visible') return;
|
if (document.visibilityState !== 'visible') return;
|
||||||
void checkConnectionHealth();
|
void checkConnectionHealth();
|
||||||
|
|||||||
@ -19,39 +19,35 @@ function showSttMissingConfigDialog(navigate) {
|
|||||||
if (goSettings) navigate('tools-settings-view');
|
if (goSettings) navigate('tools-settings-view');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSpeechInputModal({ navigate, onTextReady, onSendText, onSendQueued }) {
|
export async function openSpeechInputModal({ navigate, onTextReady }) {
|
||||||
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
if (!isSpeechToTextConfigured(state.entrySettings)) {
|
||||||
showSttMissingConfigDialog(navigate);
|
showSttMissingConfigDialog(navigate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
const host = document.createElement('div');
|
root.innerHTML = `
|
||||||
host.innerHTML = `
|
<div class="modal" id="speech-input-modal">
|
||||||
<div class="modal" id="speech-input-modal-layer">
|
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">Голосовой ввод</h3>
|
<h3 class="modal-title">Голосовой ввод</h3>
|
||||||
<p class="meta-muted" id="speech-input-status">Идёт запись...</p>
|
<p class="meta-muted" id="speech-input-status">Идёт запись...</p>
|
||||||
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
|
<div class="voice-level-wrap"><div class="voice-level-fill" id="speech-level-fill"></div></div>
|
||||||
<p class="meta-muted" id="speech-input-time">00:00</p>
|
<p class="meta-muted" id="speech-input-time">00:00</p>
|
||||||
<p class="inline-error" id="speech-input-error"></p>
|
<p class="inline-error" id="speech-input-error"></p>
|
||||||
<div class="speech-actions-top">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
|
<button class="secondary-btn" type="button" id="speech-cancel">Отмена</button>
|
||||||
<button class="primary-btn" type="button" id="speech-ok">OK</button>
|
<button class="primary-btn" type="button" id="speech-ok">OK</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary-btn speech-send-now-btn" type="button" id="speech-send-now">Распознать и сразу отправить сообщение</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
root.append(host);
|
|
||||||
|
|
||||||
const statusEl = host.querySelector('#speech-input-status');
|
const statusEl = root.querySelector('#speech-input-status');
|
||||||
const timeEl = host.querySelector('#speech-input-time');
|
const timeEl = root.querySelector('#speech-input-time');
|
||||||
const levelEl = host.querySelector('#speech-level-fill');
|
const levelEl = root.querySelector('#speech-level-fill');
|
||||||
const errorEl = host.querySelector('#speech-input-error');
|
const errorEl = root.querySelector('#speech-input-error');
|
||||||
const cancelBtn = host.querySelector('#speech-cancel');
|
const cancelBtn = root.querySelector('#speech-cancel');
|
||||||
const sendNowBtn = host.querySelector('#speech-send-now');
|
const okBtn = root.querySelector('#speech-ok');
|
||||||
const okBtn = host.querySelector('#speech-ok');
|
|
||||||
const recorder = createMicrophoneRecorder();
|
const recorder = createMicrophoneRecorder();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
@ -59,16 +55,14 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
|
|||||||
const close = () => {
|
const close = () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
host.remove();
|
root.innerHTML = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBusy = (flag) => {
|
const setBusy = (flag) => {
|
||||||
busy = !!flag;
|
busy = !!flag;
|
||||||
cancelBtn.disabled = busy;
|
cancelBtn.disabled = busy;
|
||||||
sendNowBtn.disabled = busy;
|
|
||||||
okBtn.disabled = busy;
|
okBtn.disabled = busy;
|
||||||
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
okBtn.textContent = busy ? 'Распознаю...' : 'OK';
|
||||||
sendNowBtn.textContent = busy ? 'Распознаю...' : 'Распознать и сразу отправить сообщение';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -90,16 +84,10 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
|
|||||||
okBtn.addEventListener('click', async () => {
|
okBtn.addEventListener('click', async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
errorEl.textContent = '';
|
||||||
|
statusEl.textContent = 'Распознаю речь...';
|
||||||
try {
|
try {
|
||||||
const audioBlob = await recorder.stop();
|
const audioBlob = await recorder.stop();
|
||||||
host.innerHTML = `
|
|
||||||
<div class="modal" id="speech-input-modal-layer">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">Голосовой ввод</h3>
|
|
||||||
<p class="meta-muted">Идёт распознавание текста...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
||||||
if (typeof onTextReady === 'function') onTextReady(text);
|
if (typeof onTextReady === 'function') onTextReady(text);
|
||||||
close();
|
close();
|
||||||
@ -109,24 +97,4 @@ export async function openSpeechInputModal({ navigate, onTextReady, onSendText,
|
|||||||
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
|
errorEl.textContent = `Ошибка распознавания: ${error?.message || 'unknown'}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sendNowBtn.addEventListener('click', async () => {
|
|
||||||
if (busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const audioBlob = await recorder.stop();
|
|
||||||
close();
|
|
||||||
if (typeof onSendQueued === 'function') onSendQueued();
|
|
||||||
const text = await transcribeAudioBySettings(audioBlob, state.entrySettings);
|
|
||||||
if (typeof onSendText === 'function') {
|
|
||||||
await onSendText(text);
|
|
||||||
} else if (typeof onTextReady === 'function') {
|
|
||||||
onTextReady(text);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setBusy(false);
|
|
||||||
close();
|
|
||||||
window.alert(`Ошибка распознавания: ${error?.message || 'unknown'}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
||||||
@ -28,35 +27,6 @@ function getTotalUnreadMessages() {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateWithGuestRules(pageId, navigate) {
|
|
||||||
if (state.session.isAuthorized) {
|
|
||||||
navigate(pageId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pageId === 'messages-list') {
|
|
||||||
openAuthRequiredModal({
|
|
||||||
title: 'Личные сообщения недоступны',
|
|
||||||
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pageId === 'profile-view') {
|
|
||||||
openAuthRequiredModal({
|
|
||||||
title: 'Профиль недоступен',
|
|
||||||
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pageId === 'notifications-view') {
|
|
||||||
openAuthRequiredModal({
|
|
||||||
title: 'Уведомления недоступны',
|
|
||||||
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderToolbar(currentPageId, navigate) {
|
export function renderToolbar(currentPageId, navigate) {
|
||||||
const root = document.createElement('nav');
|
const root = document.createElement('nav');
|
||||||
root.className = 'toolbar';
|
root.className = 'toolbar';
|
||||||
@ -93,7 +63,7 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
installChannelsHoldSwitcher(btn, navigate);
|
installChannelsHoldSwitcher(btn, navigate);
|
||||||
} else {
|
} else {
|
||||||
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
btn.addEventListener('click', () => navigate(item.pageId));
|
||||||
}
|
}
|
||||||
root.append(btn);
|
root.append(btn);
|
||||||
});
|
});
|
||||||
@ -106,7 +76,7 @@ function installChannelsHoldSwitcher(button, navigate) {
|
|||||||
let pressed = false;
|
let pressed = false;
|
||||||
let holdActive = false;
|
let holdActive = false;
|
||||||
let overlay = null;
|
let overlay = null;
|
||||||
let selectedMode = 'feed';
|
let selectedMode = 'dialogs';
|
||||||
|
|
||||||
const clearTimer = () => {
|
const clearTimer = () => {
|
||||||
if (holdTimer) {
|
if (holdTimer) {
|
||||||
@ -150,7 +120,7 @@ function installChannelsHoldSwitcher(button, navigate) {
|
|||||||
button.addEventListener('pointerdown', (event) => {
|
button.addEventListener('pointerdown', (event) => {
|
||||||
pressed = true;
|
pressed = true;
|
||||||
holdActive = false;
|
holdActive = false;
|
||||||
selectedMode = 'feed';
|
selectedMode = 'dialogs';
|
||||||
clearTimer();
|
clearTimer();
|
||||||
holdTimer = window.setTimeout(() => {
|
holdTimer = window.setTimeout(() => {
|
||||||
if (!pressed) return;
|
if (!pressed) return;
|
||||||
@ -173,7 +143,7 @@ function installChannelsHoldSwitcher(button, navigate) {
|
|||||||
navigate(`channels-list/${mode}`);
|
navigate(`channels-list/${mode}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('channels-list/feed');
|
navigate('channels-list/dialogs');
|
||||||
});
|
});
|
||||||
|
|
||||||
button.addEventListener('pointercancel', () => {
|
button.addEventListener('pointercancel', () => {
|
||||||
@ -186,4 +156,3 @@ function installChannelsHoldSwitcher(button, navigate) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,67 +11,11 @@ import {
|
|||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||||
import { navigateBack } from '../router.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|
||||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingThreadScroll = new Map();
|
const pendingThreadScroll = new Map();
|
||||||
const threadAvatarSnapshotCache = new Map();
|
|
||||||
const threadAvatarPendingByLogin = new Map();
|
|
||||||
|
|
||||||
async function loadThreadAvatarSnapshot(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
if (!cleanLogin) return null;
|
|
||||||
const key = cleanLogin.toLowerCase();
|
|
||||||
if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key);
|
|
||||||
if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key);
|
|
||||||
const pending = loadProfileSnapshot(cleanLogin)
|
|
||||||
.then((snapshot) => {
|
|
||||||
threadAvatarSnapshotCache.set(key, snapshot || null);
|
|
||||||
threadAvatarPendingByLogin.delete(key);
|
|
||||||
return snapshot || null;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
threadAvatarSnapshotCache.set(key, null);
|
|
||||||
threadAvatarPendingByLogin.delete(key);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
threadAvatarPendingByLogin.set(key, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createThreadAvatar(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
|
||||||
const avatarEl = renderUserAvatar({
|
|
||||||
login: cleanLogin || 'unknown',
|
|
||||||
size: 'small',
|
|
||||||
className: 'channel-message-avatar',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
if (!cleanLogin) return avatarEl;
|
|
||||||
void loadThreadAvatarSnapshot(cleanLogin).then((snapshot) => {
|
|
||||||
if (!avatarEl.isConnected) return;
|
|
||||||
const upgraded = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
avatar: snapshot?.avatar?.txId
|
|
||||||
? {
|
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
size: 'small',
|
|
||||||
className: 'channel-message-avatar',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
avatarEl.replaceWith(upgraded);
|
|
||||||
});
|
|
||||||
return avatarEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logThreadRuntimeError(stage, error, context = {}) {
|
function logThreadRuntimeError(stage, error, context = {}) {
|
||||||
const message = String(error?.message || error || 'thread runtime error');
|
const message = String(error?.message || error || 'thread runtime error');
|
||||||
@ -105,11 +49,6 @@ function toSafeInt(value) {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeBlockchainName(value) {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
return /^[^-]+-\d+$/.test(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReactionActionKey(messageRef) {
|
function makeReactionActionKey(messageRef) {
|
||||||
const login = String(state.session.login || '').trim().toLowerCase();
|
const login = String(state.session.login || '').trim().toLowerCase();
|
||||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||||
@ -130,8 +69,7 @@ function messageRefKey(messageRef) {
|
|||||||
function buildAbsoluteRouteUrl(routePath = '') {
|
function buildAbsoluteRouteUrl(routePath = '') {
|
||||||
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.pathname = `/${cleanRoute}`;
|
url.hash = `#/${cleanRoute}`;
|
||||||
url.hash = '';
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,8 +88,8 @@ function parseThreadSelector(route) {
|
|||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
ownerBlockchainName: '',
|
ownerBlockchainName: '',
|
||||||
channelRootBlockNumber: null,
|
rootBlockNumber: null,
|
||||||
channelRootBlockHash: '0',
|
rootBlockHash: '0',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -166,8 +104,8 @@ function parseThreadSelector(route) {
|
|||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
|
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
|
||||||
channelRootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
||||||
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -182,12 +120,10 @@ function allFeedSummaries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveChannelDisplayName(channelSelector) {
|
function resolveChannelDisplayName(channelSelector) {
|
||||||
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
|
if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
|
||||||
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
|
|
||||||
if (!channelSelector?.ownerBlockchainName || rootNumber == null) return '';
|
|
||||||
const ownerBch = String(channelSelector.ownerBlockchainName);
|
const ownerBch = String(channelSelector.ownerBlockchainName);
|
||||||
const rootNo = Number(rootNumber);
|
const rootNo = Number(channelSelector.rootBlockNumber);
|
||||||
const rootHash = normalizeRouteHash(rootHashRaw);
|
const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
|
||||||
|
|
||||||
const found = allFeedSummaries().find((summary) => (
|
const found = allFeedSummaries().find((summary) => (
|
||||||
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
|
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
|
||||||
@ -198,81 +134,37 @@ function resolveChannelDisplayName(channelSelector) {
|
|||||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractChannelContextFromThreadPayload(payload) {
|
function buildBackRoute(selector) {
|
||||||
const focusInfo = payload?.focus?.channelInfo;
|
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||||
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
|
return [
|
||||||
return {
|
'channel',
|
||||||
ownerBlockchainName: String(focusInfo.ownerBlockchainName || '').trim(),
|
encodeRoutePart(selector.short.ownerBlockchainName),
|
||||||
channelRootBlockNumber: Number(focusInfo.channelRoot.blockNumber),
|
encodeRoutePart(selector.short.channelName),
|
||||||
channelRootBlockHash: '0',
|
].join('/');
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
|
||||||
for (let i = ancestors.length - 1; i >= 0; i -= 1) {
|
|
||||||
const info = ancestors[i]?.channelInfo;
|
|
||||||
if (info?.ownerBlockchainName && info?.channelRoot?.blockNumber != null) {
|
|
||||||
return {
|
|
||||||
ownerBlockchainName: String(info.ownerBlockchainName || '').trim(),
|
|
||||||
channelRootBlockNumber: Number(info.channelRoot.blockNumber),
|
|
||||||
channelRootBlockHash: '0',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveChannelDisplayNameFromServer(channelSelector) {
|
|
||||||
const ownerBch = String(channelSelector?.ownerBlockchainName || '').trim();
|
|
||||||
const rootNo = Number(channelSelector?.channelRootBlockNumber);
|
|
||||||
if (!ownerBch || !Number.isFinite(rootNo) || rootNo < 0) return '';
|
|
||||||
|
|
||||||
const ownerLogin = extractLoginFromBlockchainName(ownerBch);
|
|
||||||
if (!ownerLogin) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
|
|
||||||
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
|
|
||||||
const row = rows.find((item) => (
|
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch.toLowerCase()
|
|
||||||
&& Number(item?.channel?.channelRoot?.blockNumber) === rootNo
|
|
||||||
));
|
|
||||||
if (!row?.channel?.channelName) return '';
|
|
||||||
|
|
||||||
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
|
|
||||||
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
return 'channels-list';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildThreadRouteFromTarget(target, selector) {
|
function buildThreadRouteFromTarget(target, selector) {
|
||||||
if (!target) return '';
|
if (!target) return '';
|
||||||
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
|
||||||
return makeShineMessageRoute({
|
|
||||||
ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName),
|
|
||||||
messageBlockchainName: target.blockchainName,
|
|
||||||
messageBlockNumber: target.blockNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
|
||||||
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
|
||||||
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||||
return makeShineChannelRoute({
|
return [
|
||||||
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
'channel',
|
||||||
ownerBlockchainName: ownerBch,
|
encodeRoutePart(selector.short.ownerBlockchainName),
|
||||||
channelName: selector.short.channelName,
|
encodeRoutePart(selector.short.channelName),
|
||||||
});
|
target.blockNumber,
|
||||||
|
].join('/');
|
||||||
}
|
}
|
||||||
const label = String(resolvedChannelLabel || '').trim();
|
if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
|
||||||
const slashIndex = label.indexOf('/');
|
return [
|
||||||
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
|
'channel-thread-view',
|
||||||
return makeShineChannelRoute({
|
encodeRoutePart(target.blockchainName),
|
||||||
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
target.blockNumber,
|
||||||
ownerBlockchainName: ownerBch,
|
normalizeRouteHash(target.blockHash),
|
||||||
channelName,
|
encodeRoutePart(selector.channel.ownerBlockchainName),
|
||||||
});
|
selector.channel.rootBlockNumber,
|
||||||
|
normalizeRouteHash(selector.channel.rootBlockHash),
|
||||||
|
].join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTargetFromNode(node) {
|
function buildTargetFromNode(node) {
|
||||||
@ -293,11 +185,12 @@ function firstNonEmptyText(...candidates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function latestVersionText(versions) {
|
function latestVersionText(versions) {
|
||||||
if (!Array.isArray(versions) || !versions.length) return '';
|
if (!Array.isArray(versions)) return '';
|
||||||
const version = versions[versions.length - 1];
|
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||||
if (typeof version?.text === 'string') return version.text;
|
const version = versions[i];
|
||||||
if (typeof version?.message === 'string') return version.message;
|
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||||
if (typeof version?.body === 'string') return version.body;
|
if (value) return value;
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,104 +272,16 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
const rows = Array.isArray(versions) ? versions : [];
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="thread-history-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">${title}</h3>
|
|
||||||
<div class="stack" id="thread-history-list"></div>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" id="thread-history-close" type="button">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const list = root.querySelector('#thread-history-list');
|
|
||||||
if (list) {
|
|
||||||
rows.forEach((item, index) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'card stack';
|
|
||||||
const ts = Number(item?.createdAtMs || 0);
|
|
||||||
const text = String(item?.text || '').trim() || 'удалено';
|
|
||||||
row.innerHTML = `
|
|
||||||
<strong>Версия ${index + 1}</strong>
|
|
||||||
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
|
|
||||||
<p class="channel-message-body">${text}</p>
|
|
||||||
`;
|
|
||||||
list.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
root.querySelector('#thread-history-close')?.addEventListener('click', () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="thread-edit-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">Редактировать сообщение</h3>
|
|
||||||
<textarea id="thread-edit-text" class="input" rows="6" maxlength="2000"></textarea>
|
|
||||||
<div class="meta-muted inline-error" id="thread-edit-error"></div>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" id="thread-edit-cancel" type="button">Отмена</button>
|
|
||||||
<button class="primary-btn" id="thread-edit-save" type="button">ОК</button>
|
|
||||||
</div>
|
|
||||||
<button class="destructive-btn modal-danger-action" id="thread-edit-delete" type="button">Удалить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const textEl = root.querySelector('#thread-edit-text');
|
|
||||||
const errorEl = root.querySelector('#thread-edit-error');
|
|
||||||
if (textEl) textEl.value = String(initialText || '');
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
|
|
||||||
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
|
|
||||||
const value = String(textEl?.value || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
errorEl.textContent = 'Введите текст сообщения.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await onSave(value);
|
|
||||||
close();
|
|
||||||
} catch (error) {
|
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await onDelete();
|
|
||||||
close();
|
|
||||||
} catch (error) {
|
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (textEl) textEl.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack thread-node-card channel-message-card';
|
card.className = 'card stack thread-node-card';
|
||||||
card.classList.add('is-counters-visible');
|
|
||||||
|
|
||||||
const author = node?.authorLogin || 'автор';
|
const author = node?.authorLogin || 'автор';
|
||||||
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
const text = resolveNodeText(node) || '(пусто)';
|
||||||
const versionsTotal = Number(node?.versionsTotal || versions.length || 1);
|
|
||||||
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
|
||||||
const likes = Number(node?.likesCount || 0);
|
const likes = Number(node?.likesCount || 0);
|
||||||
const replies = Number(node?.repliesCount || 0);
|
const replies = Number(node?.repliesCount || 0);
|
||||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
const versions = Number(node?.versionsTotal || 1);
|
||||||
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
const changes = Math.max(0, versions - 1);
|
||||||
|
|
||||||
const headingText = String(heading || '').trim();
|
const headingText = String(heading || '').trim();
|
||||||
if (headingText) {
|
if (headingText) {
|
||||||
@ -486,51 +291,18 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
card.append(headingEl);
|
card.append(headingEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorTile = document.createElement('button');
|
const meta = document.createElement('p');
|
||||||
authorTile.type = 'button';
|
meta.className = 'thread-node-meta';
|
||||||
authorTile.className = 'channel-message-author-tile';
|
meta.innerHTML = `
|
||||||
|
<span class="author-line-login">${author}</span>
|
||||||
|
<span class="author-line-num">· #${localNumber}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
const avatar = createThreadAvatar(author);
|
|
||||||
|
|
||||||
const authorBlock = document.createElement('div');
|
|
||||||
authorBlock.className = 'channel-message-author';
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'channel-message-title author-line';
|
|
||||||
const loginEl = document.createElement('span');
|
|
||||||
loginEl.className = 'author-line-login';
|
|
||||||
loginEl.textContent = author;
|
|
||||||
const numberEl = document.createElement('span');
|
|
||||||
numberEl.className = 'author-line-num';
|
|
||||||
numberEl.textContent = `· #${localNumber}`;
|
|
||||||
title.append(loginEl, numberEl);
|
|
||||||
if (versionsTotal > 1) {
|
|
||||||
const editedMarker = document.createElement('button');
|
|
||||||
editedMarker.type = 'button';
|
|
||||||
editedMarker.className = 'message-edited-marker';
|
|
||||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
|
||||||
editedMarker.title = 'Открыть историю редактирования';
|
|
||||||
editedMarker.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openMessageHistoryModal({
|
|
||||||
title: `История #${localNumber}`,
|
|
||||||
versions,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
title.append(editedMarker);
|
|
||||||
}
|
|
||||||
const timestamp = document.createElement('div');
|
|
||||||
timestamp.className = 'channel-message-time';
|
|
||||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
|
||||||
authorBlock.append(title, timestamp);
|
|
||||||
authorTile.append(avatar, authorBlock);
|
|
||||||
|
|
||||||
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
|
|
||||||
const body = document.createElement('p');
|
const body = document.createElement('p');
|
||||||
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
body.className = 'thread-node-body';
|
||||||
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
|
body.textContent = text;
|
||||||
|
|
||||||
card.append(authorTile, body);
|
card.append(meta, body);
|
||||||
|
|
||||||
const target = buildTargetFromNode(node);
|
const target = buildTargetFromNode(node);
|
||||||
const refKey = messageRefKey(target);
|
const refKey = messageRefKey(target);
|
||||||
@ -546,20 +318,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const isLiked = getMessageReactionState(target) === 'liked';
|
const isLiked = getMessageReactionState(target) === 'liked';
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'thread-node-actions channel-message-actions';
|
actions.className = 'thread-node-actions';
|
||||||
|
|
||||||
const likeButton = document.createElement('button');
|
const likeButton = document.createElement('button');
|
||||||
likeButton.type = 'button';
|
likeButton.type = 'button';
|
||||||
likeButton.className = 'channel-action-item thread-like-btn';
|
likeButton.className = 'secondary-btn thread-like-btn';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.innerHTML = `
|
likeButton.textContent = isPending ? `❤️ ${likes}...` : `${isLiked ? '❤️' : '🤍'} ${likes}`;
|
||||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
|
||||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
|
||||||
<span class="channel-action-counter">${likes}</span>
|
|
||||||
`;
|
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
if (!isLiked) {
|
if (!isLiked) {
|
||||||
@ -568,6 +335,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
}
|
}
|
||||||
await longPressFeel(event.currentTarget, 130);
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
|
likeButton.textContent = `❤️ ${likes}...`;
|
||||||
try {
|
try {
|
||||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -582,14 +350,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
|
|
||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'channel-action-item thread-reply-btn';
|
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||||
replyButton.innerHTML = `
|
replyButton.textContent = `💬 ${replies}`;
|
||||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
|
||||||
<span class="channel-action-label">Ответить</span>
|
|
||||||
<span class="channel-action-counter">${replies}</span>
|
|
||||||
`;
|
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
openReplyModal({
|
openReplyModal({
|
||||||
navigate: handlers.navigate,
|
navigate: handlers.navigate,
|
||||||
@ -597,50 +360,25 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const changedButton = document.createElement('button');
|
||||||
|
changedButton.type = 'button';
|
||||||
|
changedButton.className = 'secondary-btn thread-version-btn';
|
||||||
|
changedButton.textContent = `✏️ ${changes}`;
|
||||||
|
changedButton.disabled = true;
|
||||||
|
changedButton.style.display = changes > 0 ? '' : 'none';
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'channel-action-item thread-share-btn';
|
shareButton.className = 'secondary-btn thread-share-btn';
|
||||||
shareButton.innerHTML = `
|
shareButton.textContent = '↗ Отправить';
|
||||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
|
||||||
<span class="channel-action-label">Отправить</span>
|
|
||||||
`;
|
|
||||||
shareButton.addEventListener('click', async (event) => {
|
shareButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
await handlers.onShare(target);
|
await handlers.onShare(target);
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(likeButton, replyButton, shareButton);
|
actions.append(likeButton, replyButton, changedButton, shareButton);
|
||||||
if (isOwnMessage) {
|
|
||||||
const editButton = document.createElement('button');
|
|
||||||
editButton.type = 'button';
|
|
||||||
editButton.className = 'channel-action-item';
|
|
||||||
editButton.setAttribute('aria-label', 'Редактировать');
|
|
||||||
editButton.title = 'Редактировать';
|
|
||||||
editButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
|
||||||
`;
|
|
||||||
editButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openEditMessageModal({
|
|
||||||
initialText: String(text || '').trim() === 'удалено' ? '' : text,
|
|
||||||
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
|
|
||||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
actions.append(editButton);
|
|
||||||
}
|
|
||||||
card.append(actions);
|
card.append(actions);
|
||||||
authorTile.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const login = String(node?.authorLogin || '').trim();
|
|
||||||
if (!login) return;
|
|
||||||
handlers.navigate(makeProfileRoute(login));
|
|
||||||
});
|
|
||||||
card.addEventListener('click', () => {
|
|
||||||
handlers.onOpenThread(target);
|
|
||||||
});
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -703,6 +441,7 @@ function renderSkeleton(screen) {
|
|||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const selector = parseThreadSelector(route);
|
const selector = parseThreadSelector(route);
|
||||||
|
const backRoute = buildBackRoute(selector);
|
||||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||||
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||||
|
|
||||||
@ -711,16 +450,9 @@ export function render({ navigate, route }) {
|
|||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
appScreen?.classList.add('channels-scroll-clean');
|
||||||
|
|
||||||
const header = renderHeader({
|
const channelIndicator = document.createElement('div');
|
||||||
title: '',
|
channelIndicator.className = 'card channels-user-chip';
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
||||||
rightActions: [{ label: 'Тред в канале: ...', onClick: () => {} }],
|
|
||||||
});
|
|
||||||
const threadHeaderButton = header.querySelector('.header-actions .icon-btn');
|
|
||||||
if (threadHeaderButton) {
|
|
||||||
threadHeaderButton.classList.add('channel-header-route-btn');
|
|
||||||
threadHeaderButton.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBox = document.createElement('div');
|
const statusBox = document.createElement('div');
|
||||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||||
@ -733,7 +465,7 @@ export function render({ navigate, route }) {
|
|||||||
const next = render({ navigate, route });
|
const next = render({ navigate, route });
|
||||||
current.replaceWith(next);
|
current.replaceWith(next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
|
logThreadRuntimeError('rerender', error, { routeHash: window.location.hash });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -751,7 +483,7 @@ export function render({ navigate, route }) {
|
|||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
if (!login || !storagePwd) {
|
if (!login || !storagePwd) {
|
||||||
state.authReturnHash = window.location.pathname || '/channels-list';
|
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||||
navigate('login-view');
|
navigate('login-view');
|
||||||
throw new Error('Для этого действия нужно войти');
|
throw new Error('Для этого действия нужно войти');
|
||||||
}
|
}
|
||||||
@ -813,38 +545,21 @@ export function render({ navigate, route }) {
|
|||||||
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenThread: (target) => {
|
|
||||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
|
||||||
if (!routePath) {
|
|
||||||
showStatus('Не удалось определить путь до треда.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(routePath);
|
|
||||||
},
|
|
||||||
onActionError: (error, action) => {
|
onActionError: (error, action) => {
|
||||||
const fallback = action === 'unlike'
|
const fallback = action === 'unlike'
|
||||||
? 'Не удалось убрать лайк.'
|
? 'Не удалось убрать лайк.'
|
||||||
: 'Не удалось поставить лайк.';
|
: 'Не удалось поставить лайк.';
|
||||||
showStatus(toUserMessage(error, fallback));
|
showStatus(toUserMessage(error, fallback));
|
||||||
},
|
},
|
||||||
onEdit: async (target, textValue, meta = {}) => {
|
|
||||||
const { login, storagePwd } = requireSigningSession();
|
|
||||||
await authService.addBlockEditMessage({
|
|
||||||
login,
|
|
||||||
storagePwd,
|
|
||||||
message: target,
|
|
||||||
text: textValue,
|
|
||||||
isChannelPost: meta?.isChannelPost === true,
|
|
||||||
channel: selector?.channel || null,
|
|
||||||
});
|
|
||||||
softHaptic(12);
|
|
||||||
showToast('Сообщение обновлено');
|
|
||||||
showStatus('');
|
|
||||||
rerender();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
screen.append(header, statusBox);
|
screen.append(
|
||||||
|
renderHeader({
|
||||||
|
title: 'Тред',
|
||||||
|
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
screen.append(channelIndicator, statusBox);
|
||||||
|
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
const invalid = document.createElement('div');
|
const invalid = document.createElement('div');
|
||||||
@ -866,46 +581,10 @@ export function render({ navigate, route }) {
|
|||||||
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
|
||||||
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []),
|
...(Array.isArray(ownFeed?.followedChannels) ? ownFeed.followedChannels : []),
|
||||||
];
|
];
|
||||||
const ownerRaw = String(selector.short.ownerBlockchainName || '').trim();
|
const channel = allRows.find((item) => (
|
||||||
const ownerNormalized = ownerRaw.toLowerCase();
|
String(item?.channel?.ownerBlockchainName || '').trim() === selector.short.ownerBlockchainName
|
||||||
const ownerLoginFromBch = extractLoginFromBlockchainName(ownerRaw);
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
|
||||||
const channelNameNormalized = String(selector.short.channelName || '').trim().toLowerCase();
|
|
||||||
let channel = allRows.find((item) => (
|
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
||||||
));
|
));
|
||||||
if (!channel) {
|
|
||||||
channel = allRows.find((item) => (
|
|
||||||
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerNormalized
|
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
|
|
||||||
try {
|
|
||||||
const ownerUser = await authService.getUser(ownerRaw);
|
|
||||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
|
||||||
if (ownerBch) {
|
|
||||||
channel = allRows.find((item) => (
|
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore fallback lookup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!channel && ownerLoginFromBch) {
|
|
||||||
try {
|
|
||||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
|
|
||||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
|
||||||
channel = ownerRows.find((item) => (
|
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
|
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === channelNameNormalized
|
|
||||||
));
|
|
||||||
} catch {
|
|
||||||
// ignore owner feed lookup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
|
const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
|
||||||
const rootNo = Number(channel?.channel?.channelRoot?.blockNumber);
|
const rootNo = Number(channel?.channel?.channelRoot?.blockNumber);
|
||||||
const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash);
|
const rootHash = normalizeRouteHash(channel?.channel?.channelRoot?.blockHash);
|
||||||
@ -914,14 +593,25 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
selector.channel = {
|
selector.channel = {
|
||||||
ownerBlockchainName: ownerBch,
|
ownerBlockchainName: ownerBch,
|
||||||
channelRootBlockNumber: rootNo,
|
rootBlockNumber: rootNo,
|
||||||
channelRootBlockHash: rootHash,
|
rootBlockHash: rootHash,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let resolvedHash = normalizeMessageHash(resolvedMessage?.blockHash);
|
||||||
|
if (!resolvedHash) {
|
||||||
|
const channelPayload = await authService.getChannelMessages(selector.channel, 400, 'asc', state.session.login);
|
||||||
|
const messages = Array.isArray(channelPayload?.messages) ? channelPayload.messages : [];
|
||||||
|
const foundMessage = messages.find((item) => Number(item?.messageRef?.blockNumber) === Number(resolvedMessage.blockNumber));
|
||||||
|
const foundHash = normalizeMessageHash(foundMessage?.messageRef?.blockHash);
|
||||||
|
if (!foundHash) {
|
||||||
|
throw new Error('Не удалось определить hash сообщения для открытия треда.');
|
||||||
|
}
|
||||||
|
resolvedHash = foundHash;
|
||||||
|
}
|
||||||
resolvedMessage = {
|
resolvedMessage = {
|
||||||
blockchainName: ownerBch,
|
blockchainName: ownerBch,
|
||||||
blockNumber: resolvedMessage.blockNumber,
|
blockNumber: resolvedMessage.blockNumber,
|
||||||
blockHash: normalizeMessageHash(resolvedMessage?.blockHash),
|
blockHash: resolvedHash,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -932,68 +622,30 @@ export function render({ navigate, route }) {
|
|||||||
const focus = payload?.focus || null;
|
const focus = payload?.focus || null;
|
||||||
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
||||||
|
|
||||||
const focusHash = normalizeMessageHash(focus?.messageRef?.blockHash);
|
|
||||||
if (focusHash && selector?.message) {
|
|
||||||
selector.message.blockHash = focusHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((!selector?.channel?.ownerBlockchainName || selector?.channel?.channelRootBlockNumber == null) && payload) {
|
|
||||||
const context = extractChannelContextFromThreadPayload(payload);
|
|
||||||
if (context) {
|
|
||||||
selector.channel = {
|
|
||||||
ownerBlockchainName: context.ownerBlockchainName,
|
|
||||||
channelRootBlockNumber: context.channelRootBlockNumber,
|
|
||||||
channelRootBlockHash: normalizeRouteHash(context.channelRootBlockHash),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
|
||||||
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
|
|
||||||
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
|
|
||||||
}
|
|
||||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
|
||||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
|
||||||
if (threadHeaderButton) {
|
|
||||||
threadHeaderButton.textContent = `Тред в канале: ${resolvedChannelTitle}`;
|
|
||||||
threadHeaderButton.disabled = false;
|
|
||||||
threadHeaderButton.onclick = (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
|
|
||||||
if (routeToChannel) navigate(routeToChannel);
|
|
||||||
else navigate('channels-list');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const nextNumber = () => {
|
const nextNumber = () => {
|
||||||
seq += 1;
|
seq += 1;
|
||||||
return seq;
|
return seq;
|
||||||
};
|
};
|
||||||
|
|
||||||
let ancestorsWrap = null;
|
|
||||||
if (ancestors.length) {
|
if (ancestors.length) {
|
||||||
ancestorsWrap = document.createElement('div');
|
const ancestorsWrap = document.createElement('div');
|
||||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.className = 'section-title';
|
title.className = 'section-title';
|
||||||
title.textContent = 'История выше (на что это ответ)';
|
title.textContent = 'Предыдущие сообщения';
|
||||||
ancestorsWrap.append(title);
|
ancestorsWrap.append(title);
|
||||||
ancestors.forEach((node, index) => {
|
ancestors.forEach((node, index) => {
|
||||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||||
});
|
});
|
||||||
|
screen.append(ancestorsWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusWrap = null;
|
|
||||||
if (focus) {
|
if (focus) {
|
||||||
focusWrap = document.createElement('div');
|
const focusWrap = document.createElement('div');
|
||||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||||
const focusTitle = document.createElement('h3');
|
|
||||||
focusTitle.className = 'section-title';
|
|
||||||
focusTitle.textContent = 'Текущее сообщение';
|
|
||||||
focusWrap.append(focusTitle);
|
|
||||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
||||||
|
screen.append(focusWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const descendantsWrap = document.createElement('div');
|
const descendantsWrap = document.createElement('div');
|
||||||
@ -1012,23 +664,8 @@ export function render({ navigate, route }) {
|
|||||||
descendantsWrap.append(empty);
|
descendantsWrap.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ancestorsWrap) {
|
|
||||||
screen.append(ancestorsWrap);
|
|
||||||
const divider = document.createElement('div');
|
|
||||||
divider.className = 'thread-history-divider';
|
|
||||||
screen.append(divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (focusWrap) screen.append(focusWrap);
|
|
||||||
screen.append(descendantsWrap);
|
screen.append(descendantsWrap);
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey);
|
||||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
|
||||||
if (!hasPendingScroll && focusWrap) {
|
|
||||||
setTimeout(() => {
|
|
||||||
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
||||||
}, 20);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
const failed = document.createElement('div');
|
const failed = document.createElement('div');
|
||||||
|
|||||||
@ -17,72 +17,12 @@ import {
|
|||||||
softHaptic,
|
softHaptic,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||||
import { navigateBack } from '../router.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|
||||||
import {
|
|
||||||
extractLoginFromBlockchainName,
|
|
||||||
makeProfileRoute,
|
|
||||||
makeShineMessageRoute,
|
|
||||||
} from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
const messageAvatarSnapshotCache = new Map();
|
|
||||||
const messageAvatarPendingByLogin = new Map();
|
|
||||||
|
|
||||||
async function loadMessageAvatarSnapshot(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
if (!cleanLogin) return null;
|
|
||||||
const key = cleanLogin.toLowerCase();
|
|
||||||
if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key);
|
|
||||||
if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key);
|
|
||||||
const pending = loadProfileSnapshot(cleanLogin)
|
|
||||||
.then((snapshot) => {
|
|
||||||
messageAvatarSnapshotCache.set(key, snapshot || null);
|
|
||||||
messageAvatarPendingByLogin.delete(key);
|
|
||||||
return snapshot || null;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
messageAvatarSnapshotCache.set(key, null);
|
|
||||||
messageAvatarPendingByLogin.delete(key);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
messageAvatarPendingByLogin.set(key, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMessageAvatar(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
|
||||||
const avatarEl = renderUserAvatar({
|
|
||||||
login: cleanLogin || 'unknown',
|
|
||||||
size: 'small',
|
|
||||||
className: 'channel-message-avatar',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
if (!cleanLogin) return avatarEl;
|
|
||||||
void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => {
|
|
||||||
if (!avatarEl.isConnected) return;
|
|
||||||
const upgraded = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
avatar: snapshot?.avatar?.txId
|
|
||||||
? {
|
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
size: 'small',
|
|
||||||
className: 'channel-message-avatar',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
avatarEl.replaceWith(upgraded);
|
|
||||||
});
|
|
||||||
return avatarEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -115,11 +55,6 @@ function toSafeInt(value) {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeBlockchainName(value) {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
return /^[^-]+-\d+$/.test(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReactionActionKey(messageRef) {
|
function makeReactionActionKey(messageRef) {
|
||||||
const login = String(state.session.login || '').trim().toLowerCase();
|
const login = String(state.session.login || '').trim().toLowerCase();
|
||||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||||
@ -163,8 +98,7 @@ function blockRefToMessageKey(blockRef, fallbackBch = '') {
|
|||||||
function buildAbsoluteRouteUrl(routePath = '') {
|
function buildAbsoluteRouteUrl(routePath = '') {
|
||||||
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.pathname = `/${cleanRoute}`;
|
url.hash = `#/${cleanRoute}`;
|
||||||
url.hash = '';
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,12 +134,25 @@ function buildSelectorFromRoute(route, channelId) {
|
|||||||
|
|
||||||
function buildThreadRoute(messageRef, selector) {
|
function buildThreadRoute(messageRef, selector) {
|
||||||
if (!messageRef || !selector) return '';
|
if (!messageRef || !selector) return '';
|
||||||
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
|
const ownerBlockchainName = String(selector.ownerBlockchainName || '').trim();
|
||||||
return makeShineMessageRoute({
|
const channelName = String(selector.channelName || '').trim();
|
||||||
ownerLogin,
|
if (ownerBlockchainName && channelName) {
|
||||||
messageBlockchainName: messageRef.blockchainName,
|
return [
|
||||||
messageBlockNumber: messageRef.blockNumber,
|
'channel',
|
||||||
});
|
encodeRoutePart(ownerBlockchainName),
|
||||||
|
encodeRoutePart(channelName),
|
||||||
|
messageRef.blockNumber,
|
||||||
|
].join('/');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'channel-thread-view',
|
||||||
|
encodeRoutePart(messageRef.blockchainName),
|
||||||
|
messageRef.blockNumber,
|
||||||
|
normalizeRouteHash(messageRef.blockHash),
|
||||||
|
encodeRoutePart(selector.ownerBlockchainName),
|
||||||
|
selector.channelRootBlockNumber,
|
||||||
|
normalizeRouteHash(selector.channelRootBlockHash),
|
||||||
|
].join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyText(...candidates) {
|
function firstNonEmptyText(...candidates) {
|
||||||
@ -218,11 +165,12 @@ function firstNonEmptyText(...candidates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function latestVersionText(versions) {
|
function latestVersionText(versions) {
|
||||||
if (!Array.isArray(versions) || !versions.length) return '';
|
if (!Array.isArray(versions)) return '';
|
||||||
const version = versions[versions.length - 1];
|
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||||
if (typeof version?.text === 'string') return version.text;
|
const version = versions[i];
|
||||||
if (typeof version?.message === 'string') return version.message;
|
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||||
if (typeof version?.body === 'string') return version.body;
|
if (value) return value;
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,92 +377,6 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMessageHistoryModal({ versions = [], title = 'История изменений' }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
const rows = Array.isArray(versions) ? versions : [];
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="message-history-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">${title}</h3>
|
|
||||||
<div class="stack" id="message-history-list"></div>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" id="message-history-close" type="button">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const list = root.querySelector('#message-history-list');
|
|
||||||
if (list) {
|
|
||||||
rows.forEach((item, index) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'card stack';
|
|
||||||
const ts = toTimestampMs(item?.createdAtMs);
|
|
||||||
const text = String(item?.text || '').trim() || 'удалено';
|
|
||||||
row.innerHTML = `
|
|
||||||
<strong>Версия ${index + 1}</strong>
|
|
||||||
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '—'}</div>
|
|
||||||
<p class="channel-message-body">${text}</p>
|
|
||||||
`;
|
|
||||||
list.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
root.querySelector('#message-history-close')?.addEventListener('click', () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="edit-message-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">Редактировать сообщение</h3>
|
|
||||||
<textarea id="edit-message-text" class="input" rows="6" maxlength="2000"></textarea>
|
|
||||||
<div class="meta-muted inline-error" id="edit-message-error"></div>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" id="edit-message-cancel" type="button">Отмена</button>
|
|
||||||
<button class="primary-btn" id="edit-message-save" type="button">ОК</button>
|
|
||||||
</div>
|
|
||||||
<button class="destructive-btn modal-danger-action" id="edit-message-delete" type="button">Удалить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const textEl = root.querySelector('#edit-message-text');
|
|
||||||
const errorEl = root.querySelector('#edit-message-error');
|
|
||||||
if (textEl) textEl.value = String(initialText || '');
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
|
|
||||||
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
|
|
||||||
const value = String(textEl?.value || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
errorEl.textContent = 'Введите текст сообщения.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await onSave(value);
|
|
||||||
close();
|
|
||||||
} catch (error) {
|
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await onDelete();
|
|
||||||
close();
|
|
||||||
} catch (error) {
|
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (textEl) textEl.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapApiMessageToPost(message, selector, localNumber) {
|
function mapApiMessageToPost(message, selector, localNumber) {
|
||||||
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
||||||
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
||||||
@ -537,29 +399,20 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
return {
|
return {
|
||||||
localNumber,
|
localNumber,
|
||||||
authorLogin: message?.authorLogin || 'автор',
|
authorLogin: message?.authorLogin || 'автор',
|
||||||
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'),
|
body: resolvedText || '(пусто)',
|
||||||
versionsTotal: Number(message?.versionsTotal || 1),
|
|
||||||
versions: Array.isArray(message?.versions) ? message.versions : [],
|
|
||||||
likesCount: Number(message?.likesCount || 0),
|
likesCount: Number(message?.likesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||||
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromApi(route, channelId) {
|
async function loadFromApi(route, channelId) {
|
||||||
const currentSessionLogin = String(state.session.login || '').trim();
|
|
||||||
const isAuthorized = !!currentSessionLogin;
|
|
||||||
let cachedFeed = null;
|
let cachedFeed = null;
|
||||||
const ensureFeed = async () => {
|
const ensureFeed = async () => {
|
||||||
if (cachedFeed) return cachedFeed;
|
if (cachedFeed) return cachedFeed;
|
||||||
if (!isAuthorized) {
|
cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
||||||
cachedFeed = {};
|
|
||||||
return cachedFeed;
|
|
||||||
}
|
|
||||||
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
|
|
||||||
return cachedFeed;
|
return cachedFeed;
|
||||||
};
|
};
|
||||||
const getAllRows = async () => {
|
const getAllRows = async () => {
|
||||||
@ -575,11 +428,8 @@ async function loadFromApi(route, channelId) {
|
|||||||
if (selector?.ownerBlockchainName && selector?.channelName) {
|
if (selector?.ownerBlockchainName && selector?.channelName) {
|
||||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
|
||||||
let channel = null;
|
|
||||||
if (isAuthorized) {
|
|
||||||
const allRows = await getAllRows();
|
const allRows = await getAllRows();
|
||||||
channel = allRows.find((item) => (
|
let channel = allRows.find((item) => (
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||||
));
|
));
|
||||||
@ -589,7 +439,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
|
if (!channel) {
|
||||||
try {
|
try {
|
||||||
const ownerUser = await authService.getUser(routeOwnerRaw);
|
const ownerUser = await authService.getUser(routeOwnerRaw);
|
||||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
||||||
@ -603,22 +453,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
// ignore fallback lookup failures
|
// ignore fallback lookup failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!channel) {
|
|
||||||
const ownerLoginForLookup = routeOwnerLoginFromBch || (!looksLikeBlockchainName(routeOwnerRaw) ? routeOwnerRaw : '');
|
|
||||||
if (ownerLoginForLookup) {
|
|
||||||
try {
|
|
||||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
|
|
||||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
|
||||||
channel = ownerRows.find((item) => (
|
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
|
||||||
));
|
|
||||||
} catch {
|
|
||||||
// ignore owner feed lookup failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
||||||
throw new Error('Канал не найден.');
|
throw new Error('Канал не найден.');
|
||||||
}
|
}
|
||||||
@ -634,12 +468,12 @@ async function loadFromApi(route, channelId) {
|
|||||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
|
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
let reverseChannelMissingWarning = '';
|
let reverseChannelMissingWarning = '';
|
||||||
let mergedMessages = [...messages];
|
let mergedMessages = [...messages];
|
||||||
|
|
||||||
const currentLogin = currentSessionLogin;
|
const currentLogin = String(state.session.login || '').trim();
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
const channelName = String(payload.channel?.channelName || '').trim();
|
const channelName = String(payload.channel?.channelName || '').trim();
|
||||||
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
|
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
|
||||||
@ -665,7 +499,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
||||||
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
||||||
};
|
};
|
||||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
|
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login);
|
||||||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||||||
mergedMessages = mergedMessages.concat(reverseMessages);
|
mergedMessages = mergedMessages.concat(reverseMessages);
|
||||||
} else {
|
} else {
|
||||||
@ -683,9 +517,9 @@ async function loadFromApi(route, channelId) {
|
|||||||
return aNum - bNum;
|
return aNum - bNum;
|
||||||
})
|
})
|
||||||
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
||||||
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase();
|
const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase();
|
||||||
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
|
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
|
||||||
const isSubscribed = isAuthorized && followedRows.some((row) => (
|
const isSubscribed = followedRows.some((row) => (
|
||||||
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
|
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
|
||||||
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
|
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
|
||||||
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
|
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
|
||||||
@ -747,31 +581,17 @@ function renderDemoFallback(screen, navigate, error) {
|
|||||||
screen.append(back);
|
screen.append(back);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollChannelToBottom(screen, smooth = true) {
|
function applyPendingScroll(screen, routeKey) {
|
||||||
const feed = screen.querySelector('.channel-feed');
|
|
||||||
if (feed) {
|
|
||||||
feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
|
||||||
}
|
|
||||||
const appScreen = document.getElementById('app-screen');
|
|
||||||
if (appScreen) {
|
|
||||||
appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPendingScroll(screen, routeKey, forceBottom = false) {
|
|
||||||
const target = pendingScrollByRoute.get(routeKey);
|
const target = pendingScrollByRoute.get(routeKey);
|
||||||
if (!target && !forceBottom) return;
|
if (!target) return;
|
||||||
|
|
||||||
const doScroll = () => {
|
const doScroll = () => {
|
||||||
if (!target && forceBottom) {
|
|
||||||
scrollChannelToBottom(screen, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target === '__LAST__') {
|
if (target === '__LAST__') {
|
||||||
scrollChannelToBottom(screen, true);
|
const cards = screen.querySelectorAll('[data-message-key]');
|
||||||
|
const last = cards[cards.length - 1];
|
||||||
|
if (last) {
|
||||||
|
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
pendingScrollByRoute.delete(routeKey);
|
pendingScrollByRoute.delete(routeKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -792,18 +612,16 @@ function renderPostCard(post, {
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onReply,
|
onReply,
|
||||||
onShare,
|
onShare,
|
||||||
onEdit,
|
|
||||||
}) {
|
}) {
|
||||||
const versionsTotal = Number(post?.versionsTotal || 1);
|
|
||||||
|
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack channel-message-card';
|
card.className = 'card stack channel-message-card';
|
||||||
|
|
||||||
const authorTile = document.createElement('button');
|
const topRow = document.createElement('div');
|
||||||
authorTile.type = 'button';
|
topRow.className = 'channel-message-top';
|
||||||
authorTile.className = 'channel-message-author-tile';
|
|
||||||
|
|
||||||
const avatar = createMessageAvatar(post.authorLogin);
|
const avatar = document.createElement('div');
|
||||||
|
avatar.className = 'channel-message-avatar';
|
||||||
|
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
|
||||||
|
|
||||||
const authorBlock = document.createElement('div');
|
const authorBlock = document.createElement('div');
|
||||||
authorBlock.className = 'channel-message-author';
|
authorBlock.className = 'channel-message-author';
|
||||||
@ -823,37 +641,14 @@ function renderPostCard(post, {
|
|||||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||||
|
|
||||||
title.append(loginEl, numberEl);
|
title.append(loginEl, numberEl);
|
||||||
if (versionsTotal > 1) {
|
|
||||||
const editedMarker = document.createElement('button');
|
|
||||||
editedMarker.type = 'button';
|
|
||||||
editedMarker.className = 'message-edited-marker';
|
|
||||||
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
|
|
||||||
editedMarker.title = 'Открыть историю редактирования';
|
|
||||||
editedMarker.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openMessageHistoryModal({
|
|
||||||
title: `История #${post.localNumber}`,
|
|
||||||
versions: post.versions,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
title.append(editedMarker);
|
|
||||||
}
|
|
||||||
authorBlock.append(title, timestamp);
|
authorBlock.append(title, timestamp);
|
||||||
authorTile.append(avatar, authorBlock);
|
topRow.append(avatar, authorBlock);
|
||||||
authorTile.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const cleanLogin = String(post.authorLogin || '').trim();
|
|
||||||
if (!cleanLogin) return;
|
|
||||||
navigate(makeProfileRoute(cleanLogin));
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
|
||||||
const body = document.createElement('p');
|
const body = document.createElement('p');
|
||||||
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
|
body.className = 'channel-message-body';
|
||||||
body.textContent = isDeletedMessage ? 'Сообщение удалено' : post.body;
|
body.textContent = post.body;
|
||||||
|
|
||||||
card.append(authorTile, body);
|
card.append(topRow, body);
|
||||||
|
|
||||||
const refKey = messageRefKey(post.messageRef);
|
const refKey = messageRefKey(post.messageRef);
|
||||||
if (refKey) {
|
if (refKey) {
|
||||||
@ -881,7 +676,6 @@ function renderPostCard(post, {
|
|||||||
`;
|
`;
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
if (!isLiked) {
|
if (!isLiked) {
|
||||||
@ -904,7 +698,6 @@ function renderPostCard(post, {
|
|||||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||||
`;
|
`;
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
openReplyModal({
|
openReplyModal({
|
||||||
navigate,
|
navigate,
|
||||||
@ -913,6 +706,19 @@ function renderPostCard(post, {
|
|||||||
});
|
});
|
||||||
actions.append(likeButton, replyButton);
|
actions.append(likeButton, replyButton);
|
||||||
|
|
||||||
|
const openThreadButton = document.createElement('button');
|
||||||
|
openThreadButton.type = 'button';
|
||||||
|
openThreadButton.className = 'channel-action-item channel-action-thread';
|
||||||
|
openThreadButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">#</span>
|
||||||
|
<span class="channel-action-label">Тред</span>
|
||||||
|
`;
|
||||||
|
openThreadButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
const route = buildThreadRoute(post.messageRef, selector);
|
||||||
|
if (route) navigate(route);
|
||||||
|
});
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'channel-action-item channel-action-share';
|
shareButton.className = 'channel-action-item channel-action-share';
|
||||||
@ -927,46 +733,49 @@ function renderPostCard(post, {
|
|||||||
await onShare(route);
|
await onShare(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(shareButton);
|
actions.append(openThreadButton, shareButton);
|
||||||
if (post.isOwnMessage) {
|
|
||||||
const editButton = document.createElement('button');
|
|
||||||
editButton.type = 'button';
|
|
||||||
editButton.className = 'channel-action-item';
|
|
||||||
editButton.setAttribute('aria-label', 'Редактировать');
|
|
||||||
editButton.title = 'Редактировать';
|
|
||||||
editButton.innerHTML = `
|
|
||||||
<span class="channel-action-icon" aria-hidden="true">✏️</span>
|
|
||||||
`;
|
|
||||||
editButton.addEventListener('click', (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openEditMessageModal({
|
|
||||||
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
|
|
||||||
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
|
|
||||||
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
actions.append(editButton);
|
|
||||||
}
|
|
||||||
card.append(actions);
|
card.append(actions);
|
||||||
card.addEventListener('click', () => {
|
|
||||||
const route = buildThreadRoute(post.messageRef, selector);
|
|
||||||
if (route) navigate(route);
|
|
||||||
});
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'card channel-head-card';
|
||||||
|
|
||||||
|
const title = document.createElement('strong');
|
||||||
|
title.className = 'channel-head-title';
|
||||||
|
title.textContent = String(channelData.channel.name || '').trim();
|
||||||
|
|
||||||
|
const owner = document.createElement('p');
|
||||||
|
owner.className = 'channel-head-meta';
|
||||||
|
owner.textContent = `Владелец: ${channelData.channel.ownerName}`;
|
||||||
|
|
||||||
|
const headActions = document.createElement('div');
|
||||||
|
headActions.className = 'channel-head-actions';
|
||||||
|
const aboutButton = document.createElement('button');
|
||||||
|
aboutButton.type = 'button';
|
||||||
|
aboutButton.className = 'secondary-btn small-btn';
|
||||||
|
aboutButton.textContent = 'О канале';
|
||||||
|
aboutButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openAboutChannelModal(channelData.channel);
|
||||||
|
});
|
||||||
|
headActions.append(aboutButton);
|
||||||
|
|
||||||
|
head.append(title);
|
||||||
|
head.append(owner, headActions);
|
||||||
if (channelData.reverseChannelMissingWarning) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
reverseWarning.className = 'channel-head-meta';
|
reverseWarning.className = 'channel-head-meta';
|
||||||
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
|
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
|
||||||
screen.append(reverseWarning);
|
head.append(reverseWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionButton = document.createElement('button');
|
const actionButton = document.createElement('button');
|
||||||
actionButton.className = 'destructive-btn channel-main-action';
|
actionButton.className = channelData.isOwnChannel
|
||||||
actionButton.textContent = 'Подписаться на канал';
|
? 'primary-btn channel-main-action'
|
||||||
|
: 'destructive-btn channel-main-action';
|
||||||
|
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
|
||||||
|
|
||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack channel-feed';
|
feed.className = 'stack channel-feed';
|
||||||
@ -980,7 +789,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
onToggleLike: handlers.onToggleLike,
|
onToggleLike: handlers.onToggleLike,
|
||||||
onReply: handlers.onReply,
|
onReply: handlers.onReply,
|
||||||
onShare: handlers.onShare,
|
onShare: handlers.onShare,
|
||||||
onEdit: handlers.onEdit,
|
|
||||||
});
|
});
|
||||||
const key = messageRefKey(post.messageRef);
|
const key = messageRefKey(post.messageRef);
|
||||||
if (key) {
|
if (key) {
|
||||||
@ -996,7 +804,16 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!channelData.isSubscribed) {
|
if (channelData.isOwnChannel) {
|
||||||
|
actionButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openAddMessageModal({
|
||||||
|
channelName: channelData.channel.name,
|
||||||
|
navigate,
|
||||||
|
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (!channelData.isSubscribed) {
|
||||||
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1005,15 +822,13 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
backButton.textContent = 'Назад к каналам';
|
backButton.textContent = 'Назад к каналам';
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
||||||
screen.append(feed);
|
screen.append(head, actionButton, feed, backButton);
|
||||||
} else if (!channelData.isSubscribed) {
|
|
||||||
screen.append(actionButton, feed, backButton);
|
|
||||||
} else {
|
} else {
|
||||||
screen.append(feed, backButton);
|
screen.append(head, feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
|
applyPendingScroll(screen, routeKey);
|
||||||
return () => {
|
return () => {
|
||||||
// noop
|
// noop
|
||||||
};
|
};
|
||||||
@ -1051,17 +866,6 @@ export function render({ navigate, route }) {
|
|||||||
statusBox.style.display = '';
|
statusBox.style.display = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const header = renderHeader({
|
|
||||||
title: '',
|
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
|
||||||
rightActions: [{ label: 'Канал: ...', onClick: () => {} }],
|
|
||||||
});
|
|
||||||
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
|
|
||||||
if (channelHeaderButton) {
|
|
||||||
channelHeaderButton.classList.add('channel-header-route-btn');
|
|
||||||
channelHeaderButton.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rerender = () => {
|
const rerender = () => {
|
||||||
const current = document.querySelector('section.channels-screen--channel');
|
const current = document.querySelector('section.channels-screen--channel');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@ -1074,7 +878,7 @@ export function render({ navigate, route }) {
|
|||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
if (!login || !storagePwd) {
|
if (!login || !storagePwd) {
|
||||||
state.authReturnHash = window.location.pathname || '/channels-list';
|
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||||
navigate('login-view');
|
navigate('login-view');
|
||||||
throw new Error('Для этого действия нужно войти');
|
throw new Error('Для этого действия нужно войти');
|
||||||
}
|
}
|
||||||
@ -1159,25 +963,12 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEditPost = async (messageRef, text) => {
|
screen.append(
|
||||||
const { login, storagePwd } = requireSigningSession();
|
renderHeader({
|
||||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
title: '',
|
||||||
throw new Error('Идентификатор канала не готов.');
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
}
|
}),
|
||||||
await authService.addBlockEditMessage({
|
);
|
||||||
login,
|
|
||||||
storagePwd,
|
|
||||||
message: messageRef,
|
|
||||||
text,
|
|
||||||
isChannelPost: true,
|
|
||||||
channel: activeSelector,
|
|
||||||
});
|
|
||||||
softHaptic(12);
|
|
||||||
showToast('Сообщение обновлено');
|
|
||||||
rerender();
|
|
||||||
};
|
|
||||||
|
|
||||||
screen.append(header);
|
|
||||||
screen.append(statusBox);
|
screen.append(statusBox);
|
||||||
|
|
||||||
const skeleton = renderSkeleton(screen);
|
const skeleton = renderSkeleton(screen);
|
||||||
@ -1188,41 +979,6 @@ export function render({ navigate, route }) {
|
|||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
activeSelector = apiData?.selector || null;
|
||||||
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
|
|
||||||
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
|
|
||||||
if (channelHeaderButton) {
|
|
||||||
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
|
|
||||||
channelHeaderButton.disabled = false;
|
|
||||||
channelHeaderButton.onclick = (event) => {
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openAboutChannelModal(apiData.channel);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (apiData?.isOwnChannel) {
|
|
||||||
const headerActions = header.querySelector('.header-actions');
|
|
||||||
if (headerActions) {
|
|
||||||
const addBtn = document.createElement('button');
|
|
||||||
addBtn.type = 'button';
|
|
||||||
addBtn.className = 'icon-btn channel-header-add-btn';
|
|
||||||
addBtn.textContent = 'Добавить сообщение';
|
|
||||||
addBtn.addEventListener('click', (event) => {
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openAddMessageModal({
|
|
||||||
channelName: apiData?.channel?.name || '',
|
|
||||||
navigate,
|
|
||||||
onSubmit: async (bodyText) => {
|
|
||||||
try {
|
|
||||||
await onAddPost(bodyText);
|
|
||||||
showStatus('');
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
headerActions.append(addBtn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||||
onToggleLike: async (messageRef, action) => {
|
onToggleLike: async (messageRef, action) => {
|
||||||
@ -1250,14 +1006,6 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShare: onShare,
|
onShare: onShare,
|
||||||
onEdit: async (messageRef, text) => {
|
|
||||||
try {
|
|
||||||
await onEditPost(messageRef, text);
|
|
||||||
showStatus('');
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(toUserMessage(error, 'Не удалось изменить сообщение.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSubscribeChannel: async (event) => {
|
onSubscribeChannel: async (event) => {
|
||||||
animatePress(event?.currentTarget);
|
animatePress(event?.currentTarget);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
softHaptic,
|
softHaptic,
|
||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
|
||||||
@ -18,7 +17,7 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
|||||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||||
const CHANNEL_TYPE_STORIES = 0;
|
const CHANNEL_TYPE_STORIES = 0;
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
const TAB_ORDER = ['feed', 'my'];
|
const TAB_ORDER = ['dialogs', 'feed', 'my'];
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -44,14 +43,12 @@ function normalizeLoginInput(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
|
||||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||||
return makeShineChannelRoute({
|
if (ownerBch && channelName) {
|
||||||
ownerLogin,
|
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`;
|
||||||
ownerBlockchainName: ownerBch,
|
}
|
||||||
channelName: channelName || fallbackId,
|
return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatarLetterFromName(name = '') {
|
function avatarLetterFromName(name = '') {
|
||||||
@ -411,7 +408,7 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
||||||
<h3 class="modal-title">Поиск каналов</h3>
|
<h3 class="modal-title">Поиск каналов</h3>
|
||||||
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
|
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
|
||||||
<input id="channels-find-input" class="input" placeholder="Например: aidar" autocomplete="off" />
|
<input id="channels-find-input" class="input" placeholder="Например: aid" autocomplete="off" />
|
||||||
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
||||||
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
||||||
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
||||||
@ -466,12 +463,8 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
openBtn.textContent = 'Просмотреть';
|
openBtn.textContent = 'Просмотреть';
|
||||||
openBtn.addEventListener('click', () => {
|
openBtn.addEventListener('click', () => {
|
||||||
close();
|
close();
|
||||||
const route = makeShineChannelRoute({
|
const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim();
|
||||||
ownerLogin: String(item.ownerLogin || '').trim(),
|
navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`);
|
||||||
ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
|
|
||||||
channelName: String(item.channelName || '').trim(),
|
|
||||||
});
|
|
||||||
if (route) navigate(route);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
row.style.display = 'flex';
|
row.style.display = 'flex';
|
||||||
@ -589,13 +582,11 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
function mapMockGroups() {
|
function mapMockGroups() {
|
||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...channel,
|
...channel,
|
||||||
route: makeShineChannelRoute({
|
route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`,
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
tabCategory: channel.kind === 'own'
|
||||||
ownerBlockchainName: String(channel.ownerName || ''),
|
|
||||||
channelName: String(channel.channelName || channel.title || channel.id),
|
|
||||||
}),
|
|
||||||
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal'
|
|
||||||
? 'my'
|
? 'my'
|
||||||
|
: channel.kind === 'own-personal'
|
||||||
|
? 'dialogs'
|
||||||
: 'feed',
|
: 'feed',
|
||||||
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
||||||
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
||||||
@ -634,7 +625,9 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
|||||||
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||||
const isOwn = bucketKey === 'own';
|
const isOwn = bucketKey === 'own';
|
||||||
const tabCategory = isOwn ? 'my' : 'feed';
|
const tabCategory = isOwn
|
||||||
|
? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
|
||||||
|
: 'feed';
|
||||||
|
|
||||||
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
|
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
|
||||||
|
|
||||||
@ -694,13 +687,12 @@ function toListModel(groups) {
|
|||||||
function renderEmptyState(activeTab, navigate) {
|
function renderEmptyState(activeTab, navigate) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||||
if (!state.session.isAuthorized) {
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
const text = document.createElement('p');
|
const text = document.createElement('p');
|
||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
if (activeTab === 'feed') {
|
if (activeTab === 'feed') {
|
||||||
text.textContent = 'Нет подписок и найденных каналов.';
|
text.textContent = 'Нет подписок и найденных каналов.';
|
||||||
|
} else if (activeTab === 'dialogs') {
|
||||||
|
text.textContent = 'У вас пока нет персональных каналов.';
|
||||||
} else if (activeTab === 'my') {
|
} else if (activeTab === 'my') {
|
||||||
text.textContent = 'У вас пока нет каналов.';
|
text.textContent = 'У вас пока нет каналов.';
|
||||||
} else {
|
} else {
|
||||||
@ -777,14 +769,7 @@ function renderDemoFallback(container, navigate, error, onRetry) {
|
|||||||
<span class="channel-row-time">—</span>
|
<span class="channel-row-time">—</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
|
||||||
const route = channel.route || makeShineChannelRoute({
|
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
|
||||||
ownerBlockchainName: String(channel.ownerName || ''),
|
|
||||||
channelName: String(channel.channelName || channel.id),
|
|
||||||
});
|
|
||||||
if (route) navigate(route);
|
|
||||||
});
|
|
||||||
list.append(row);
|
list.append(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -968,7 +953,7 @@ function renderChannelMain(channel, activeTab) {
|
|||||||
title.className = 'channel-row-title';
|
title.className = 'channel-row-title';
|
||||||
title.textContent = activeTab === 'my' ? channel.channelName : channel.title;
|
title.textContent = activeTab === 'my' ? channel.channelName : channel.title;
|
||||||
|
|
||||||
if (activeTab === 'my' && channel.channelDescription) {
|
if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) {
|
||||||
const desc = document.createElement('p');
|
const desc = document.createElement('p');
|
||||||
desc.className = 'channel-row-description';
|
desc.className = 'channel-row-description';
|
||||||
desc.textContent = channel.channelDescription;
|
desc.textContent = channel.channelDescription;
|
||||||
@ -1017,21 +1002,9 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
|
|
||||||
const main = renderChannelMain(channel, activeTab);
|
const main = renderChannelMain(channel, activeTab);
|
||||||
|
|
||||||
const isGuest = !state.session.isAuthorized;
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
controls.className = 'channel-row-controls';
|
||||||
|
|
||||||
const time = document.createElement('span');
|
|
||||||
time.className = 'channel-row-time';
|
|
||||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
|
|
||||||
|
|
||||||
const count = document.createElement('span');
|
|
||||||
count.className = 'unread channel-row-count';
|
|
||||||
const unreadCount = Number(channel.unreadCount || 0);
|
|
||||||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
|
||||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
|
||||||
|
|
||||||
if (!isGuest) {
|
|
||||||
const menuButton = document.createElement('button');
|
const menuButton = document.createElement('button');
|
||||||
menuButton.type = 'button';
|
menuButton.type = 'button';
|
||||||
menuButton.className = 'channel-menu-trigger';
|
menuButton.className = 'channel-menu-trigger';
|
||||||
@ -1057,19 +1030,21 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
});
|
});
|
||||||
rerenderList();
|
rerenderList();
|
||||||
});
|
});
|
||||||
controls.append(menuButton);
|
|
||||||
}
|
const time = document.createElement('span');
|
||||||
controls.append(time, count);
|
time.className = 'channel-row-time';
|
||||||
|
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
|
||||||
|
|
||||||
|
const count = document.createElement('span');
|
||||||
|
count.className = 'unread channel-row-count';
|
||||||
|
const unreadCount = Number(channel.unreadCount || 0);
|
||||||
|
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
|
count.classList.toggle('is-empty', unreadCount <= 0);
|
||||||
|
|
||||||
|
controls.append(menuButton, time, count);
|
||||||
|
|
||||||
row.append(avatar, main, controls);
|
row.append(avatar, main, controls);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`));
|
||||||
const route = channel.route || makeShineChannelRoute({
|
|
||||||
ownerLogin: String(channel.ownerName || 'channel'),
|
|
||||||
ownerBlockchainName: String(channel.ownerName || ''),
|
|
||||||
channelName: String(channel.channelName || channel.id),
|
|
||||||
});
|
|
||||||
if (route) navigate(route);
|
|
||||||
});
|
|
||||||
list.append(row);
|
list.append(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1087,10 +1062,17 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab === 'my') {
|
if (tab === 'dialogs') {
|
||||||
button.textContent = 'Найти канал';
|
button.textContent = 'Новый персональный публичный чат';
|
||||||
button.className = baseClass;
|
button.className = baseClass;
|
||||||
button.onclick = () => openChannelFinderModal({ navigate });
|
button.onclick = () => navigate('add-personal-public-chat-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'my') {
|
||||||
|
button.textContent = 'Создать канал';
|
||||||
|
button.className = baseClass;
|
||||||
|
button.onclick = () => navigate('add-channel-view');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1103,20 +1085,8 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
|||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
renderSkeletonList(contentEl, 5);
|
renderSkeletonList(contentEl, 5);
|
||||||
|
|
||||||
if (!state.session.isAuthorized) {
|
|
||||||
setChannelsFeed(null, {});
|
|
||||||
listState.channels = [];
|
|
||||||
renderListContent({
|
|
||||||
screen,
|
|
||||||
container: contentEl,
|
|
||||||
listState,
|
|
||||||
navigate,
|
|
||||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!state.session.login) throw new Error('not_authorized');
|
||||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||||
const groups = mapApiFeed(feed, listState.notificationsState);
|
const groups = mapApiFeed(feed, listState.notificationsState);
|
||||||
|
|
||||||
@ -1152,7 +1122,6 @@ export function render({ navigate, route }) {
|
|||||||
const createSuccessFlash = pullCreateSuccessFlash();
|
const createSuccessFlash = pullCreateSuccessFlash();
|
||||||
const notificationsState = readChannelNotificationsState();
|
const notificationsState = readChannelNotificationsState();
|
||||||
|
|
||||||
const isGuest = !state.session.isAuthorized;
|
|
||||||
const listState = {
|
const listState = {
|
||||||
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
|
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
|
||||||
? String(route?.params?.mode).trim()
|
? String(route?.params?.mode).trim()
|
||||||
@ -1163,24 +1132,18 @@ export function render({ navigate, route }) {
|
|||||||
channels: [],
|
channels: [],
|
||||||
menuCleanup: null,
|
menuCleanup: null,
|
||||||
};
|
};
|
||||||
if (isGuest && listState.activeTab === 'my') {
|
|
||||||
listState.activeTab = 'feed';
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentEl = document.createElement('div');
|
const contentEl = document.createElement('div');
|
||||||
contentEl.className = 'channels-list-content';
|
contentEl.className = 'channels-list-content';
|
||||||
|
|
||||||
const topBarEl = document.createElement('div');
|
|
||||||
topBarEl.className = 'channels-top-bar';
|
|
||||||
|
|
||||||
const tabsEl = document.createElement('div');
|
const tabsEl = document.createElement('div');
|
||||||
tabsEl.className = 'channels-tabs';
|
tabsEl.className = 'channels-tabs';
|
||||||
const tabLabels = {
|
const tabLabels = {
|
||||||
feed: 'Каналы',
|
feed: 'Каналы',
|
||||||
my: 'Мои каналы',
|
dialogs: 'Чаты',
|
||||||
|
my: 'Мои',
|
||||||
};
|
};
|
||||||
TAB_ORDER.forEach((tabKey) => {
|
TAB_ORDER.forEach((tabKey) => {
|
||||||
if (isGuest && tabKey === 'my') return;
|
|
||||||
const tabBtn = document.createElement('button');
|
const tabBtn = document.createElement('button');
|
||||||
tabBtn.type = 'button';
|
tabBtn.type = 'button';
|
||||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
||||||
@ -1193,15 +1156,6 @@ export function render({ navigate, route }) {
|
|||||||
tabsEl.append(tabBtn);
|
tabsEl.append(tabBtn);
|
||||||
});
|
});
|
||||||
|
|
||||||
const topActionBtn = document.createElement('button');
|
|
||||||
topActionBtn.type = 'button';
|
|
||||||
topActionBtn.className = 'secondary-btn channels-top-action-btn';
|
|
||||||
topActionBtn.textContent = 'Создать канал';
|
|
||||||
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
|
|
||||||
if (isGuest) topActionBtn.style.display = 'none';
|
|
||||||
|
|
||||||
topBarEl.append(tabsEl, topActionBtn);
|
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
const bottomCta = document.createElement('button');
|
||||||
bottomCta.type = 'button';
|
bottomCta.type = 'button';
|
||||||
|
|
||||||
@ -1209,9 +1163,9 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
const rerenderList = () => {
|
const rerenderList = () => {
|
||||||
try {
|
try {
|
||||||
const expectedPath = `/channels-list/${listState.activeTab}`;
|
const expectedHash = `#/channels-list/${listState.activeTab}`;
|
||||||
if (window.location.pathname !== expectedPath) {
|
if (window.location.hash !== expectedHash) {
|
||||||
window.history.replaceState({}, '', expectedPath);
|
window.history.replaceState({}, '', expectedHash);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore history errors
|
// ignore history errors
|
||||||
@ -1229,9 +1183,6 @@ export function render({ navigate, route }) {
|
|||||||
refreshFeed: reloadFeed,
|
refreshFeed: reloadFeed,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showCreate = !isGuest && listState.activeTab === 'my';
|
|
||||||
topActionBtn.style.display = showCreate ? '' : 'none';
|
|
||||||
|
|
||||||
updateBottomCta({
|
updateBottomCta({
|
||||||
button: bottomCta,
|
button: bottomCta,
|
||||||
listState,
|
listState,
|
||||||
@ -1265,7 +1216,7 @@ export function render({ navigate, route }) {
|
|||||||
rerenderList();
|
rerenderList();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
screen.append(topBarEl, contentEl, bottomCta);
|
screen.append(tabsEl, contentEl, bottomCta);
|
||||||
|
|
||||||
if (createSuccessFlash) {
|
if (createSuccessFlash) {
|
||||||
showToast(createSuccessFlash);
|
showToast(createSuccessFlash);
|
||||||
|
|||||||
@ -10,108 +10,14 @@ import {
|
|||||||
markOutgoingSent,
|
markOutgoingSent,
|
||||||
markReadReceiptSentByBaseKey,
|
markReadReceiptSentByBaseKey,
|
||||||
authService,
|
authService,
|
||||||
setContacts,
|
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { startOutgoingCall } from '../services/call-service.js';
|
import { startOutgoingCall } from '../services/call-service.js';
|
||||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||||
import { showToast } from '../services/channels-ux.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
|
|
||||||
function openMessageActionsModal({ messageText = '', onReadAloud }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
if (!root) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="chat-message-actions-modal-overlay">
|
|
||||||
<div class="modal-card stack dm-dialog-card dm-message-actions-menu" id="chat-message-actions-modal">
|
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Копировать</button>
|
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => {
|
|
||||||
if (event.target?.id === 'chat-message-actions-modal-overlay') close();
|
|
||||||
});
|
|
||||||
root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
if (navigator?.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(String(messageText || ''));
|
|
||||||
}
|
|
||||||
showToast('Сообщение скопированно', { timeoutMs: 1000 });
|
|
||||||
} catch {
|
|
||||||
showToast('Не удалось скопировать сообщение', { kind: 'error', timeoutMs: 1200 });
|
|
||||||
} finally {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
|
|
||||||
close();
|
|
||||||
if (typeof onReadAloud === 'function') await onReadAloud();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTtsMissingConfigDialog(navigate) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
if (!root) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="chat-tts-missing-modal">
|
|
||||||
<div class="modal-card stack dm-dialog-card">
|
|
||||||
<h3 class="modal-title">Озвучка не настроена</h3>
|
|
||||||
<p class="meta-muted">Перейти в настройки инструментов?</p>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" type="button" id="chat-tts-no">Нет</button>
|
|
||||||
<button class="primary-btn" type="button" id="chat-tts-yes">Да</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const close = () => { root.innerHTML = ''; };
|
|
||||||
root.querySelector('#chat-tts-no')?.addEventListener('click', close);
|
|
||||||
root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
|
|
||||||
close();
|
|
||||||
navigate('tools-settings-view');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoResizeComposer(textarea) {
|
|
||||||
if (!textarea) return;
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openConfirmContactModal(targetLogin = '') {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
if (!root) return Promise.resolve(false);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="contact-confirm-modal">
|
|
||||||
<div class="modal-card stack dm-dialog-card">
|
|
||||||
<h3 class="modal-title">Добавить собеседника</h3>
|
|
||||||
<p class="meta-muted">Добавить пользователя @${targetLogin} в контакты?</p>
|
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="secondary-btn" type="button" id="contact-confirm-no">Нет</button>
|
|
||||||
<button class="primary-btn" type="button" id="contact-confirm-yes">Да</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const close = (answer) => {
|
|
||||||
root.innerHTML = '';
|
|
||||||
resolve(!!answer);
|
|
||||||
};
|
|
||||||
root.querySelector('#contact-confirm-no')?.addEventListener('click', () => close(false));
|
|
||||||
root.querySelector('#contact-confirm-yes')?.addEventListener('click', () => close(true));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBaseKey(baseKey) {
|
function parseBaseKey(baseKey) {
|
||||||
const raw = String(baseKey || '').trim();
|
const raw = String(baseKey || '').trim();
|
||||||
const parts = raw.split('|');
|
const parts = raw.split('|');
|
||||||
@ -172,14 +78,11 @@ function scrollToLatestMessage(list) {
|
|||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
window.requestAnimationFrame(apply);
|
window.requestAnimationFrame(apply);
|
||||||
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
|
|
||||||
window.setTimeout(apply, 0);
|
window.setTimeout(apply, 0);
|
||||||
window.setTimeout(apply, 60);
|
|
||||||
window.setTimeout(apply, 120);
|
window.setTimeout(apply, 120);
|
||||||
window.setTimeout(apply, 260);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLog(list, chatId, { onOpenActions } = {}) {
|
function renderLog(list, chatId) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const messages = getChatMessages(chatId);
|
const messages = getChatMessages(chatId);
|
||||||
let unreadSeparatorInserted = false;
|
let unreadSeparatorInserted = false;
|
||||||
@ -219,9 +122,6 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bubble.append(textNode, metaNode);
|
bubble.append(textNode, metaNode);
|
||||||
bubble.addEventListener('click', () => {
|
|
||||||
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
|
||||||
});
|
|
||||||
list.append(bubble);
|
list.append(bubble);
|
||||||
});
|
});
|
||||||
scrollToLatestMessage(list);
|
scrollToLatestMessage(list);
|
||||||
@ -242,42 +142,20 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: `Чат с ${contact.name}`,
|
title: `Чат: ${contact.name}`,
|
||||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||||
rightActions: [{
|
rightActions: [{
|
||||||
label: 'Позвонить',
|
label: 'Позвонить',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await startOutgoingCall(chatId);
|
await startOutgoingCall(chatId);
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId);
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
|
||||||
messageText: msg?.text || '',
|
|
||||||
onReadAloud: async () => {
|
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
||||||
showTtsMissingConfigDialog(navigate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||||
from: 'out',
|
from: 'out',
|
||||||
kind: 'call-tech',
|
kind: 'call-tech',
|
||||||
});
|
});
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId);
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
|
||||||
messageText: msg?.text || '',
|
|
||||||
onReadAloud: async () => {
|
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
||||||
showTtsMissingConfigDialog(navigate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
@ -290,26 +168,18 @@ export function render({ navigate, route }) {
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'secondary-btn';
|
btn.className = 'secondary-btn';
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.textContent = 'Добавить собеседника в контакты';
|
btn.textContent = 'Добавить в контакты';
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const approved = await openConfirmContactModal(chatId);
|
await authService.addCloseFriend(chatId);
|
||||||
if (!approved) return;
|
state.contacts = [...new Set([...(state.contacts || []), chatId])];
|
||||||
await authService.setUserRelation({
|
|
||||||
login: state.session.login,
|
|
||||||
toLogin: chatId,
|
|
||||||
kind: 'contact',
|
|
||||||
enabled: true,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
const contactsPayload = await authService.listContacts();
|
|
||||||
setContacts(contactsPayload?.contacts || []);
|
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'contacts',
|
source: 'contacts',
|
||||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
message: `Пользователь ${chatId} добавлен в контакты`,
|
||||||
});
|
});
|
||||||
card.remove();
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Добавлено';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
@ -332,30 +202,52 @@ export function render({ navigate, route }) {
|
|||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.className = 'chat-input dm-chat-input';
|
form.className = 'chat-input dm-chat-input';
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea>
|
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
||||||
<div class="dm-actions-col">
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input">🎤</button>
|
||||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-read-aloud">🔊</button>
|
||||||
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const sendTextMessage = async (rawText) => {
|
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
||||||
const text = String(rawText || '').trim();
|
const input = form.elements.message;
|
||||||
if (!text) return;
|
await openSpeechInputModal({
|
||||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
navigate,
|
||||||
renderLog(log, chatId, {
|
onTextReady: (text) => {
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
const prev = String(input.value || '').trim();
|
||||||
messageText: msg?.text || '',
|
input.value = prev ? `${prev} ${text}` : text;
|
||||||
onReadAloud: async () => {
|
},
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
});
|
||||||
showTtsMissingConfigDialog(navigate);
|
});
|
||||||
|
|
||||||
|
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
|
||||||
|
const input = form.elements.message;
|
||||||
|
const text = String(input.value || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
window.alert('Введите текст для озвучки.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
},
|
const goSettings = window.confirm('Озвучка не настроена. Перейти в настройки инструментов?');
|
||||||
}),
|
if (goSettings) navigate('tools-settings-view');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await speakTextBySettings(text, state.entrySettings);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = form.elements.message;
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const tempId = addOutgoingPendingMessage(chatId, text);
|
||||||
|
input.value = '';
|
||||||
|
renderLog(log, chatId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authService.sendDirectMessage({
|
const result = await authService.sendDirectMessage({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
@ -367,18 +259,7 @@ export function render({ navigate, route }) {
|
|||||||
messageKey: result?.outgoingKey || '',
|
messageKey: result?.outgoingKey || '',
|
||||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||||
});
|
});
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId);
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
|
||||||
messageText: msg?.text || '',
|
|
||||||
onReadAloud: async () => {
|
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
||||||
showTtsMissingConfigDialog(navigate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'outgoing-dm',
|
source: 'outgoing-dm',
|
||||||
@ -401,92 +282,14 @@ export function render({ navigate, route }) {
|
|||||||
error: e?.message || 'unknown',
|
error: e?.message || 'unknown',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId);
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
|
||||||
messageText: msg?.text || '',
|
|
||||||
onReadAloud: async () => {
|
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
||||||
showTtsMissingConfigDialog(navigate);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const input = form.elements.message;
|
|
||||||
autoResizeComposer(input);
|
|
||||||
input?.addEventListener('input', () => autoResizeComposer(input));
|
|
||||||
input?.addEventListener('focus', () => {
|
|
||||||
scrollToLatestMessage(log);
|
|
||||||
});
|
|
||||||
input?.addEventListener('keydown', async (event) => {
|
|
||||||
if (event.key !== 'Enter') return;
|
|
||||||
if (event.ctrlKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
const start = Number(input.selectionStart ?? input.value.length);
|
|
||||||
const end = Number(input.selectionEnd ?? input.value.length);
|
|
||||||
const value = String(input.value || '');
|
|
||||||
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
|
|
||||||
const nextPos = start + 1;
|
|
||||||
try {
|
|
||||||
input.setSelectionRange(nextPos, nextPos);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
autoResizeComposer(input);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
const text = String(input.value || '').trim();
|
|
||||||
if (!text) return;
|
|
||||||
input.value = '';
|
|
||||||
autoResizeComposer(input);
|
|
||||||
await sendTextMessage(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
|
||||||
await openSpeechInputModal({
|
|
||||||
navigate,
|
|
||||||
onTextReady: (text) => {
|
|
||||||
const prev = String(input.value || '').trim();
|
|
||||||
input.value = prev ? `${prev} ${text}` : text;
|
|
||||||
autoResizeComposer(input);
|
|
||||||
},
|
|
||||||
onSendText: async (text) => sendTextMessage(text),
|
|
||||||
onSendQueued: () => {
|
|
||||||
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
input.value = '';
|
|
||||||
autoResizeComposer(input);
|
|
||||||
await sendTextMessage(text);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
wrap.append(log, form);
|
wrap.append(log, form);
|
||||||
screen.append(wrap);
|
screen.append(wrap);
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId);
|
||||||
onOpenActions: (msg) => openMessageActionsModal({
|
|
||||||
messageText: msg?.text || '',
|
|
||||||
onReadAloud: async () => {
|
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
||||||
showTtsMissingConfigDialog(navigate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
|
||||||
void sendReadReceiptsForVisible(chatId);
|
void sendReadReceiptsForVisible(chatId);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +1,7 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService } from '../state.js';
|
import { authService } from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
||||||
const searchAvatarSnapshotCache = new Map();
|
|
||||||
const searchAvatarPendingByLogin = new Map();
|
|
||||||
|
|
||||||
async function loadSearchAvatarSnapshot(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
if (!cleanLogin) return null;
|
|
||||||
const key = cleanLogin.toLowerCase();
|
|
||||||
if (searchAvatarSnapshotCache.has(key)) return searchAvatarSnapshotCache.get(key);
|
|
||||||
if (searchAvatarPendingByLogin.has(key)) return searchAvatarPendingByLogin.get(key);
|
|
||||||
const pending = loadProfileSnapshot(cleanLogin)
|
|
||||||
.then((snapshot) => {
|
|
||||||
searchAvatarSnapshotCache.set(key, snapshot || null);
|
|
||||||
searchAvatarPendingByLogin.delete(key);
|
|
||||||
return snapshot || null;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
searchAvatarSnapshotCache.set(key, null);
|
|
||||||
searchAvatarPendingByLogin.delete(key);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
searchAvatarPendingByLogin.set(key, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSearchAvatar(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
|
||||||
const avatarEl = renderUserAvatar({
|
|
||||||
login: cleanLogin || 'unknown',
|
|
||||||
size: 'small',
|
|
||||||
className: 'avatar',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
if (!cleanLogin) return avatarEl;
|
|
||||||
void loadSearchAvatarSnapshot(cleanLogin).then((snapshot) => {
|
|
||||||
if (!avatarEl.isConnected) return;
|
|
||||||
const upgraded = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
avatar: snapshot?.avatar?.txId
|
|
||||||
? {
|
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
size: 'small',
|
|
||||||
className: 'avatar',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
avatarEl.replaceWith(upgraded);
|
|
||||||
});
|
|
||||||
return avatarEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@ -99,17 +44,16 @@ export function render({ navigate }) {
|
|||||||
matches.forEach((login) => {
|
matches.forEach((login) => {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
const avatarEl = createSearchAvatar(login);
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
|
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>${login}</strong>
|
<strong>${login}</strong>
|
||||||
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-muted">Профиль</div>
|
<div class="meta-muted">Профиль</div>
|
||||||
`;
|
`;
|
||||||
row.prepend(avatarEl);
|
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
navigate(makeProfileRoute(login));
|
navigate(`user-profile-view/${encodeURIComponent(login)}/contact-search-view`);
|
||||||
});
|
});
|
||||||
resultsList.append(row);
|
resultsList.append(row);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -184,8 +184,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {
|
|||||||
|
|
||||||
async function forceUiUpdateNow() {
|
async function forceUiUpdateNow() {
|
||||||
try {
|
try {
|
||||||
window.history.replaceState({}, '', '/settings-view');
|
window.location.hash = '#/settings-view';
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
||||||
} catch {}
|
} catch {}
|
||||||
if (!('serviceWorker' in navigator)) {
|
if (!('serviceWorker' in navigator)) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@ -8,72 +8,8 @@ import {
|
|||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const dmAvatarSnapshotCache = new Map();
|
|
||||||
const dmAvatarPendingByLogin = new Map();
|
|
||||||
|
|
||||||
async function loadDmAvatarSnapshot(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
if (!cleanLogin) return null;
|
|
||||||
const key = cleanLogin.toLowerCase();
|
|
||||||
if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key);
|
|
||||||
if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key);
|
|
||||||
const pending = loadProfileSnapshot(cleanLogin)
|
|
||||||
.then((snapshot) => {
|
|
||||||
dmAvatarSnapshotCache.set(key, snapshot || null);
|
|
||||||
dmAvatarPendingByLogin.delete(key);
|
|
||||||
return snapshot || null;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
dmAvatarSnapshotCache.set(key, null);
|
|
||||||
dmAvatarPendingByLogin.delete(key);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
dmAvatarPendingByLogin.set(key, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDmAvatar(login) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
|
||||||
const avatarEl = renderUserAvatar({
|
|
||||||
login: cleanLogin || 'unknown',
|
|
||||||
size: 'small',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
if (!cleanLogin) return avatarEl;
|
|
||||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
|
||||||
if (!avatarEl.isConnected) return;
|
|
||||||
const upgraded = renderUserAvatar({
|
|
||||||
login: cleanLogin,
|
|
||||||
avatar: snapshot?.avatar?.txId
|
|
||||||
? {
|
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
size: 'small',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
upgraded.classList.add('avatar');
|
|
||||||
avatarEl.replaceWith(upgraded);
|
|
||||||
});
|
|
||||||
return avatarEl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatChatRowTime(ts) {
|
|
||||||
const value = Number(ts || 0);
|
|
||||||
if (!Number.isFinite(value) || value <= 0) return '-';
|
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@ -93,22 +29,20 @@ export function render({ navigate }) {
|
|||||||
function renderRow(item) {
|
function renderRow(item) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
const avatarEl = createDmAvatar(item.id);
|
|
||||||
avatarEl.classList.add('avatar');
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="dm-row-main">
|
<div class="avatar">${item.initials}</div>
|
||||||
<div class="dm-row-title-wrap">
|
<div>
|
||||||
<strong class="dm-row-title">${item.name}</strong>
|
<div class="row" style="justify-content:flex-start; gap:8px;">
|
||||||
|
<strong>${item.name}</strong>
|
||||||
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
|
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p>
|
<p class="meta-muted" style="margin-top:4px;">${item.lastMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dm-row-meta-col">
|
<div style="display:grid; justify-items:end; gap:6px;">
|
||||||
|
<span class="meta-muted">${item.time}</span>
|
||||||
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
|
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
|
||||||
<span class="meta-muted dm-row-time">${item.time}</span>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
row.prepend(avatarEl);
|
|
||||||
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@ -125,12 +59,12 @@ export function render({ navigate }) {
|
|||||||
const chat = getChatMessages(login);
|
const chat = getChatMessages(login);
|
||||||
const lastChat = chat[chat.length - 1];
|
const lastChat = chat[chat.length - 1];
|
||||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
|
||||||
return {
|
return {
|
||||||
id: login,
|
id: login,
|
||||||
|
initials: (login[0] || '?').toUpperCase(),
|
||||||
name: preview?.name || login,
|
name: preview?.name || login,
|
||||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||||
time: formatChatRowTime(lastTimeMs),
|
time: preview?.time || '—',
|
||||||
unread,
|
unread,
|
||||||
notInContacts: false,
|
notInContacts: false,
|
||||||
};
|
};
|
||||||
@ -147,12 +81,12 @@ export function render({ navigate }) {
|
|||||||
const chat = getChatMessages(login);
|
const chat = getChatMessages(login);
|
||||||
const lastChat = chat[chat.length - 1];
|
const lastChat = chat[chat.length - 1];
|
||||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
|
||||||
return {
|
return {
|
||||||
id: login,
|
id: login,
|
||||||
|
initials: (login[0] || '?').toUpperCase(),
|
||||||
name: login,
|
name: login,
|
||||||
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
||||||
time: formatChatRowTime(lastTimeMs),
|
time: 'сейчас',
|
||||||
unread,
|
unread,
|
||||||
notInContacts: true,
|
notInContacts: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||||
|
|
||||||
@ -16,14 +14,6 @@ function normalizeLogin(value) {
|
|||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDebounced(fn, delayMs = 2000) {
|
|
||||||
let timer = 0;
|
|
||||||
return (...args) => {
|
|
||||||
if (timer) window.clearTimeout(timer);
|
|
||||||
timer = window.setTimeout(() => fn(...args), delayMs);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normKey(value) {
|
function normKey(value) {
|
||||||
return normalizeLogin(value).toLowerCase();
|
return normalizeLogin(value).toLowerCase();
|
||||||
}
|
}
|
||||||
@ -517,7 +507,6 @@ let persistedCenterHistory = [];
|
|||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
const routeLogin = normalizeLogin(route?.params?.login || '');
|
|
||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
persistedCenterLogin = '';
|
persistedCenterLogin = '';
|
||||||
persistedCenterHistory = [];
|
persistedCenterHistory = [];
|
||||||
@ -544,7 +533,7 @@ export function render({ navigate, route }) {
|
|||||||
const cleanLogin = normalizeLogin(login);
|
const cleanLogin = normalizeLogin(login);
|
||||||
if (!cleanLogin) return '';
|
if (!cleanLogin) return '';
|
||||||
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
||||||
return makeProfileRoute(cleanLogin);
|
return `user-profile-view/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function helpText() {
|
function helpText() {
|
||||||
@ -562,15 +551,6 @@ export function render({ navigate, route }) {
|
|||||||
persistedCenterHistory = [...centerHistory];
|
persistedCenterHistory = [...centerHistory];
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncLinksUrl(login, { push = false } = {}) {
|
|
||||||
const clean = normalizeLogin(login);
|
|
||||||
if (!clean) return;
|
|
||||||
const nextPath = `/${makeProfileLinksRoute(clean)}`;
|
|
||||||
if (window.location.pathname === nextPath) return;
|
|
||||||
if (push) window.history.pushState({}, '', nextPath);
|
|
||||||
else window.history.replaceState({}, '', nextPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBackButtonState(backBtn) {
|
function setBackButtonState(backBtn) {
|
||||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||||
backBtn.disabled = centerHistory.length === 0;
|
backBtn.disabled = centerHistory.length === 0;
|
||||||
@ -588,8 +568,13 @@ export function render({ navigate, route }) {
|
|||||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</div>
|
<div class="meta-muted" id="network-search-meta">Введите логин и нажмите «Искать».</div>
|
||||||
<div class="stack" id="network-search-results"></div>
|
<div class="stack" id="network-search-results"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="ghost-btn" type="button" id="network-search-profile" disabled>Показать профиль</button>
|
||||||
|
<button class="primary-btn" type="button" id="network-search-graph" disabled>Показать связи</button>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-btn" type="button" id="network-search-ok" disabled>OK</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -600,6 +585,9 @@ export function render({ navigate, route }) {
|
|||||||
const runBtn = root.querySelector('#network-search-run');
|
const runBtn = root.querySelector('#network-search-run');
|
||||||
const metaEl = root.querySelector('#network-search-meta');
|
const metaEl = root.querySelector('#network-search-meta');
|
||||||
const resultsEl = root.querySelector('#network-search-results');
|
const resultsEl = root.querySelector('#network-search-results');
|
||||||
|
const profileBtn = root.querySelector('#network-search-profile');
|
||||||
|
const graphBtn = root.querySelector('#network-search-graph');
|
||||||
|
const okBtn = root.querySelector('#network-search-ok');
|
||||||
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
|
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
@ -619,6 +607,10 @@ export function render({ navigate, route }) {
|
|||||||
if (!(row instanceof HTMLElement)) return;
|
if (!(row instanceof HTMLElement)) return;
|
||||||
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
|
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
|
||||||
});
|
});
|
||||||
|
const hasSelected = Boolean(selectedLogin);
|
||||||
|
if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected;
|
||||||
|
if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected;
|
||||||
|
if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCandidates = (logins) => {
|
const renderCandidates = (logins) => {
|
||||||
@ -669,8 +661,6 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
closeBtn?.addEventListener('click', close);
|
closeBtn?.addEventListener('click', close);
|
||||||
runBtn?.addEventListener('click', () => { void runSearch(); });
|
runBtn?.addEventListener('click', () => { void runSearch(); });
|
||||||
const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000);
|
|
||||||
inputEl.addEventListener('input', debouncedSearch);
|
|
||||||
inputEl.addEventListener('keydown', (event) => {
|
inputEl.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -682,11 +672,23 @@ export function render({ navigate, route }) {
|
|||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
const button = target.closest('[data-candidate]');
|
const button = target.closest('[data-candidate]');
|
||||||
if (!(button instanceof HTMLElement)) return;
|
if (!(button instanceof HTMLElement)) return;
|
||||||
const nextLogin = String(button.dataset.candidate || '');
|
applySelection(String(button.dataset.candidate || ''));
|
||||||
applySelection(nextLogin);
|
});
|
||||||
if (!nextLogin) return;
|
profileBtn?.addEventListener('click', () => {
|
||||||
|
if (!selectedLogin) return;
|
||||||
|
const routeTo = profileInfoRoute(selectedLogin);
|
||||||
|
if (!routeTo) return;
|
||||||
close();
|
close();
|
||||||
void load(nextLogin, { pushHistory: true });
|
navigate(routeTo);
|
||||||
|
});
|
||||||
|
graphBtn?.addEventListener('click', () => {
|
||||||
|
if (!selectedLogin) return;
|
||||||
|
close();
|
||||||
|
void load(selectedLogin, { pushHistory: true });
|
||||||
|
});
|
||||||
|
okBtn?.addEventListener('click', () => {
|
||||||
|
if (!selectedLogin) return;
|
||||||
|
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
|
||||||
});
|
});
|
||||||
|
|
||||||
window.setTimeout(() => inputEl.focus(), 0);
|
window.setTimeout(() => inputEl.focus(), 0);
|
||||||
@ -763,7 +765,6 @@ export function render({ navigate, route }) {
|
|||||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||||
centerHistory.push(prevCenter);
|
centerHistory.push(prevCenter);
|
||||||
}
|
}
|
||||||
syncLinksUrl(targetCenter, { push: pushHistory });
|
|
||||||
|
|
||||||
const model = buildGraphModel(graph, targetCenter);
|
const model = buildGraphModel(graph, targetCenter);
|
||||||
const layout = layoutNodes(model);
|
const layout = layoutNodes(model);
|
||||||
@ -838,22 +839,13 @@ export function render({ navigate, route }) {
|
|||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (routeLogin) {
|
if (keepHistory && centerLogin) {
|
||||||
centerLogin = routeLogin;
|
|
||||||
centerHistory = [];
|
|
||||||
persistHistory();
|
|
||||||
void load(centerLogin, { pushHistory: false });
|
|
||||||
} else if (keepHistory && centerLogin) {
|
|
||||||
void load(centerLogin, { pushHistory: false });
|
void load(centerLogin, { pushHistory: false });
|
||||||
} else {
|
} else {
|
||||||
centerLogin = normalizeLogin(state.session.login || '');
|
centerLogin = normalizeLogin(state.session.login || '');
|
||||||
centerHistory = [];
|
centerHistory = [];
|
||||||
persistHistory();
|
persistHistory();
|
||||||
if (centerLogin) {
|
|
||||||
void load(centerLogin, { pushHistory: false });
|
void load(centerLogin, { pushHistory: false });
|
||||||
} else {
|
|
||||||
window.setTimeout(() => openSearchModal(), 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setBackButtonState(backBtnEl);
|
setBackButtonState(backBtnEl);
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@ -30,40 +29,6 @@ function escapeHtml(text) {
|
|||||||
.replaceAll("'", ''');
|
.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 }) {
|
export function render({ navigate }) {
|
||||||
const login = state.session.login || profile.login;
|
const login = state.session.login || profile.login;
|
||||||
|
|
||||||
@ -73,23 +38,15 @@ export function render({ navigate }) {
|
|||||||
const topActions = document.createElement('div');
|
const topActions = document.createElement('div');
|
||||||
topActions.className = 'profile-top-actions';
|
topActions.className = 'profile-top-actions';
|
||||||
topActions.innerHTML = `
|
topActions.innerHTML = `
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Изменить профиль</button>
|
||||||
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="wallet">Кошелёк</button>
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
||||||
`;
|
`;
|
||||||
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||||
|
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||||
screen.append(topActions);
|
screen.append(topActions);
|
||||||
|
|
||||||
const bottomActions = document.createElement('div');
|
|
||||||
bottomActions.className = 'profile-bottom-actions';
|
|
||||||
bottomActions.innerHTML = `
|
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-bottom-action="wallet">Кошелёк</button>
|
|
||||||
<button class="ghost-btn profile-top-action-btn profile-links-two-line" type="button" data-bottom-action="links">Показать\nсвязи</button>
|
|
||||||
`;
|
|
||||||
bottomActions.querySelector('[data-bottom-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
|
||||||
bottomActions.querySelector('[data-bottom-action="links"]')?.addEventListener('click', () => navigate(makeProfileLinksRoute(login)));
|
|
||||||
screen.append(bottomActions);
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack profile-main-card';
|
card.className = 'card stack profile-main-card';
|
||||||
|
|
||||||
@ -104,6 +61,13 @@ export function render({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const statusRow = document.createElement('div');
|
||||||
|
statusRow.className = 'row profile-status-row';
|
||||||
|
statusRow.innerHTML = `
|
||||||
|
<div class="status-line" data-profile-status-line="true">Загрузка параметров...</div>
|
||||||
|
<button class="ghost-btn profile-refresh-btn" type="button" data-reload="true">Обновить</button>
|
||||||
|
`;
|
||||||
|
|
||||||
const badgesRow = document.createElement('div');
|
const badgesRow = document.createElement('div');
|
||||||
badgesRow.className = 'row';
|
badgesRow.className = 'row';
|
||||||
badgesRow.innerHTML = `
|
badgesRow.innerHTML = `
|
||||||
@ -114,6 +78,8 @@ export function render({ navigate }) {
|
|||||||
const listWrap = document.createElement('div');
|
const listWrap = document.createElement('div');
|
||||||
listWrap.className = 'stack profile-param-list';
|
listWrap.className = 'stack profile-param-list';
|
||||||
|
|
||||||
|
const reloadBtn = statusRow.querySelector('[data-reload="true"]');
|
||||||
|
const statusLineEl = statusRow.querySelector('[data-profile-status-line="true"]');
|
||||||
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
||||||
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||||
@ -169,21 +135,6 @@ export function render({ navigate }) {
|
|||||||
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
officialBtn?.classList.add('profile-badge-trigger');
|
|
||||||
shineBtn?.classList.add('profile-badge-trigger');
|
|
||||||
officialBtn?.addEventListener('click', () => {
|
|
||||||
openProfileInfoModal({
|
|
||||||
title: 'Официальный канал',
|
|
||||||
text: officialInfoText(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
shineBtn?.addEventListener('click', () => {
|
|
||||||
openProfileInfoModal({
|
|
||||||
title: 'Справка о сияющих',
|
|
||||||
text: shineInfoText(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderFields(fields) {
|
function renderFields(fields) {
|
||||||
listWrap.innerHTML = '';
|
listWrap.innerHTML = '';
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
@ -204,6 +155,10 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
async function refreshProfileSnapshot() {
|
async function refreshProfileSnapshot() {
|
||||||
try {
|
try {
|
||||||
|
if (statusLineEl instanceof HTMLElement) {
|
||||||
|
statusLineEl.className = 'status-line';
|
||||||
|
statusLineEl.textContent = 'Загрузка параметров...';
|
||||||
|
}
|
||||||
const snapshot = await loadProfileSnapshot(login);
|
const snapshot = await loadProfileSnapshot(login);
|
||||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||||
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
currentToggles = Array.isArray(snapshot.toggles) ? snapshot.toggles : [];
|
||||||
@ -213,12 +168,39 @@ export function render({ navigate }) {
|
|||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
renderFields(currentFields);
|
renderFields(currentFields);
|
||||||
|
if (statusLineEl instanceof HTMLElement) {
|
||||||
|
statusLineEl.className = 'status-line is-available';
|
||||||
|
statusLineEl.textContent = 'Профиль обновлён.';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore status row in profile-view
|
if (statusLineEl instanceof HTMLElement) {
|
||||||
|
statusLineEl.className = 'status-line is-unavailable';
|
||||||
|
statusLineEl.textContent = `Ошибка загрузки профиля: ${error?.message || 'unknown'}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
card.append(topRow, badgesRow, listWrap);
|
const showToggleInfo = (toggleKey) => {
|
||||||
|
const item = currentToggles.find((entry) => entry.key === toggleKey);
|
||||||
|
const isEnabled = Boolean(item?.enabled);
|
||||||
|
if (toggleKey === 'official') {
|
||||||
|
if (statusLineEl instanceof HTMLElement) statusLineEl.className = 'status-line is-available';
|
||||||
|
if (statusLineEl instanceof HTMLElement) statusLineEl.textContent = isEnabled
|
||||||
|
? 'Аккаунт является официальным.'
|
||||||
|
: 'Аккаунт не является официальным.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusLineEl instanceof HTMLElement) statusLineEl.className = 'status-line is-available';
|
||||||
|
if (statusLineEl instanceof HTMLElement) statusLineEl.textContent = isEnabled
|
||||||
|
? 'Аккаунт является сияющим.'
|
||||||
|
: 'Аккаунт не является сияющим.';
|
||||||
|
};
|
||||||
|
|
||||||
|
reloadBtn?.addEventListener('click', refreshProfileSnapshot);
|
||||||
|
officialBtn?.addEventListener('click', () => showToggleInfo('official'));
|
||||||
|
shineBtn?.addEventListener('click', () => showToggleInfo('shine'));
|
||||||
|
|
||||||
|
card.append(topRow, badgesRow, listWrap, statusRow);
|
||||||
screen.append(card);
|
screen.append(card);
|
||||||
|
|
||||||
updateAvatarUi();
|
updateAvatarUi();
|
||||||
|
|||||||
@ -132,8 +132,8 @@ export function render({ navigate }) {
|
|||||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||||
const nextHash = String(state.authReturnHash || '').trim();
|
const nextHash = String(state.authReturnHash || '').trim();
|
||||||
state.authReturnHash = '';
|
state.authReturnHash = '';
|
||||||
if (nextHash.startsWith('/')) {
|
if (nextHash.startsWith('#/')) {
|
||||||
navigate(nextHash.slice(1));
|
navigate(nextHash.slice(2));
|
||||||
} else {
|
} else {
|
||||||
navigate('profile-view');
|
navigate('profile-view');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,19 +32,13 @@ export function render({ navigate }) {
|
|||||||
registerButton.textContent = 'Зарегистрироваться';
|
registerButton.textContent = 'Зарегистрироваться';
|
||||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||||
|
|
||||||
const guestViewButton = document.createElement('button');
|
|
||||||
guestViewButton.className = 'ghost-btn';
|
|
||||||
guestViewButton.type = 'button';
|
|
||||||
guestViewButton.textContent = 'Только просмотр';
|
|
||||||
guestViewButton.addEventListener('click', () => navigate('network-view'));
|
|
||||||
|
|
||||||
const settingsButton = document.createElement('button');
|
const settingsButton = document.createElement('button');
|
||||||
settingsButton.className = 'ghost-btn';
|
settingsButton.className = 'ghost-btn';
|
||||||
settingsButton.type = 'button';
|
settingsButton.type = 'button';
|
||||||
settingsButton.textContent = 'Настройки';
|
settingsButton.textContent = 'Настройки';
|
||||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||||
|
|
||||||
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
actions.append(loginButton, registerButton, settingsButton);
|
||||||
screen.append(logo, title, actions);
|
screen.append(logo, title, actions);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,8 @@ import {
|
|||||||
loadUserProfileCard,
|
loadUserProfileCard,
|
||||||
} from '../services/user-connections.js';
|
} from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
|
||||||
|
|
||||||
import { navigateBack } from '../router.js';
|
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
|
||||||
|
|
||||||
export const pageMeta = { id: 'user', title: 'Чужой профиль' };
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
@ -21,40 +18,6 @@ function escapeHtml(text) {
|
|||||||
.replaceAll("'", ''');
|
.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) {
|
function genderText(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
if (normalized === 'male') return 'Мужской';
|
if (normalized === 'male') return 'Мужской';
|
||||||
@ -63,28 +26,28 @@ function genderText(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relationButtonLabel(kind, flags) {
|
function relationButtonLabel(kind, flags) {
|
||||||
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
|
if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
|
||||||
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
|
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
|
||||||
return flags.outFollow ? 'Отписаться' : 'Подписаться';
|
return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationNextState(kind, flags) {
|
function relationNextState(kind, flags) {
|
||||||
if (kind === 'contact') return !flags.outContact;
|
if (kind === 'follow') return !flags.outFollow;
|
||||||
if (kind === 'friend') return !flags.outFriend;
|
if (kind === 'friend') return !flags.outFriend;
|
||||||
return !flags.outFollow;
|
return !flags.outContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationConfirmLabel(kind) {
|
function relationConfirmLabel(kind) {
|
||||||
if (kind === 'contact') return 'контакт';
|
if (kind === 'follow') return 'подписку';
|
||||||
if (kind === 'friend') return 'статус близкого друга';
|
if (kind === 'friend') return 'статус близкого друга';
|
||||||
return 'подписку';
|
return 'контакт';
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationStateText(kind, flags) {
|
function relationStateText(kind, flags) {
|
||||||
if (kind === 'contact') {
|
if (kind === 'follow') {
|
||||||
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
|
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
|
||||||
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
|
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
|
||||||
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
|
if (flags.inFollow) return 'Этот профиль подписан на вас.';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (kind === 'friend') {
|
if (kind === 'friend') {
|
||||||
@ -93,52 +56,12 @@ function relationStateText(kind, flags) {
|
|||||||
if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
|
if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
|
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
|
||||||
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
|
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
|
||||||
if (flags.inFollow) return 'Этот профиль подписан на вас.';
|
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function opinionItemsFromFlags(flags) {
|
|
||||||
const items = [];
|
|
||||||
if (flags.outShineSeen) {
|
|
||||||
items.push({
|
|
||||||
kind: 'shine_seen',
|
|
||||||
text: 'вы утверждаете, что очень мало знаете этого человека, но вы видели его сияющим, и всё, что вы о нём знаете, подтверждает это',
|
|
||||||
label: 'видел сияющим',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (flags.outShineConfirmed) {
|
|
||||||
items.push({
|
|
||||||
kind: 'shine_confirmed',
|
|
||||||
text: 'вы утверждаете, что достаточно хорошо знаете этого человека и точно уверены, что этот человек сияющий',
|
|
||||||
label: 'точно сияющий',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (flags.outKnownPerson) {
|
|
||||||
items.push({
|
|
||||||
kind: 'known_person',
|
|
||||||
text: 'вы утверждаете, что просто знаете этого человека',
|
|
||||||
label: 'просто знаю',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveActiveOpinionKind(flags) {
|
|
||||||
if (flags.outShineSeen) return 'shine_seen';
|
|
||||||
if (flags.outShineConfirmed) return 'shine_confirmed';
|
|
||||||
if (flags.outKnownPerson) return 'known_person';
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function opinionLabelByKind(kind) {
|
|
||||||
if (kind === 'shine_seen') return 'мало знаком, но видел сияющим';
|
|
||||||
if (kind === 'shine_confirmed') return 'точно уверен, что сияющий';
|
|
||||||
if (kind === 'known_person') return 'просто знаю человека';
|
|
||||||
return kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderIdentity(card) {
|
function renderIdentity(card) {
|
||||||
const lines = buildIdentityLines({
|
const lines = buildIdentityLines({
|
||||||
login: card.login,
|
login: card.login,
|
||||||
@ -176,88 +99,31 @@ function renderIdentity(card) {
|
|||||||
function renderReadOnlyBadges(card) {
|
function renderReadOnlyBadges(card) {
|
||||||
return `
|
return `
|
||||||
<div class="row wrap-row">
|
<div class="row wrap-row">
|
||||||
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button>
|
<span class="badge ${card.official ? 'is-yes-official' : 'is-no'}">Официальный: ${card.official ? 'Yes' : 'No'}</span>
|
||||||
<button class="badge profile-badge-trigger ${card.shine ? 'is-yes-shine' : 'is-no'}" type="button" data-profile-info="shine">Сияющий: ${card.shine ? 'Yes' : 'No'}</button>
|
<span class="badge ${card.shine ? 'is-yes-shine' : 'is-no'}">Сияющий: ${card.shine ? 'Yes' : 'No'}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRelations(flags) {
|
function renderRelations(flags) {
|
||||||
const rows = [
|
const rows = [
|
||||||
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
|
|
||||||
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
|
|
||||||
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
|
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
|
||||||
|
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
|
||||||
|
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
|
||||||
];
|
];
|
||||||
const opinionItems = opinionItemsFromFlags(flags);
|
|
||||||
const hasOpinion = opinionItems.length > 0;
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card stack user-relations-list" data-profile-relations="true">
|
<div class="card stack user-relations-list">
|
||||||
${rows.map((row) => `
|
${rows.map((row) => `
|
||||||
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
|
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
|
||||||
<span class="user-rel-text">${escapeHtml(row.text)}</span>
|
<span class="user-rel-text">${escapeHtml(row.text)}</span>
|
||||||
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
|
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
<div class="user-rel-opinions-wrap ${hasOpinion ? '' : 'is-empty'}">
|
|
||||||
<div class="user-rel-opinions-list">
|
|
||||||
${opinionItems.map((item) => `
|
|
||||||
<div class="user-rel-opinion-item">${escapeHtml(item.text)}</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
<div class="user-rel-opinions-hint">Добавьте одну из этих трёх формулировок.</div>
|
|
||||||
</div>
|
|
||||||
<div class="user-rel-row">
|
|
||||||
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
|
|
||||||
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openOpinionMenuModal({ flags, onApply }) {
|
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
if (!root) return;
|
|
||||||
const activeKind = resolveActiveOpinionKind(flags);
|
|
||||||
const items = [
|
|
||||||
{ kind: 'known_person', title: 'просто знаю человека' },
|
|
||||||
{ kind: 'shine_confirmed', title: 'точно уверен, что сияющий' },
|
|
||||||
{ kind: 'shine_seen', title: 'мало знаком, но видел сияющим' },
|
|
||||||
];
|
|
||||||
const rowsHtml = items
|
|
||||||
.filter((item) => item.kind !== activeKind)
|
|
||||||
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Высказать: ${item.title}</button>`)
|
|
||||||
.join('');
|
|
||||||
const removeHtml = activeKind
|
|
||||||
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" id="user-opinion-modal">
|
|
||||||
<div class="modal-card stack">
|
|
||||||
<h3 class="modal-title">${activeKind ? 'Изменить мнение' : 'Добавить мнение'}</h3>
|
|
||||||
<div class="stack">${rowsHtml}${removeHtml}</div>
|
|
||||||
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const close = () => { root.innerHTML = ''; };
|
|
||||||
root.querySelector('#user-opinion-modal-close')?.addEventListener('click', close);
|
|
||||||
root.querySelector('#user-opinion-modal')?.addEventListener('click', (event) => {
|
|
||||||
if (event.target?.id === 'user-opinion-modal') close();
|
|
||||||
});
|
|
||||||
root.querySelectorAll('[data-opinion-mode]').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const nextKind = String(btn.getAttribute('data-opinion-kind') || '').trim();
|
|
||||||
const mode = String(btn.getAttribute('data-opinion-mode') || '').trim();
|
|
||||||
close();
|
|
||||||
if (!nextKind) return;
|
|
||||||
await onApply({ mode, nextKind, activeKind });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReadOnlyParams(card) {
|
function renderReadOnlyParams(card) {
|
||||||
const rows = [
|
const rows = [
|
||||||
{ label: 'Имя', value: card.firstName },
|
{ label: 'Имя', value: card.firstName },
|
||||||
@ -281,6 +147,7 @@ function renderReadOnlyParams(card) {
|
|||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const requestedLogin = String(route.params.login || '').trim();
|
const requestedLogin = String(route.params.login || '').trim();
|
||||||
|
const fromPage = String(route.params.fromPage || 'messages-list').trim() || 'messages-list';
|
||||||
const sessionLogin = String(state.session.login || '').trim();
|
const sessionLogin = String(state.session.login || '').trim();
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@ -296,14 +163,12 @@ export function render({ navigate, route }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Профиль пользователя',
|
title: 'Профиль пользователя',
|
||||||
leftAction: { label: '←', onClick: () => navigateBack() },
|
leftAction: { label: '←', onClick: () => navigate(fromPage) },
|
||||||
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
|
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
|
||||||
}),
|
}),
|
||||||
status,
|
status,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
|
|
||||||
linksHeaderBtn?.classList.add('profile-links-header-btn');
|
|
||||||
|
|
||||||
let currentCard = null;
|
let currentCard = null;
|
||||||
let currentFlags = null;
|
let currentFlags = null;
|
||||||
@ -313,17 +178,14 @@ export function render({ navigate, route }) {
|
|||||||
const followBtn = body.querySelector('[data-relation-action="follow"]');
|
const followBtn = body.querySelector('[data-relation-action="follow"]');
|
||||||
const friendBtn = body.querySelector('[data-relation-action="friend"]');
|
const friendBtn = body.querySelector('[data-relation-action="friend"]');
|
||||||
const contactBtn = body.querySelector('[data-relation-action="contact"]');
|
const contactBtn = body.querySelector('[data-relation-action="contact"]');
|
||||||
const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]');
|
if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
|
||||||
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
|
|
||||||
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
|
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
|
||||||
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
|
|
||||||
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
|
|
||||||
followBtn.textContent = relationButtonLabel('follow', currentFlags);
|
followBtn.textContent = relationButtonLabel('follow', currentFlags);
|
||||||
contactBtn.disabled = Boolean(isSelf);
|
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
|
||||||
friendBtn.disabled = Boolean(isSelf);
|
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
|
||||||
followBtn.disabled = Boolean(isSelf);
|
followBtn.disabled = Boolean(isSelf);
|
||||||
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
|
friendBtn.disabled = Boolean(isSelf);
|
||||||
opinionBtn.disabled = Boolean(isSelf);
|
contactBtn.disabled = Boolean(isSelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@ -358,10 +220,6 @@ export function render({ navigate, route }) {
|
|||||||
body.prepend(identityCard);
|
body.prepend(identityCard);
|
||||||
|
|
||||||
syncActionButtons();
|
syncActionButtons();
|
||||||
if (String(route?.params?.section || '').toLowerCase() === 'links') {
|
|
||||||
const rel = body.querySelector('[data-profile-relations="true"]');
|
|
||||||
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
status.className = 'status-line is-available';
|
status.className = 'status-line is-available';
|
||||||
status.textContent = 'Профиль обновлён.';
|
status.textContent = 'Профиль обновлён.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -384,14 +242,6 @@ export function render({ navigate, route }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'opinion-menu') {
|
|
||||||
openOpinionMenuModal({
|
|
||||||
flags: currentFlags,
|
|
||||||
onApply: onOpinionApply,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextEnabled = relationNextState(kind, currentFlags);
|
const nextEnabled = relationNextState(kind, currentFlags);
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
|
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
|
||||||
@ -420,87 +270,13 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onOpinionApply({ mode, nextKind, activeKind }) {
|
|
||||||
if (isBusy || !currentCard || !currentFlags) return;
|
|
||||||
if (!sessionLogin) {
|
|
||||||
window.alert('Для изменения связей нужен активный вход.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!state.session.storagePwdInMemory) {
|
|
||||||
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = window.confirm(`Изменить мнение о пользователе ${currentCard.login}?`);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
isBusy = true;
|
|
||||||
status.className = 'status-line';
|
|
||||||
status.textContent = 'Сохранение отношения в блокчейн...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (activeKind) {
|
|
||||||
await authService.setUserRelation({
|
|
||||||
login: sessionLogin,
|
|
||||||
toLogin: currentCard.login,
|
|
||||||
kind: activeKind,
|
|
||||||
enabled: false,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'set') {
|
|
||||||
await authService.setUserRelation({
|
|
||||||
login: sessionLogin,
|
|
||||||
toLogin: currentCard.login,
|
|
||||||
kind: nextKind,
|
|
||||||
enabled: true,
|
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
if (mode === 'set') {
|
|
||||||
const opinionVisible = Boolean(
|
|
||||||
currentFlags?.outKnownPerson
|
|
||||||
|| currentFlags?.outShineConfirmed
|
|
||||||
|| currentFlags?.outShineSeen,
|
|
||||||
);
|
|
||||||
if (!opinionVisible) {
|
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 350));
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
status.className = 'status-line is-unavailable';
|
|
||||||
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
|
|
||||||
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
|
|
||||||
isBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body.addEventListener('click', (event) => {
|
body.addEventListener('click', (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
const infoBtn = target.closest('[data-profile-info]');
|
|
||||||
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
|
|
||||||
if (infoKind === 'official') {
|
|
||||||
openProfileInfoModal({
|
|
||||||
title: 'Официальный канал',
|
|
||||||
text: officialInfoText(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (infoKind === 'shine') {
|
|
||||||
openProfileInfoModal({
|
|
||||||
title: 'Справка о сияющих',
|
|
||||||
text: shineInfoText(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const actionBtn = target.closest('[data-relation-action]');
|
const actionBtn = target.closest('[data-relation-action]');
|
||||||
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
||||||
if (!kind) return;
|
if (!kind) return;
|
||||||
void onRelationAction(kind);
|
onRelationAction(kind);
|
||||||
});
|
});
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
createRandomSolanaWallet,
|
|
||||||
createSolanaWalletFromPrivateBase58,
|
|
||||||
formatSol,
|
formatSol,
|
||||||
getBalanceSol,
|
getBalanceSol,
|
||||||
getTopupSiteUrl,
|
getTopupSiteUrl,
|
||||||
@ -19,7 +17,6 @@ import {
|
|||||||
} from '../services/arweave-wallet-service.js';
|
} from '../services/arweave-wallet-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||||
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
|
|
||||||
|
|
||||||
function nowRu() {
|
function nowRu() {
|
||||||
return new Date().toLocaleString('ru-RU');
|
return new Date().toLocaleString('ru-RU');
|
||||||
@ -168,203 +165,6 @@ export function render({ navigate }) {
|
|||||||
const sendBtn = actions.querySelector('#send-sol');
|
const sendBtn = actions.querySelector('#send-sol');
|
||||||
const topupBtn = actions.querySelector('#topup-sol');
|
const topupBtn = actions.querySelector('#topup-sol');
|
||||||
|
|
||||||
const generatedCard = document.createElement('div');
|
|
||||||
generatedCard.className = 'card stack';
|
|
||||||
generatedCard.innerHTML = `
|
|
||||||
<h3 style="margin:0;">Создание нового кошелька Solana</h3>
|
|
||||||
<p class="meta-muted" style="margin:0;">Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.</p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const privateLabel = document.createElement('label');
|
|
||||||
privateLabel.className = 'meta-muted';
|
|
||||||
privateLabel.textContent = 'Приватный ключ (Base58, 32 байта)';
|
|
||||||
privateLabel.setAttribute('for', 'solana-private-base58-input');
|
|
||||||
|
|
||||||
const privateInput = document.createElement('input');
|
|
||||||
privateInput.id = 'solana-private-base58-input';
|
|
||||||
privateInput.type = 'text';
|
|
||||||
privateInput.placeholder = 'Введите приватный ключ Base58';
|
|
||||||
privateInput.maxLength = SOLANA_PRIVATE_BASE58_MAX_LEN;
|
|
||||||
privateInput.autocomplete = 'off';
|
|
||||||
privateInput.spellcheck = false;
|
|
||||||
|
|
||||||
const privateState = document.createElement('p');
|
|
||||||
privateState.className = 'meta-muted';
|
|
||||||
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
|
|
||||||
|
|
||||||
const generatedPublicLabel = document.createElement('label');
|
|
||||||
generatedPublicLabel.className = 'meta-muted';
|
|
||||||
generatedPublicLabel.textContent = 'Публичный ключ (Base58)';
|
|
||||||
generatedPublicLabel.setAttribute('for', 'solana-generated-public-key');
|
|
||||||
|
|
||||||
const generatedPublicInput = document.createElement('input');
|
|
||||||
generatedPublicInput.id = 'solana-generated-public-key';
|
|
||||||
generatedPublicInput.type = 'text';
|
|
||||||
generatedPublicInput.readOnly = true;
|
|
||||||
generatedPublicInput.placeholder = 'Будет сгенерирован после нажатия кнопки';
|
|
||||||
|
|
||||||
const generatedPrivateLabel = document.createElement('label');
|
|
||||||
generatedPrivateLabel.className = 'meta-muted';
|
|
||||||
generatedPrivateLabel.textContent = 'Сгенерированный приватный ключ (Base58)';
|
|
||||||
generatedPrivateLabel.setAttribute('for', 'solana-generated-private-key');
|
|
||||||
|
|
||||||
const generatedPrivateInput = document.createElement('input');
|
|
||||||
generatedPrivateInput.id = 'solana-generated-private-key';
|
|
||||||
generatedPrivateInput.type = 'text';
|
|
||||||
generatedPrivateInput.readOnly = true;
|
|
||||||
generatedPrivateInput.placeholder = 'Появится после генерации';
|
|
||||||
|
|
||||||
const generationActions = document.createElement('div');
|
|
||||||
generationActions.className = 'row';
|
|
||||||
generationActions.innerHTML = `
|
|
||||||
<button class="primary-btn" id="generate-random-solana" style="width:100%;">Сгенерировать случайный кошелёк</button>
|
|
||||||
<button class="primary-btn" id="generate-from-private-solana" style="width:100%;">Сгенерировать из приватного ключа</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const copyGeneratedActions = document.createElement('div');
|
|
||||||
copyGeneratedActions.className = 'row';
|
|
||||||
copyGeneratedActions.innerHTML = `
|
|
||||||
<button class="text-btn" id="copy-generated-private-solana" style="width:100%;">Копировать приватный</button>
|
|
||||||
<button class="text-btn" id="copy-generated-public-solana" style="width:100%;">Копировать публичный</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
generatedCard.append(
|
|
||||||
privateLabel,
|
|
||||||
privateInput,
|
|
||||||
privateState,
|
|
||||||
generationActions,
|
|
||||||
generatedPrivateLabel,
|
|
||||||
generatedPrivateInput,
|
|
||||||
generatedPublicLabel,
|
|
||||||
generatedPublicInput,
|
|
||||||
copyGeneratedActions,
|
|
||||||
);
|
|
||||||
|
|
||||||
const randomGenerateBtn = generationActions.querySelector('#generate-random-solana');
|
|
||||||
const fromPrivateGenerateBtn = generationActions.querySelector('#generate-from-private-solana');
|
|
||||||
const copyGeneratedPrivateBtn = copyGeneratedActions.querySelector('#copy-generated-private-solana');
|
|
||||||
const copyGeneratedPublicBtn = copyGeneratedActions.querySelector('#copy-generated-public-solana');
|
|
||||||
|
|
||||||
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
|
|
||||||
const validatePrivateInput = () => {
|
|
||||||
const value = String(privateInput.value || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!BASE58_RE.test(value)) {
|
|
||||||
privateState.textContent = 'Недопустимый формат: используйте только Base58.';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.length > SOLANA_PRIVATE_BASE58_MAX_LEN) {
|
|
||||||
privateState.textContent = 'Слишком длинное значение.';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
||||||
let num = 0n;
|
|
||||||
for (const c of value) {
|
|
||||||
num = num * 58n + BigInt(alphabet.indexOf(c));
|
|
||||||
}
|
|
||||||
let hex = num.toString(16);
|
|
||||||
if (hex.length % 2) hex = `0${hex}`;
|
|
||||||
const decoded = hex ? hex.match(/.{1,2}/g)?.map((h) => parseInt(h, 16)) || [] : [];
|
|
||||||
let leadingZeros = 0;
|
|
||||||
while (leadingZeros < value.length && value[leadingZeros] === '1') leadingZeros += 1;
|
|
||||||
const byteLen = leadingZeros + decoded.length;
|
|
||||||
if (byteLen < 32) {
|
|
||||||
privateState.textContent = 'Слишком короткое значение: нужно 32 байта.';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (byteLen > 32) {
|
|
||||||
privateState.textContent = 'Слишком длинное значение: нужно ровно 32 байта.';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
privateState.textContent = 'Ошибка декодирования Base58.';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
privateState.textContent = 'Подходит';
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
privateInput.addEventListener('input', () => {
|
|
||||||
validatePrivateInput();
|
|
||||||
});
|
|
||||||
|
|
||||||
const setGenerationDisabled = (disabled) => {
|
|
||||||
randomGenerateBtn.disabled = disabled;
|
|
||||||
fromPrivateGenerateBtn.disabled = disabled;
|
|
||||||
copyGeneratedPrivateBtn.disabled = disabled;
|
|
||||||
copyGeneratedPublicBtn.disabled = disabled;
|
|
||||||
};
|
|
||||||
|
|
||||||
randomGenerateBtn.addEventListener('click', async () => {
|
|
||||||
setGenerationDisabled(true);
|
|
||||||
try {
|
|
||||||
const generated = await createRandomSolanaWallet();
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
generatedPrivateInput.value = generated.privateKey32Base58;
|
|
||||||
generatedPublicInput.value = generated.address;
|
|
||||||
privateState.textContent = 'Случайный кошелёк создан.';
|
|
||||||
setStatus('Случайный кошелёк Solana успешно сгенерирован.');
|
|
||||||
} catch (error) {
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
setStatus(`Ошибка генерации случайного кошелька: ${error?.message || 'unknown'}`);
|
|
||||||
} finally {
|
|
||||||
setGenerationDisabled(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fromPrivateGenerateBtn.addEventListener('click', async () => {
|
|
||||||
if (!validatePrivateInput()) {
|
|
||||||
setStatus('Исправьте приватный ключ перед генерацией.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGenerationDisabled(true);
|
|
||||||
try {
|
|
||||||
const generated = await createSolanaWalletFromPrivateBase58(privateInput.value);
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
generatedPrivateInput.value = generated.privateKey32Base58;
|
|
||||||
generatedPublicInput.value = generated.address;
|
|
||||||
privateState.textContent = 'Подходит';
|
|
||||||
setStatus('Публичный ключ сгенерирован из введённого приватного ключа.');
|
|
||||||
} catch (error) {
|
|
||||||
if (modeToken !== activeModeToken) return;
|
|
||||||
setStatus(`Ошибка генерации из приватного ключа: ${error?.message || 'unknown'}`);
|
|
||||||
} finally {
|
|
||||||
setGenerationDisabled(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
copyGeneratedPrivateBtn.addEventListener('click', async () => {
|
|
||||||
const value = String(generatedPrivateInput.value || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
setStatus('Сначала сгенерируйте приватный ключ.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
setStatus('Приватный ключ скопирован.');
|
|
||||||
} catch {
|
|
||||||
setStatus('Не удалось скопировать приватный ключ в этом браузере.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
copyGeneratedPublicBtn.addEventListener('click', async () => {
|
|
||||||
const value = String(generatedPublicInput.value || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
setStatus('Сначала сгенерируйте публичный ключ.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
setStatus('Публичный ключ скопирован.');
|
|
||||||
} catch {
|
|
||||||
setStatus('Не удалось скопировать публичный ключ в этом браузере.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshBalance = async () => {
|
const refreshBalance = async () => {
|
||||||
if (!walletAddress) {
|
if (!walletAddress) {
|
||||||
setStatus('Кошелёк не инициализирован.');
|
setStatus('Кошелёк не инициализирован.');
|
||||||
@ -465,7 +265,7 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
content.append(backBtn, card, actions, generatedCard);
|
content.append(backBtn, card, actions);
|
||||||
setStatus('Инициализация wallet.key...');
|
setStatus('Инициализация wallet.key...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { parseShineRootSegment } from './services/shine-routes.js';
|
|
||||||
|
|
||||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
||||||
|
|
||||||
export const PRE_AUTH_PAGES = [
|
export const PRE_AUTH_PAGES = [
|
||||||
@ -16,13 +14,10 @@ export const PRE_AUTH_PAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function getRoute() {
|
export function getRoute() {
|
||||||
const currentPath = String(window.location.pathname || '').trim();
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||||
const raw = currentPath
|
if (!raw) {
|
||||||
.replace(/^\/+/, '')
|
return { pageId: '', params: {} };
|
||||||
.replace(/^index\.html$/i, '')
|
}
|
||||||
.replace(/^index\.html\//i, '')
|
|
||||||
.replace(/\/+$/, '');
|
|
||||||
if (!raw) return { pageId: '', params: {} };
|
|
||||||
|
|
||||||
const segments = raw.split('/').filter(Boolean);
|
const segments = raw.split('/').filter(Boolean);
|
||||||
const pageId = segments[0] || '';
|
const pageId = segments[0] || '';
|
||||||
@ -36,73 +31,6 @@ export function getRoute() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shineLogin = parseShineRootSegment(pageId);
|
|
||||||
if (shineLogin) {
|
|
||||||
const section = decodePart(segments[1] || '').toLowerCase();
|
|
||||||
if (!section) {
|
|
||||||
return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } };
|
|
||||||
}
|
|
||||||
if (section === 'links') {
|
|
||||||
return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
|
|
||||||
}
|
|
||||||
if (section === 'channels') {
|
|
||||||
const sub = decodePart(segments[2] || '').toLowerCase();
|
|
||||||
if (sub === 'owned') {
|
|
||||||
return {
|
|
||||||
pageId: 'channels-list',
|
|
||||||
params: { mode: 'my', login: shineLogin, scope: 'owned' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (sub === 'following') {
|
|
||||||
return {
|
|
||||||
pageId: 'channels-list',
|
|
||||||
params: { mode: 'feed', login: shineLogin, scope: 'following' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pageId: 'channels-list',
|
|
||||||
params: { mode: 'feed', login: shineLogin, scope: 'all' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (section === 'msg') {
|
|
||||||
return {
|
|
||||||
pageId: 'channel-thread-view',
|
|
||||||
params: {
|
|
||||||
messageBlockchainName: decodePart(segments[2]),
|
|
||||||
messageBlockNumber: segments[3] || '',
|
|
||||||
messageBlockHash: '',
|
|
||||||
channelOwnerBlockchainName: '',
|
|
||||||
channelRootBlockNumber: '',
|
|
||||||
channelRootBlockHash: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (section === 'channel') {
|
|
||||||
const ownerBlockchainName = decodePart(segments[2] || '');
|
|
||||||
const channelName = decodePart(segments[3] || '');
|
|
||||||
const messageBlockNumber = segments[4] || '';
|
|
||||||
if (ownerBlockchainName && channelName && messageBlockNumber) {
|
|
||||||
return {
|
|
||||||
pageId: 'channel-thread-view',
|
|
||||||
params: {
|
|
||||||
ownerBlockchainName,
|
|
||||||
channelName,
|
|
||||||
messageBlockNumber,
|
|
||||||
messageBlockHash: '',
|
|
||||||
messageBlockchainName: '',
|
|
||||||
channelOwnerBlockchainName: ownerBlockchainName,
|
|
||||||
channelRootBlockNumber: '',
|
|
||||||
channelRootBlockHash: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
pageId: 'channel-view',
|
|
||||||
params: { ownerBlockchainName, channelName, channelId: '' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageId === 'chat-view') {
|
if (pageId === 'chat-view') {
|
||||||
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||||
}
|
}
|
||||||
@ -122,16 +50,51 @@ export function getRoute() {
|
|||||||
return { pageId, params: { channelId: dynamicId || '' } };
|
return { pageId, params: { channelId: dynamicId || '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'channel') {
|
||||||
|
// Короткий формат:
|
||||||
|
// #/channel/{ownerBlockchainName}/{channelName}
|
||||||
|
// #/channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber}
|
||||||
|
const ownerBlockchainName = decodePart(segments[1] || '');
|
||||||
|
const channelName = decodePart(segments[2] || '');
|
||||||
|
const messageBlockNumber = segments[3] || '';
|
||||||
|
|
||||||
|
if (ownerBlockchainName && channelName && messageBlockNumber) {
|
||||||
|
return {
|
||||||
|
pageId: 'channel-thread-view',
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelName,
|
||||||
|
messageBlockNumber,
|
||||||
|
messageBlockHash: '',
|
||||||
|
// поддержка старого контракта страницы треда
|
||||||
|
messageBlockchainName: '',
|
||||||
|
channelOwnerBlockchainName: ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: '',
|
||||||
|
channelRootBlockHash: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageId: 'channel-view',
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelName,
|
||||||
|
channelId: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'channel-thread-view') {
|
if (pageId === 'channel-thread-view') {
|
||||||
return {
|
return {
|
||||||
pageId,
|
pageId,
|
||||||
params: {
|
params: {
|
||||||
messageBlockchainName: decodePart(segments[1]),
|
messageBlockchainName: decodePart(segments[1]),
|
||||||
messageBlockNumber: segments[2] || '',
|
messageBlockNumber: segments[2] || '',
|
||||||
messageBlockHash: '',
|
messageBlockHash: segments[3] || '',
|
||||||
channelOwnerBlockchainName: decodePart(segments[3]),
|
channelOwnerBlockchainName: decodePart(segments[4]),
|
||||||
channelRootBlockNumber: segments[4] || '',
|
channelRootBlockNumber: segments[5] || '',
|
||||||
channelRootBlockHash: segments[5] || '',
|
channelRootBlockHash: segments[6] || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -140,29 +103,39 @@ export function getRoute() {
|
|||||||
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'user-profile-view') {
|
||||||
|
return {
|
||||||
|
pageId,
|
||||||
|
params: {
|
||||||
|
login: dynamicId ? decodeURIComponent(dynamicId) : '',
|
||||||
|
fromPage: segments[2] ? decodeURIComponent(segments[2]) : 'messages-list',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'network-view') {
|
if (pageId === 'network-view') {
|
||||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
return {
|
||||||
|
pageId,
|
||||||
|
params: {
|
||||||
|
mode: segments[1] ? decodePart(segments[1]) : '',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageId === 'channels-list') {
|
if (pageId === 'channels-list') {
|
||||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
return {
|
||||||
|
pageId,
|
||||||
|
params: {
|
||||||
|
mode: segments[1] ? decodePart(segments[1]) : '',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { pageId, params: {} };
|
return { pageId, params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(path) {
|
export function navigate(path) {
|
||||||
const cleanPath = String(path || '').replace(/^\/+/, '');
|
window.location.hash = `#/${path}`;
|
||||||
const nextPath = cleanPath ? `/${cleanPath}` : '/';
|
|
||||||
if (window.location.pathname !== nextPath) {
|
|
||||||
window.history.pushState({}, '', nextPath);
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function navigateBack() {
|
|
||||||
if (window.history.length <= 1) return;
|
|
||||||
window.history.back();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveToolbarActive(pageId) {
|
export function resolveToolbarActive(pageId) {
|
||||||
@ -183,9 +156,11 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'language-view' ||
|
pageId === 'language-view' ||
|
||||||
pageId === 'app-log-view' ||
|
pageId === 'app-log-view' ||
|
||||||
pageId === 'pwa-diagnostics-view'
|
pageId === 'pwa-diagnostics-view'
|
||||||
) return 'profile-view';
|
) {
|
||||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
return 'profile-view';
|
||||||
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
}
|
||||||
if (pageId === 'user') return 'messages-list';
|
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||||
|
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||||
|
if (pageId === 'user-profile-view') return 'messages-list';
|
||||||
return 'profile-view';
|
return 'profile-view';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
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,9 +37,7 @@ const MSG_TYPE_CONNECTION = 3;
|
|||||||
|
|
||||||
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
|
||||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||||
const MSG_SUBTYPE_TEXT_EDIT_POST = 11;
|
|
||||||
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
const MSG_SUBTYPE_TEXT_REPLY = 20;
|
||||||
const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
|
||||||
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
const MSG_SUBTYPE_REACTION_LIKE = 1;
|
||||||
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
|
||||||
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
|
||||||
@ -61,9 +59,6 @@ const CONNECTION_SUBTYPES = Object.freeze({
|
|||||||
parent: { on: 50, off: 51 },
|
parent: { on: 50, off: 51 },
|
||||||
child: { on: 52, off: 53 },
|
child: { on: 52, off: 53 },
|
||||||
sibling: { on: 54, off: 55 },
|
sibling: { on: 54, off: 55 },
|
||||||
known_person: { on: 60, off: 61 },
|
|
||||||
shine_confirmed: { on: 70, off: 71 },
|
|
||||||
shine_seen: { on: 74, off: 75 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeServerUrl(url) {
|
function normalizeServerUrl(url) {
|
||||||
@ -371,56 +366,6 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTextEditPostBodyBytes({
|
|
||||||
lineCode,
|
|
||||||
prevLineNumber,
|
|
||||||
prevLineHashHex,
|
|
||||||
thisLineNumber,
|
|
||||||
toBlockNumber,
|
|
||||||
toBlockHashHex,
|
|
||||||
text,
|
|
||||||
}) {
|
|
||||||
const message = String(text || '').trim();
|
|
||||||
const targetBlockNumber = Number(toBlockNumber);
|
|
||||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
|
||||||
throw new Error('Invalid target block number for edit post');
|
|
||||||
}
|
|
||||||
const textBytes = utf8Bytes(message);
|
|
||||||
if (textBytes.length > 65535) {
|
|
||||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
|
||||||
}
|
|
||||||
|
|
||||||
return concatBytes(
|
|
||||||
int32Bytes(lineCode),
|
|
||||||
int32Bytes(prevLineNumber),
|
|
||||||
hexToBytes(normalizeHex32(prevLineHashHex)),
|
|
||||||
int32Bytes(thisLineNumber),
|
|
||||||
int32Bytes(targetBlockNumber),
|
|
||||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
|
||||||
int16Bytes(textBytes.length),
|
|
||||||
textBytes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeTextEditReplyBodyBytes({ toBlockNumber, toBlockHashHex, text }) {
|
|
||||||
const message = String(text || '').trim();
|
|
||||||
const targetBlockNumber = Number(toBlockNumber);
|
|
||||||
if (!Number.isFinite(targetBlockNumber) || targetBlockNumber < 0) {
|
|
||||||
throw new Error('Invalid target block number for edit reply');
|
|
||||||
}
|
|
||||||
const textBytes = utf8Bytes(message);
|
|
||||||
if (textBytes.length > 65535) {
|
|
||||||
throw new Error('Message text must be 0..65535 UTF-8 bytes');
|
|
||||||
}
|
|
||||||
|
|
||||||
return concatBytes(
|
|
||||||
int32Bytes(targetBlockNumber),
|
|
||||||
hexToBytes(normalizeHex32(toBlockHashHex)),
|
|
||||||
int16Bytes(textBytes.length),
|
|
||||||
textBytes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeConnectionBodyBytes({
|
function makeConnectionBodyBytes({
|
||||||
lineCode = 0,
|
lineCode = 0,
|
||||||
prevLineNumber = -1,
|
prevLineNumber = -1,
|
||||||
@ -1010,98 +955,6 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBlockEditMessage({
|
|
||||||
login,
|
|
||||||
message,
|
|
||||||
text,
|
|
||||||
storagePwd,
|
|
||||||
isChannelPost = false,
|
|
||||||
channel = null,
|
|
||||||
}) {
|
|
||||||
const cleanLogin = String(login || '').trim();
|
|
||||||
const cleanText = String(text || '').trim();
|
|
||||||
const target = normalizeMessageRefTarget(message, 'edit');
|
|
||||||
const lockKey = `edit:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}:${isChannelPost ? 'post' : 'reply'}`;
|
|
||||||
|
|
||||||
return this.runWriteLocked(lockKey, async () => {
|
|
||||||
if (isChannelPost) {
|
|
||||||
const selector = channel || {};
|
|
||||||
const ownerBlockchainName = String(selector?.ownerBlockchainName || target.blockchainName || '').trim();
|
|
||||||
const lineCode = Number(selector?.channelRootBlockNumber);
|
|
||||||
if (!ownerBlockchainName || !Number.isFinite(lineCode) || lineCode < 0) {
|
|
||||||
throw new Error('Invalid channel selector for edit');
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
|
|
||||||
if (rootHashHex === ZERO64) {
|
|
||||||
const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, ownerBlockchainName);
|
|
||||||
const rootChannel = ownChannels.find((item) => item.rootBlockNumber === lineCode);
|
|
||||||
if (rootChannel) rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prevLineNumber = lineCode;
|
|
||||||
let prevLineHashHex = rootHashHex;
|
|
||||||
let thisLineNumber = 1;
|
|
||||||
try {
|
|
||||||
const latestPayload = await this.getChannelMessages({
|
|
||||||
ownerBlockchainName,
|
|
||||||
channelRootBlockNumber: lineCode,
|
|
||||||
channelRootBlockHash: rootHashHex,
|
|
||||||
}, 1, 'desc', cleanLogin);
|
|
||||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
|
||||||
const latestVersions = Array.isArray(latestMessage?.versions) ? latestMessage.versions : [];
|
|
||||||
const latestVersion = latestVersions[latestVersions.length - 1] || null;
|
|
||||||
const latestBlockNumber = Number(latestVersion?.blockNumber ?? latestMessage?.messageRef?.blockNumber);
|
|
||||||
const latestBlockHash = normalizeHex32(latestVersion?.blockHash ?? latestMessage?.messageRef?.blockHash, '');
|
|
||||||
const latestVersionsTotal = Number(latestMessage?.versionsTotal);
|
|
||||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
|
||||||
prevLineNumber = latestBlockNumber;
|
|
||||||
prevLineHashHex = latestBlockHash;
|
|
||||||
thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0
|
|
||||||
? Math.max(1, latestVersionsTotal)
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fallback to root anchor
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyBytes = makeTextEditPostBodyBytes({
|
|
||||||
lineCode,
|
|
||||||
prevLineNumber,
|
|
||||||
prevLineHashHex,
|
|
||||||
thisLineNumber,
|
|
||||||
toBlockNumber: target.blockNumber,
|
|
||||||
toBlockHashHex: target.blockHash,
|
|
||||||
text: cleanText,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.addBlockSigned({
|
|
||||||
login: cleanLogin,
|
|
||||||
storagePwd,
|
|
||||||
msgType: MSG_TYPE_TEXT,
|
|
||||||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_POST,
|
|
||||||
msgVersion: 1,
|
|
||||||
bodyBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyBytes = makeTextEditReplyBodyBytes({
|
|
||||||
toBlockNumber: target.blockNumber,
|
|
||||||
toBlockHashHex: target.blockHash,
|
|
||||||
text: cleanText,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.addBlockSigned({
|
|
||||||
login: cleanLogin,
|
|
||||||
storagePwd,
|
|
||||||
msgType: MSG_TYPE_TEXT,
|
|
||||||
msgSubType: MSG_SUBTYPE_TEXT_EDIT_REPLY,
|
|
||||||
msgVersion: 1,
|
|
||||||
bodyBytes,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
async addBlockFollowUser({ login, targetLogin, storagePwd, unfollow = false }) {
|
||||||
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
const cleanTargetLogin = String(targetLogin || '').trim().replace(/^@+/, '');
|
||||||
if (!cleanTargetLogin) throw new Error('Target login is required');
|
if (!cleanTargetLogin) throw new Error('Target login is required');
|
||||||
@ -1381,15 +1234,10 @@ export class AuthService {
|
|||||||
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null;
|
||||||
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber);
|
||||||
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, '');
|
||||||
const latestVersionsTotal = Number(latestMessage?.versionsTotal);
|
|
||||||
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) {
|
||||||
prevLineNumber = latestBlockNumber;
|
prevLineNumber = latestBlockNumber;
|
||||||
prevLineHashHex = latestBlockHash;
|
prevLineHashHex = latestBlockHash;
|
||||||
// В line-цепочке thisLineNumber — это номер шага линии, а не глобальный blockNumber.
|
thisLineNumber = latestBlockNumber + 1;
|
||||||
// Для следующего POST берем шаг после последней известной версии сообщения.
|
|
||||||
thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0
|
|
||||||
? Math.max(0, latestVersionsTotal)
|
|
||||||
: 1;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fallback to root anchor
|
// fallback to root anchor
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
function encodeRoutePart(value = '') {
|
|
||||||
return encodeURIComponent(String(value || '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeRoutePart(value = '') {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(String(value || ''));
|
|
||||||
} catch {
|
|
||||||
return String(value || '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeLogin(value = '') {
|
|
||||||
return String(value || '').trim().replace(/^@+/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractLoginFromBlockchainName(value = '') {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
const match = raw.match(/^(.+)-\d+$/);
|
|
||||||
if (!match) return normalizeLogin(raw);
|
|
||||||
return normalizeLogin(String(match[1] || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeProfileRoute(login = '') {
|
|
||||||
const clean = normalizeLogin(login);
|
|
||||||
return clean ? `shine.${encodeRoutePart(clean)}` : 'profile-view';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeProfileLinksRoute(login = '') {
|
|
||||||
const clean = normalizeLogin(login);
|
|
||||||
return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network-view/keep-history';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeProfileChannelsRoute(login = '', scope = 'all') {
|
|
||||||
const clean = normalizeLogin(login);
|
|
||||||
if (!clean) return 'channels-list/feed';
|
|
||||||
const normalizedScope = String(scope || '').trim().toLowerCase();
|
|
||||||
if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
|
|
||||||
if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
|
|
||||||
return `shine.${encodeRoutePart(clean)}/channels`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
|
|
||||||
const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName);
|
|
||||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
|
||||||
const chName = String(channelName || '').trim();
|
|
||||||
const msgNo = String(messageBlockNumber || '').trim();
|
|
||||||
if (!cleanOwnerLogin || !ownerBch || !chName) return '';
|
|
||||||
if (msgNo) return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
|
|
||||||
return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
|
||||||
const cleanOwnerLogin = normalizeLogin(ownerLogin);
|
|
||||||
const msgBch = String(messageBlockchainName || '').trim();
|
|
||||||
const msgNo = String(messageBlockNumber || '').trim();
|
|
||||||
if (!cleanOwnerLogin || !msgBch || !msgNo) return '';
|
|
||||||
return `shine.${encodeRoutePart(cleanOwnerLogin)}/msg/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseShineRootSegment(segment = '') {
|
|
||||||
const raw = String(segment || '').trim();
|
|
||||||
if (!raw.toLowerCase().startsWith('shine.')) return '';
|
|
||||||
return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
|
|||||||
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
|
const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/';
|
||||||
|
|
||||||
let solanaLibPromise = null;
|
let solanaLibPromise = null;
|
||||||
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
|
|
||||||
|
|
||||||
function normalizeEndpoint(url) {
|
function normalizeEndpoint(url) {
|
||||||
const raw = String(url || '').trim();
|
const raw = String(url || '').trim();
|
||||||
@ -38,34 +37,6 @@ export async function deriveWalletFromPassword(password) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRandomSolanaWallet() {
|
|
||||||
const solana = await loadSolanaLib();
|
|
||||||
const keypair = solana.Keypair.generate();
|
|
||||||
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
|
|
||||||
return {
|
|
||||||
address: keypair.publicKey.toBase58(),
|
|
||||||
privateKey32Base58,
|
|
||||||
keypair,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
|
|
||||||
const solana = await loadSolanaLib();
|
|
||||||
const clean = String(privateKey32Base58 || '').trim();
|
|
||||||
if (!clean) throw new Error('Введите приватный ключ');
|
|
||||||
if (!BASE58_RE.test(clean)) throw new Error('Разрешены только символы Base58');
|
|
||||||
const privateBytes = solana.bs58.decode(clean);
|
|
||||||
if (privateBytes.length !== 32) {
|
|
||||||
throw new Error('Приватный ключ должен быть ровно 32 байта в Base58');
|
|
||||||
}
|
|
||||||
const keypair = solana.Keypair.fromSeed(privateBytes);
|
|
||||||
return {
|
|
||||||
address: keypair.publicKey.toBase58(),
|
|
||||||
privateKey32Base58: clean,
|
|
||||||
keypair,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
|
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanPwd = String(storagePwd || '').trim();
|
const cleanPwd = String(storagePwd || '').trim();
|
||||||
|
|||||||
@ -47,23 +47,7 @@ function toToggleMap(snapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readArray(payload, key) {
|
function readArray(payload, key) {
|
||||||
const aliases = {
|
const value = payload?.[key];
|
||||||
outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
|
|
||||||
inKnownPersons: ['inKnownPersons', 'inKnownPerson', 'in_known_persons'],
|
|
||||||
outShineConfirmed: ['outShineConfirmed', 'outShineConfident', 'out_shine_confirmed'],
|
|
||||||
inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
|
|
||||||
outShineSeen: ['outShineSeen', 'out_shine_seen'],
|
|
||||||
inShineSeen: ['inShineSeen', 'in_shine_seen'],
|
|
||||||
};
|
|
||||||
const keys = aliases[key] || [key];
|
|
||||||
let value = null;
|
|
||||||
for (const oneKey of keys) {
|
|
||||||
const candidate = payload?.[oneKey];
|
|
||||||
if (Array.isArray(candidate)) {
|
|
||||||
value = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.isArray(value) ? uniqueLogins(value) : null;
|
return Array.isArray(value) ? uniqueLogins(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,12 +75,6 @@ async function buildRelationsModel(login) {
|
|||||||
inChildren: [],
|
inChildren: [],
|
||||||
outSiblings: [],
|
outSiblings: [],
|
||||||
inSiblings: [],
|
inSiblings: [],
|
||||||
outKnownPersons: [],
|
|
||||||
inKnownPersons: [],
|
|
||||||
outShineConfirmed: [],
|
|
||||||
inShineConfirmed: [],
|
|
||||||
outShineSeen: [],
|
|
||||||
inShineSeen: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,12 +117,6 @@ async function buildRelationsModel(login) {
|
|||||||
inChildren: readArray(graph, 'inChildren') || [],
|
inChildren: readArray(graph, 'inChildren') || [],
|
||||||
outSiblings: readArray(graph, 'outSiblings') || [],
|
outSiblings: readArray(graph, 'outSiblings') || [],
|
||||||
inSiblings: readArray(graph, 'inSiblings') || [],
|
inSiblings: readArray(graph, 'inSiblings') || [],
|
||||||
outKnownPersons: readArray(graph, 'outKnownPersons') || [],
|
|
||||||
inKnownPersons: readArray(graph, 'inKnownPersons') || [],
|
|
||||||
outShineConfirmed: readArray(graph, 'outShineConfirmed') || [],
|
|
||||||
inShineConfirmed: readArray(graph, 'inShineConfirmed') || [],
|
|
||||||
outShineSeen: readArray(graph, 'outShineSeen') || [],
|
|
||||||
inShineSeen: readArray(graph, 'inShineSeen') || [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,12 +151,6 @@ export async function loadCurrentRelations() {
|
|||||||
inChildren: [],
|
inChildren: [],
|
||||||
outSiblings: [],
|
outSiblings: [],
|
||||||
inSiblings: [],
|
inSiblings: [],
|
||||||
outKnownPersons: [],
|
|
||||||
inKnownPersons: [],
|
|
||||||
outShineConfirmed: [],
|
|
||||||
inShineConfirmed: [],
|
|
||||||
outShineSeen: [],
|
|
||||||
inShineSeen: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return buildRelationsModel(login);
|
return buildRelationsModel(login);
|
||||||
@ -204,12 +170,6 @@ export function relationFlagsForTarget(relations, targetLogin) {
|
|||||||
inChild: listContainsLogin(relations?.inChildren, targetLogin),
|
inChild: listContainsLogin(relations?.inChildren, targetLogin),
|
||||||
outSibling: listContainsLogin(relations?.outSiblings, targetLogin),
|
outSibling: listContainsLogin(relations?.outSiblings, targetLogin),
|
||||||
inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
|
inSibling: listContainsLogin(relations?.inSiblings, targetLogin),
|
||||||
outKnownPerson: listContainsLogin(relations?.outKnownPersons, targetLogin),
|
|
||||||
inKnownPerson: listContainsLogin(relations?.inKnownPersons, targetLogin),
|
|
||||||
outShineConfirmed: listContainsLogin(relations?.outShineConfirmed, targetLogin),
|
|
||||||
inShineConfirmed: listContainsLogin(relations?.inShineConfirmed, targetLogin),
|
|
||||||
outShineSeen: listContainsLogin(relations?.outShineSeen, targetLogin),
|
|
||||||
inShineSeen: listContainsLogin(relations?.inShineSeen, targetLogin),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -644,8 +644,6 @@
|
|||||||
.avatar-image {
|
.avatar-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-image > .avatar-fallback,
|
.avatar-image > .avatar-fallback,
|
||||||
@ -1206,10 +1204,6 @@ textarea.input {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-danger-action {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-btn {
|
.small-btn {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -1641,49 +1635,6 @@ textarea.input {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-rel-opinions-wrap {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px dashed rgba(131, 196, 255, 0.45);
|
|
||||||
background: rgba(9, 18, 31, 0.42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-rel-opinions-wrap.is-empty .user-rel-opinions-list {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-rel-opinions-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-rel-opinion-item {
|
|
||||||
color: #d7e6ff;
|
|
||||||
line-height: 1.35;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-rel-opinions-hint {
|
|
||||||
color: rgba(173, 199, 236, 0.9);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-opinion-modal-btn {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-opinion-modal-btn.is-add {
|
|
||||||
border-color: rgba(97, 170, 255, 0.7);
|
|
||||||
color: #9fcbff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-opinion-modal-btn.is-remove {
|
|
||||||
border-color: rgba(255, 120, 120, 0.72);
|
|
||||||
color: #ff9b9b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@ -1830,14 +1781,6 @@ textarea.input {
|
|||||||
.channels-screen .page-header {
|
.channels-screen .page-header {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
position: sticky;
|
|
||||||
top: calc(-1 * max(10px, env(safe-area-inset-top)));
|
|
||||||
z-index: 14;
|
|
||||||
padding: calc(max(10px, env(safe-area-inset-top)) + 2px) 0 6px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(8, 12, 20, 0.9);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
-webkit-backdrop-filter: blur(14px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-screen .page-header .icon-btn,
|
.channels-screen .page-header .icon-btn,
|
||||||
@ -2228,47 +2171,21 @@ textarea.input {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-message-author-tile {
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(19, 24, 33, 0.9);
|
|
||||||
color: #f5f8ff;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-message-author-tile:hover {
|
|
||||||
background: rgba(28, 35, 48, 0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-message-avatar {
|
.channel-message-avatar {
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
min-width: 40px;
|
min-width: 44px;
|
||||||
min-height: 40px;
|
min-height: 44px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f4f6ff;
|
color: #f4f6ff;
|
||||||
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
|
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-message-avatar.avatar-image {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-message-author {
|
.channel-message-author {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@ -2276,7 +2193,7 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channel-message-title {
|
.channel-message-title {
|
||||||
font-size: 15px;
|
font-size: 20px;
|
||||||
color: #f5f8ff;
|
color: #f5f8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2292,16 +2209,8 @@ textarea.input {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-message-body--deleted {
|
|
||||||
color: #ff9e9e;
|
|
||||||
border: 1px solid rgba(255, 126, 126, 0.5);
|
|
||||||
background: rgba(120, 18, 18, 0.28);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-message-time {
|
.channel-message-time {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.48);
|
color: rgba(255, 255, 255, 0.48);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2381,25 +2290,6 @@ textarea.input {
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-edited-marker {
|
|
||||||
appearance: none;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(255, 220, 100, 0.85);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
margin-left: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-edited-marker:hover,
|
|
||||||
.message-edited-marker:focus-visible {
|
|
||||||
color: rgba(255, 232, 150, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-action-counter {
|
.channel-action-counter {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.45);
|
||||||
@ -2430,31 +2320,6 @@ textarea.input {
|
|||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-header-route-btn {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -66%) !important;
|
|
||||||
max-width: 72vw;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 14px;
|
|
||||||
min-height: 40px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-screen .page-header .channel-header-route-btn:hover,
|
|
||||||
.channels-screen .page-header .channel-header-route-btn:focus-visible {
|
|
||||||
transform: translate(-50%, -66%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-screen .page-header .channel-header-route-btn:active,
|
|
||||||
.channels-screen .page-header .channel-header-route-btn.is-springing {
|
|
||||||
transform: translate(-50%, -66%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-node-heading {
|
.thread-node-heading {
|
||||||
color: #f1dcab;
|
color: #f1dcab;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@ -2491,15 +2356,10 @@ textarea.input {
|
|||||||
|
|
||||||
.thread-node-actions {
|
.thread-node-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-screen--thread .thread-node-actions {
|
|
||||||
display: flex !important;
|
|
||||||
grid-template-columns: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-node-level {
|
.thread-node-level {
|
||||||
--depth: 0;
|
--depth: 0;
|
||||||
margin-left: calc(var(--depth) * 12px);
|
margin-left: calc(var(--depth) * 12px);
|
||||||
@ -2508,22 +2368,11 @@ textarea.input {
|
|||||||
.thread-block {
|
.thread-block {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
border: 1px solid rgba(151, 174, 221, 0.2);
|
border: 1px solid rgba(151, 174, 221, 0.2);
|
||||||
background: linear-gradient(160deg, rgba(10, 21, 43, 0.72), rgba(8, 16, 31, 0.78));
|
background: linear-gradient(160deg, rgba(10, 21, 43, 0.72), rgba(8, 16, 31, 0.78));
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-screen--thread .thread-node-card {
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-history-divider {
|
|
||||||
height: 0;
|
|
||||||
border-top: 2px solid rgba(255, 255, 255, 0.26);
|
|
||||||
margin: 6px 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread-block--ancestors > .section-title {
|
.thread-block--ancestors > .section-title {
|
||||||
color: #b9cbef;
|
color: #b9cbef;
|
||||||
}
|
}
|
||||||
@ -2590,10 +2439,6 @@ textarea.input {
|
|||||||
color: rgba(255, 255, 255, 0.55);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-open-btn {
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 430px) {
|
@media (max-width: 430px) {
|
||||||
.channels-screen .page-title {
|
.channels-screen .page-title {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
@ -2727,7 +2572,7 @@ textarea.input {
|
|||||||
|
|
||||||
.channels-tabs {
|
.channels-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@ -2735,19 +2580,6 @@ textarea.input {
|
|||||||
background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
|
background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-top-bar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-top-action-btn {
|
|
||||||
min-height: 38px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-tab-btn {
|
.channels-tab-btn {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -3312,14 +3144,6 @@ textarea.input {
|
|||||||
background: none;
|
background: none;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: translateY(-40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channels-screen--channel .page-header .channel-header-route-btn,
|
|
||||||
.channels-screen--thread .page-header .channel-header-route-btn {
|
|
||||||
border: 1px solid rgba(146, 173, 229, 0.38);
|
|
||||||
background: linear-gradient(180deg, rgba(20, 37, 67, 0.9), rgba(13, 24, 47, 0.94));
|
|
||||||
color: #d9e6ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-head-actions .secondary-btn {
|
.channel-head-actions .secondary-btn {
|
||||||
@ -3499,10 +3323,6 @@ textarea.input {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-screen .list-item {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .meta-muted {
|
.dm-screen .meta-muted {
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
@ -3525,49 +3345,6 @@ textarea.input {
|
|||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-row-meta-col {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
align-content: end;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 64px;
|
|
||||||
align-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-main {
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-title-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-title {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-last-message {
|
|
||||||
margin-top: 0 !important;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-time {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-chat-wrap {
|
.dm-chat-wrap {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
@ -3604,16 +3381,7 @@ textarea.input {
|
|||||||
|
|
||||||
.dm-chat-input {
|
.dm-chat-input {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto auto auto;
|
||||||
align-items: end;
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 10;
|
|
||||||
padding: 10px;
|
|
||||||
border-top: 1px solid rgba(212, 175, 55, 0.22);
|
|
||||||
background: rgba(8, 12, 20, 0.9);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-voice-btn {
|
.dm-voice-btn {
|
||||||
@ -3621,21 +3389,6 @@ textarea.input {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-actions-col {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-chat-input .dm-input {
|
|
||||||
min-height: 42px;
|
|
||||||
max-height: 180px;
|
|
||||||
resize: none;
|
|
||||||
overflow-y: auto;
|
|
||||||
line-height: 1.35;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.voice-level-wrap {
|
.voice-level-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@ -3673,35 +3426,6 @@ textarea.input {
|
|||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-send-icon-btn {
|
|
||||||
min-width: 42px;
|
|
||||||
width: 42px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-message-actions-menu {
|
|
||||||
width: min(52vw, 240px);
|
|
||||||
padding: 8px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-message-action-btn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speech-actions-top {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speech-send-now-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* DM messages-list status + empty block as full glass buttons */
|
/* DM messages-list status + empty block as full glass buttons */
|
||||||
.dm-screen .dm-status-line {
|
.dm-screen .dm-status-line {
|
||||||
display: block;
|
display: block;
|
||||||
@ -3821,12 +3545,6 @@ textarea.input {
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 238, 197, 0.3);
|
box-shadow: inset 0 1px 0 rgba(255, 238, 197, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-screen--list .channels-tab-btn.is-disabled {
|
|
||||||
text-decoration: line-through;
|
|
||||||
text-decoration-thickness: 1.5px;
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
background: rgba(18, 24, 38, 0.4);
|
background: rgba(18, 24, 38, 0.4);
|
||||||
backdrop-filter: blur(25px);
|
backdrop-filter: blur(25px);
|
||||||
@ -4117,13 +3835,7 @@ textarea.input {
|
|||||||
|
|
||||||
.profile-top-actions {
|
.profile-top-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1.6fr 1fr 1fr;
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-bottom-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4132,18 +3844,13 @@ textarea.input {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.15;
|
line-height: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: pre-line;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-links-header-btn {
|
|
||||||
white-space: pre-line;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-main-card {
|
.profile-main-card {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 2px 8px 8px;
|
padding: 2px 8px 8px;
|
||||||
|
|||||||
@ -113,21 +113,6 @@ public final class MsgSubType {
|
|||||||
/** Удалить связь "брат/сестра". */
|
/** Удалить связь "брат/сестра". */
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
public static final short CONNECTION_UNSIBLING = 55;
|
||||||
|
|
||||||
/** Просто знаю этого человека. */
|
|
||||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
|
||||||
/** Не знаю этого человека. */
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
|
||||||
|
|
||||||
/** Точно уверен, что сияющий. */
|
|
||||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
|
||||||
/** Не подтверждаю, что сияющий. */
|
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
|
||||||
|
|
||||||
/** Мало знаком, но видел сияющим. */
|
|
||||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
|
||||||
/** Не отмечаю, что видел сияющим. */
|
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
|
||||||
|
|
||||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
|
|||||||
@ -16,13 +16,9 @@ import java.util.Objects;
|
|||||||
* FRIEND=10, UNFRIEND=11
|
* FRIEND=10, UNFRIEND=11
|
||||||
* CONTACT=20, UNCONTACT=21
|
* CONTACT=20, UNCONTACT=21
|
||||||
* FOLLOW=30, UNFOLLOW=31
|
* FOLLOW=30, UNFOLLOW=31
|
||||||
* SPOUSE=40, UNSPOUSE=41
|
|
||||||
* PARENT=50, UNPARENT=51
|
* PARENT=50, UNPARENT=51
|
||||||
* CHILD=52, UNCHILD=53
|
* CHILD=52, UNCHILD=53
|
||||||
* SIBLING=54, UNSIBLING=55
|
* SIBLING=54, UNSIBLING=55
|
||||||
* KNOWN_PERSON=60, UNKNOWN_PERSON=61
|
|
||||||
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
|
|
||||||
* SHINE_SEEN=74, SHINE_UNSEEN=75
|
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
@ -196,13 +192,7 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_CHILD & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF);
|
||||||
|| v == (MsgSubType.CONNECTION_KNOWN_PERSON & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_UNKNOWN_PERSON & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_SEEN & 0xFFFF)
|
|
||||||
|| v == (MsgSubType.CONNECTION_SHINE_UNSEEN & 0xFFFF);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@ -105,7 +105,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextLineBody text", st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextLineBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "TextLineBody");
|
ensureNoTail(bb, "TextLineBody");
|
||||||
}
|
}
|
||||||
@ -129,9 +129,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && message.isBlank()) {
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
throw new IllegalArgumentException("message is blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
@ -167,15 +165,15 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
||||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||||
|
|
||||||
|
if (message == null || message.isBlank())
|
||||||
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
if (message == null) throw new IllegalArgumentException("EDIT_POST message is null");
|
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||||
} else {
|
} else {
|
||||||
if (message == null || message.isBlank())
|
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
|
||||||
if (toBlockGlobalNumber != null || toBlockHash32 != null)
|
if (toBlockGlobalNumber != null || toBlockHash32 != null)
|
||||||
throw new IllegalArgumentException("POST must not contain target fields");
|
throw new IllegalArgumentException("POST must not contain target fields");
|
||||||
}
|
}
|
||||||
@ -186,12 +184,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF) && msgUtf8.length == 0) {
|
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
int cap;
|
int cap;
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)) {
|
||||||
@ -238,12 +234,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
if (allowEmpty) return "";
|
|
||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
|
||||||
}
|
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
@ -255,7 +248,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
return s;
|
return s;
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
|||||||
@ -96,7 +96,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextReplyBody text");
|
||||||
ensureNoTail(bb, "TextReplyBody");
|
ensureNoTail(bb, "TextReplyBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,10 +113,8 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
|
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY");
|
||||||
}
|
}
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("message is blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
@ -144,18 +142,18 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||||
|
|
||||||
|
if (message == null || message.isBlank())
|
||||||
|
throw new IllegalArgumentException("Text message is blank");
|
||||||
|
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toBlockchainName == null || toBlockchainName.isBlank())
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
|
||||||
} else {
|
} else {
|
||||||
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
|
|
||||||
if (toBlockchainName != null)
|
if (toBlockchainName != null)
|
||||||
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
|
||||||
}
|
}
|
||||||
@ -166,12 +164,10 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
||||||
@ -217,12 +213,9 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
/* ====================== helpers ====================== */
|
/* ====================== helpers ====================== */
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) {
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
if (allowEmpty) return "";
|
|
||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
|
||||||
}
|
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
@ -234,7 +227,7 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
return s;
|
return s;
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e);
|
||||||
|
|||||||
@ -66,15 +66,6 @@ public final class DatabaseInitializer {
|
|||||||
public static final short CONNECTION_SIBLING = 54;
|
public static final short CONNECTION_SIBLING = 54;
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
public static final short CONNECTION_UNSIBLING = 55;
|
||||||
|
|
||||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
|
||||||
|
|
||||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
|
||||||
|
|
||||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
|
||||||
|
|
||||||
public static void createNewDB(String[] args) {
|
public static void createNewDB(String[] args) {
|
||||||
AppConfig config = AppConfig.getInstance();
|
AppConfig config = AppConfig.getInstance();
|
||||||
String dbPath = config.getParam("db.path");
|
String dbPath = config.getParam("db.path");
|
||||||
|
|||||||
@ -198,9 +198,6 @@ public final class DatabaseTriggersInstaller {
|
|||||||
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
|
int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT;
|
||||||
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
|
int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD;
|
||||||
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
|
int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING;
|
||||||
int KNOWN = (int) DatabaseInitializer.CONNECTION_KNOWN_PERSON;
|
|
||||||
int SHINE_CONF = (int) DatabaseInitializer.CONNECTION_SHINE_CONFIRMED;
|
|
||||||
int SHINE_SEEN = (int) DatabaseInitializer.CONNECTION_SHINE_SEEN;
|
|
||||||
|
|
||||||
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
|
int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
|
||||||
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
||||||
@ -209,16 +206,13 @@ public final class DatabaseTriggersInstaller {
|
|||||||
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
|
int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT;
|
||||||
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
|
int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD;
|
||||||
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
|
int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING;
|
||||||
int UNKNOWN = (int) DatabaseInitializer.CONNECTION_UNKNOWN_PERSON;
|
|
||||||
int SHINE_UNCONF = (int) DatabaseInitializer.CONNECTION_SHINE_UNCONFIRMED;
|
|
||||||
int SHINE_UNSEEN = (int) DatabaseInitializer.CONNECTION_SHINE_UNSEEN;
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
||||||
AFTER INSERT ON blocks
|
AFTER INSERT ON blocks
|
||||||
WHEN NEW.msg_type = 3
|
WHEN NEW.msg_type = 3
|
||||||
BEGIN
|
BEGIN
|
||||||
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING/KNOWN_PERSON/SHINE_*:
|
-- FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING:
|
||||||
-- 1) если записи нет — создаём
|
-- 1) если записи нет — создаём
|
||||||
INSERT OR IGNORE INTO connections_state (
|
INSERT OR IGNORE INTO connections_state (
|
||||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
||||||
@ -228,12 +222,6 @@ public final class DatabaseTriggersInstaller {
|
|||||||
NEW.msg_sub_type,
|
NEW.msg_sub_type,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -245,15 +233,9 @@ public final class DatabaseTriggersInstaller {
|
|||||||
NEW.to_bch_name,
|
NEW.to_bch_name,
|
||||||
NEW.to_block_number,
|
NEW.to_block_number,
|
||||||
NEW.to_block_hash
|
NEW.to_block_hash
|
||||||
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d)
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -274,12 +256,6 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND rel_type = NEW.msg_sub_type
|
AND rel_type = NEW.msg_sub_type
|
||||||
AND to_login = COALESCE(
|
AND to_login = COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -288,15 +264,9 @@ public final class DatabaseTriggersInstaller {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d)
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -307,18 +277,12 @@ public final class DatabaseTriggersInstaller {
|
|||||||
) IS NOT NULL
|
) IS NOT NULL
|
||||||
AND NEW.to_bch_name IS NOT NULL;
|
AND NEW.to_bch_name IS NOT NULL;
|
||||||
|
|
||||||
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING/UNKNOWN_PERSON/SHINE_UN*:
|
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNSPOUSE/UNPARENT/UNCHILD/UNSIBLING:
|
||||||
-- удаляем соответствующее "позитивное" состояние
|
-- удаляем соответствующее "позитивное" состояние
|
||||||
DELETE FROM connections_state
|
DELETE FROM connections_state
|
||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
AND to_login = COALESCE(
|
AND to_login = COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -335,19 +299,10 @@ public final class DatabaseTriggersInstaller {
|
|||||||
WHEN %d THEN %d
|
WHEN %d THEN %d
|
||||||
WHEN %d THEN %d
|
WHEN %d THEN %d
|
||||||
WHEN %d THEN %d
|
WHEN %d THEN %d
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
WHEN %d THEN %d
|
|
||||||
ELSE rel_type
|
ELSE rel_type
|
||||||
END
|
END
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
(
|
|
||||||
SELECT su.login
|
|
||||||
FROM solana_users su
|
|
||||||
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
|
||||||
LIMIT 1
|
|
||||||
),
|
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -356,11 +311,11 @@ public final class DatabaseTriggersInstaller {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
) IS NOT NULL
|
) IS NOT NULL
|
||||||
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d);
|
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d);
|
||||||
END;
|
END;
|
||||||
""".formatted(
|
""".formatted(
|
||||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING,
|
||||||
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING, KNOWN, SHINE_CONF, SHINE_SEEN,
|
FRIEND, CONTACT, FOLLOW, SPOUSE, PARENT, CHILD, SIBLING,
|
||||||
|
|
||||||
UNFRIEND, FRIEND,
|
UNFRIEND, FRIEND,
|
||||||
UNCONTACT, CONTACT,
|
UNCONTACT, CONTACT,
|
||||||
@ -369,11 +324,8 @@ public final class DatabaseTriggersInstaller {
|
|||||||
UNPARENT, PARENT,
|
UNPARENT, PARENT,
|
||||||
UNCHILD, CHILD,
|
UNCHILD, CHILD,
|
||||||
UNSIBLING, SIBLING,
|
UNSIBLING, SIBLING,
|
||||||
UNKNOWN, KNOWN,
|
|
||||||
SHINE_UNCONF, SHINE_CONF,
|
|
||||||
SHINE_UNSEEN, SHINE_SEEN,
|
|
||||||
|
|
||||||
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING, UNKNOWN, SHINE_UNCONF, SHINE_UNSEEN
|
UNFRIEND, UNCONTACT, UNFOLLOW, UNSPOUSE, UNPARENT, UNCHILD, UNSIBLING
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,10 +40,8 @@ public final class MsgSubType {
|
|||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
/**
|
/**
|
||||||
* Совпадает с ConnectionBody:
|
* Совпадает с ConnectionBody:
|
||||||
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54,
|
* SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, SPOUSE=40, PARENT=50, CHILD=52, SIBLING=54
|
||||||
* KNOWN_PERSON=60, SHINE_CONFIRMED=70, SHINE_SEEN=74
|
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55
|
||||||
* UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNSPOUSE=41, UNPARENT=51, UNCHILD=53, UNSIBLING=55,
|
|
||||||
* UNKNOWN_PERSON=61, SHINE_UNCONFIRMED=71, SHINE_UNSEEN=75
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Добавить в близкие друзья (close friend). */
|
/** Добавить в близкие друзья (close friend). */
|
||||||
@ -94,24 +92,6 @@ public final class MsgSubType {
|
|||||||
/** Удалить связь "брат/сестра". */
|
/** Удалить связь "брат/сестра". */
|
||||||
public static final short CONNECTION_UNSIBLING = 55;
|
public static final short CONNECTION_UNSIBLING = 55;
|
||||||
|
|
||||||
/** Просто знаю этого человека. */
|
|
||||||
public static final short CONNECTION_KNOWN_PERSON = 60;
|
|
||||||
|
|
||||||
/** Не знаю этого человека. */
|
|
||||||
public static final short CONNECTION_UNKNOWN_PERSON = 61;
|
|
||||||
|
|
||||||
/** Точно уверен, что сияющий. */
|
|
||||||
public static final short CONNECTION_SHINE_CONFIRMED = 70;
|
|
||||||
|
|
||||||
/** Не подтверждаю, что сияющий. */
|
|
||||||
public static final short CONNECTION_SHINE_UNCONFIRMED = 71;
|
|
||||||
|
|
||||||
/** Мало знаком, но видел сияющим. */
|
|
||||||
public static final short CONNECTION_SHINE_SEEN = 74;
|
|
||||||
|
|
||||||
/** Не отмечаю, что видел сияющим. */
|
|
||||||
public static final short CONNECTION_SHINE_UNSEEN = 75;
|
|
||||||
|
|
||||||
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
|
|||||||
@ -40,15 +40,13 @@ public final class ConnectionsStateDAO {
|
|||||||
*/
|
*/
|
||||||
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
SELECT u.login AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN solana_users u_login
|
JOIN solana_users u
|
||||||
ON u_login.login = cs.to_login COLLATE NOCASE
|
ON u.login = cs.to_login COLLATE NOCASE
|
||||||
LEFT JOIN solana_users u_bch
|
|
||||||
ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
|
|
||||||
WHERE cs.login = ? COLLATE NOCASE
|
WHERE cs.login = ? COLLATE NOCASE
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY friend_login
|
ORDER BY u.login
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
@ -70,25 +68,19 @@ public final class ConnectionsStateDAO {
|
|||||||
*/
|
*/
|
||||||
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
SELECT u.login AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
LEFT JOIN solana_users u_actor
|
JOIN solana_users u
|
||||||
ON u_actor.login = cs.login COLLATE NOCASE
|
ON u.login = cs.login COLLATE NOCASE
|
||||||
LEFT JOIN solana_users u_target
|
WHERE cs.to_login = ? COLLATE NOCASE
|
||||||
ON u_target.login = ? COLLATE NOCASE
|
|
||||||
WHERE (
|
|
||||||
cs.to_login = ? COLLATE NOCASE
|
|
||||||
OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
|
|
||||||
)
|
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY friend_login
|
ORDER BY u.login
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, loginAnyCase);
|
ps.setString(1, loginAnyCase);
|
||||||
ps.setString(2, loginAnyCase);
|
ps.setInt(2, relType);
|
||||||
ps.setInt(3, relType);
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
String v = rs.getString("friend_login");
|
String v = rs.getString("friend_login");
|
||||||
|
|||||||
@ -410,23 +410,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
// target columns (optional)
|
// target columns (optional)
|
||||||
if (block.body instanceof BodyHasTarget t) {
|
if (block.body instanceof BodyHasTarget t) {
|
||||||
String targetBchName = t.toBchName();
|
|
||||||
int type = block.type & 0xFFFF;
|
|
||||||
int sub = block.subType & 0xFFFF;
|
|
||||||
boolean isTextEdit = type == 1
|
|
||||||
&& (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
|
||||||
if (isTextEdit && (targetBchName == null || targetBchName.isBlank())) {
|
|
||||||
targetBchName = blockchainName;
|
|
||||||
}
|
|
||||||
|
|
||||||
be.setToLogin(t.toLogin());
|
be.setToLogin(t.toLogin());
|
||||||
be.setToBchName(targetBchName);
|
be.setToBchName(t.toBchName());
|
||||||
be.setToBlockNumber(t.toBlockGlobalNumber());
|
be.setToBlockNumber(t.toBlockGlobalNumber());
|
||||||
be.setToBlockHash(t.toBlockHashBytes());
|
be.setToBlockHash(t.toBlockHashBytes());
|
||||||
|
|
||||||
if (isTextEdit && (be.getToLogin() == null || be.getToLogin().isBlank()) && targetBchName != null && !targetBchName.isBlank()) {
|
|
||||||
be.setToLogin(BlockchainNameUtil.loginFromBlockchainName(targetBchName));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
// edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели"
|
||||||
|
|||||||
@ -218,17 +218,16 @@ final class ChannelsReadSupport {
|
|||||||
SELECT login,bch_name,block_number,block_hash,block_bytes
|
SELECT login,bch_name,block_number,block_hash,block_bytes
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type=?
|
||||||
AND to_block_number=? AND to_block_hash=?
|
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||||
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
|
|
||||||
ORDER BY block_number ASC
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
ps.setInt(3, MsgSubType.TEXT_EDIT_POST);
|
||||||
ps.setInt(4, originalBlock);
|
ps.setString(4, ownerBch);
|
||||||
ps.setBytes(5, originalHash);
|
ps.setInt(5, originalBlock);
|
||||||
ps.setString(6, ownerBch);
|
ps.setBytes(6, originalHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
@ -111,7 +111,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
v1.setCreatedAtMs(postText.createdAtMs);
|
v1.setCreatedAtMs(postText.createdAtMs);
|
||||||
versionsOut.add(v1);
|
versionsOut.add(v1);
|
||||||
|
|
||||||
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash);
|
List<ChannelsReadSupport.PostBlock> edits = ChannelsReadSupport.versionsForPost(c, ownerBch, post.blockNumber, post.blockHash);
|
||||||
for (ChannelsReadSupport.PostBlock edit : edits) {
|
for (ChannelsReadSupport.PostBlock edit : edits) {
|
||||||
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes);
|
||||||
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
|
Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem();
|
||||||
|
|||||||
@ -196,16 +196,15 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
WHERE bch_name=? AND msg_type=1 AND msg_sub_type=?
|
||||||
AND to_block_number=? AND to_block_hash=?
|
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||||
AND (to_bch_name=? OR to_bch_name IS NULL OR to_bch_name='')
|
|
||||||
ORDER BY block_number ASC
|
ORDER BY block_number ASC
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bch);
|
ps.setString(1, bch);
|
||||||
ps.setInt(2, subType);
|
ps.setInt(2, subType);
|
||||||
ps.setInt(3, targetBlock);
|
ps.setString(3, bch);
|
||||||
ps.setBytes(4, targetHash);
|
ps.setInt(4, targetBlock);
|
||||||
ps.setString(5, bch);
|
ps.setBytes(5, targetHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostRow> out = new ArrayList<>();
|
List<PostRow> out = new ArrayList<>();
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
|||||||
@ -30,14 +30,10 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
||||||
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? "" : req.getLogin().trim();
|
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||||
if (requestedLogin.isEmpty()) {
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||||
if (ctx != null && ctx.isAuthenticatedUser()) {
|
|
||||||
requestedLogin = ctx.getLogin();
|
|
||||||
} else {
|
|
||||||
return NetExceptionResponseFactory.error(req, 422, "LOGIN_REQUIRED", "Нужно передать login пользователя");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim();
|
||||||
|
|
||||||
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||||
String canonicalLogin = findCanonicalLogin(c, requestedLogin);
|
String canonicalLogin = findCanonicalLogin(c, requestedLogin);
|
||||||
@ -59,18 +55,11 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
||||||
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||||
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||||
List<String> outKnownPersons = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
|
||||||
List<String> inKnownPersons = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_KNOWN_PERSON);
|
|
||||||
List<String> outShineConfirmed = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
|
||||||
List<String> inShineConfirmed = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_CONFIRMED);
|
|
||||||
List<String> outShineSeen = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
|
||||||
List<String> inShineSeen = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SHINE_SEEN);
|
|
||||||
|
|
||||||
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
||||||
allLogins.add(canonicalLogin);
|
allLogins.add(canonicalLogin);
|
||||||
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
|
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
|
||||||
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings,
|
outSpouses, inSpouses, outParents, inParents, outChildren, inChildren, outSiblings, inSiblings);
|
||||||
outKnownPersons, inKnownPersons, outShineConfirmed, inShineConfirmed, outShineSeen, inShineSeen);
|
|
||||||
|
|
||||||
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
||||||
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
List<String> spouseLogins = mergeUnique(outSpouses, inSpouses);
|
||||||
@ -97,12 +86,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
resp.setInChildren(inChildren);
|
resp.setInChildren(inChildren);
|
||||||
resp.setOutSiblings(outSiblings);
|
resp.setOutSiblings(outSiblings);
|
||||||
resp.setInSiblings(inSiblings);
|
resp.setInSiblings(inSiblings);
|
||||||
resp.setOutKnownPersons(outKnownPersons);
|
|
||||||
resp.setInKnownPersons(inKnownPersons);
|
|
||||||
resp.setOutShineConfirmed(outShineConfirmed);
|
|
||||||
resp.setInShineConfirmed(inShineConfirmed);
|
|
||||||
resp.setOutShineSeen(outShineSeen);
|
|
||||||
resp.setInShineSeen(inShineSeen);
|
|
||||||
resp.setParents(toRelativeItems(parentLogins, metaByLogin));
|
resp.setParents(toRelativeItems(parentLogins, metaByLogin));
|
||||||
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
|
resp.setChildren(toRelativeItems(childLogins, metaByLogin));
|
||||||
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
|
resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin));
|
||||||
|
|||||||
@ -21,12 +21,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
private List<String> inChildren = new ArrayList<>();
|
private List<String> inChildren = new ArrayList<>();
|
||||||
private List<String> outSiblings = new ArrayList<>();
|
private List<String> outSiblings = new ArrayList<>();
|
||||||
private List<String> inSiblings = new ArrayList<>();
|
private List<String> inSiblings = new ArrayList<>();
|
||||||
private List<String> outKnownPersons = new ArrayList<>();
|
|
||||||
private List<String> inKnownPersons = new ArrayList<>();
|
|
||||||
private List<String> outShineConfirmed = new ArrayList<>();
|
|
||||||
private List<String> inShineConfirmed = new ArrayList<>();
|
|
||||||
private List<String> outShineSeen = new ArrayList<>();
|
|
||||||
private List<String> inShineSeen = new ArrayList<>();
|
|
||||||
private List<RelativeItem> parents = new ArrayList<>();
|
private List<RelativeItem> parents = new ArrayList<>();
|
||||||
private List<RelativeItem> children = new ArrayList<>();
|
private List<RelativeItem> children = new ArrayList<>();
|
||||||
private List<RelativeItem> siblings = new ArrayList<>();
|
private List<RelativeItem> siblings = new ArrayList<>();
|
||||||
@ -108,18 +102,6 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; }
|
public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; }
|
||||||
public List<String> getInSiblings() { return inSiblings; }
|
public List<String> getInSiblings() { return inSiblings; }
|
||||||
public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; }
|
public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; }
|
||||||
public List<String> getOutKnownPersons() { return outKnownPersons; }
|
|
||||||
public void setOutKnownPersons(List<String> outKnownPersons) { this.outKnownPersons = outKnownPersons; }
|
|
||||||
public List<String> getInKnownPersons() { return inKnownPersons; }
|
|
||||||
public void setInKnownPersons(List<String> inKnownPersons) { this.inKnownPersons = inKnownPersons; }
|
|
||||||
public List<String> getOutShineConfirmed() { return outShineConfirmed; }
|
|
||||||
public void setOutShineConfirmed(List<String> outShineConfirmed) { this.outShineConfirmed = outShineConfirmed; }
|
|
||||||
public List<String> getInShineConfirmed() { return inShineConfirmed; }
|
|
||||||
public void setInShineConfirmed(List<String> inShineConfirmed) { this.inShineConfirmed = inShineConfirmed; }
|
|
||||||
public List<String> getOutShineSeen() { return outShineSeen; }
|
|
||||||
public void setOutShineSeen(List<String> outShineSeen) { this.outShineSeen = outShineSeen; }
|
|
||||||
public List<String> getInShineSeen() { return inShineSeen; }
|
|
||||||
public void setInShineSeen(List<String> inShineSeen) { this.inShineSeen = inShineSeen; }
|
|
||||||
public List<RelativeItem> getParents() { return parents; }
|
public List<RelativeItem> getParents() { return parents; }
|
||||||
public void setParents(List<RelativeItem> parents) { this.parents = parents; }
|
public void setParents(List<RelativeItem> parents) { this.parents = parents; }
|
||||||
public List<RelativeItem> getChildren() { return children; }
|
public List<RelativeItem> getChildren() { return children; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user