Compare commits
4 Commits
f9a15ab192
...
827d2e9c3e
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
827d2e9c3e | ||
|
|
0f3c4a621d | ||
|
|
e60475f351 | ||
|
|
0f63f7dae6 |
@ -68,6 +68,12 @@
|
|||||||
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
|
- Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду.
|
||||||
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
|
- Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет.
|
||||||
|
|
||||||
|
## Документация Figma
|
||||||
|
- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`.
|
||||||
|
- Точка входа: `Dev_Docs/Figma/README.md`.
|
||||||
|
- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`.
|
||||||
|
- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ.
|
||||||
|
|
||||||
## Известная проблема (временная пометка)
|
## Известная проблема (временная пометка)
|
||||||
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
|
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
|
||||||
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
|
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
|
||||||
|
|||||||
@ -89,10 +89,10 @@
|
|||||||
| Компонент | Статус |
|
| Компонент | Статус |
|
||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
| Регистрация серверной PDA в Solana | ✅ Реализовано |
|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
|
||||||
| Чтение `sync_servers` из PDA | Нужна реализация |
|
| Чтение `sync_servers` из PDA | ✅ Реализовано |
|
||||||
| Межсерверный WebSocket-канал | Нужна реализация |
|
| Межсерверный WebSocket-канал | Нужна реализация |
|
||||||
| Push новых DM партнёрам | Нужна реализация |
|
| Push новых DM партнёрам | Нужна реализация |
|
||||||
| Push блоков блокчейна партнёрам | Нужна реализация |
|
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия |
|
||||||
| Backfill при первом подключении | Нужна реализация |
|
| Backfill при первом подключении | Нужна реализация |
|
||||||
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
|
||||||
|
|
||||||
|
|||||||
33
Dev_Docs/Figma/README.md
Normal file
33
Dev_Docs/Figma/README.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Figma
|
||||||
|
|
||||||
|
Эта папка хранит рабочие инструкции по переносу экранов SHiNE в Figma и по обратному переносу изменений из Figma в код.
|
||||||
|
|
||||||
|
## Что здесь лежит
|
||||||
|
|
||||||
|
- `README.md` — точка входа и краткий регламент.
|
||||||
|
- `TRANSFER_UI_SCREENS.md` — подробная инструкция по переносу экранов UI в Figma и обратно.
|
||||||
|
|
||||||
|
## Когда читать
|
||||||
|
|
||||||
|
Читать перед любыми задачами вида:
|
||||||
|
- перенести экран из `shine-UI` в Figma;
|
||||||
|
- собрать новый Figma-файл для экранов SHiNE;
|
||||||
|
- перенести изменения из Figma обратно в код;
|
||||||
|
- уточнить, каким способом переносить экраны: по одному или пачкой.
|
||||||
|
|
||||||
|
## Ключевое правило
|
||||||
|
|
||||||
|
Для экранов SHiNE безопасный рабочий способ на текущий момент:
|
||||||
|
- переносить экраны в Figma по одному;
|
||||||
|
- не пытаться сразу переносить длинный auth-flow пачкой;
|
||||||
|
- после каждого переноса визуально проверять результат в самой Figma;
|
||||||
|
- только после удачного одного экрана переходить к следующему.
|
||||||
|
|
||||||
|
## Про Miro
|
||||||
|
|
||||||
|
Отдельной папки `Miro` пока нет.
|
||||||
|
|
||||||
|
Причина:
|
||||||
|
- практики по Miro в проекте пока мало;
|
||||||
|
- устойчивого процесса ещё нет;
|
||||||
|
- как только появится стабильный сценарий работы с Miro, его нужно будет оформить аналогично Figma.
|
||||||
224
Dev_Docs/Figma/TRANSFER_UI_SCREENS.md
Normal file
224
Dev_Docs/Figma/TRANSFER_UI_SCREENS.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
# Перенос экранов UI в Figma и обратно
|
||||||
|
|
||||||
|
## Зачем нужен этот документ
|
||||||
|
|
||||||
|
Этот документ фиксирует практический опыт, который уже был получен на переносе стартового экрана, экрана регистрации и дальнейших попытках.
|
||||||
|
|
||||||
|
Главная цель:
|
||||||
|
- чтобы агент не повторял неудачные попытки;
|
||||||
|
- чтобы переносы делались одинаково;
|
||||||
|
- чтобы изменения из Figma можно было уверенно переносить назад в `shine-UI`.
|
||||||
|
|
||||||
|
## Где находится основной UI
|
||||||
|
|
||||||
|
- основной клиентский UI: `shine-UI/`
|
||||||
|
- маршруты и список pre-auth экранов: `shine-UI/js/router.js`
|
||||||
|
- экраны: `shine-UI/js/pages/`
|
||||||
|
- общие стили: `shine-UI/styles/main.css`, `shine-UI/styles/layout.css`, `shine-UI/styles/components.css`
|
||||||
|
|
||||||
|
## Что считать успешным переносом в Figma
|
||||||
|
|
||||||
|
Успешный перенос экрана в Figma — это не просто фон и прямоугольники.
|
||||||
|
|
||||||
|
Нужно, чтобы:
|
||||||
|
- были видны все ключевые текстовые элементы;
|
||||||
|
- кнопки были перенесены как отдельные элементы;
|
||||||
|
- поля ввода были явно видны;
|
||||||
|
- экран был узнаваем визуально;
|
||||||
|
- пользователь мог вручную подправить макет в Figma;
|
||||||
|
- после правок можно было понять, что именно переносить обратно в код.
|
||||||
|
|
||||||
|
## Текущий рабочий способ
|
||||||
|
|
||||||
|
На текущем проекте лучший практический способ такой:
|
||||||
|
|
||||||
|
1. Переносить только один экран за раз.
|
||||||
|
2. Сначала читать конкретный `js/pages/<screen>.js`.
|
||||||
|
3. Затем читать связанные стили из `styles/components.css` и `styles/layout.css`.
|
||||||
|
4. После этого вручную собирать экран в Figma как отдельный frame с явными элементами.
|
||||||
|
5. Проверять в Figma, что не получился только фон без текста и контролов.
|
||||||
|
6. Только после успешной проверки переходить к следующему экрану.
|
||||||
|
|
||||||
|
## Почему нельзя переносить пачкой
|
||||||
|
|
||||||
|
Был получен негативный опыт:
|
||||||
|
- при переносе сразу многих экранов в Figma часть экранов отображалась как фон без нормальных надписей и элементов;
|
||||||
|
- длинные экраны с большим количеством текста и форм разваливались;
|
||||||
|
- автогенерация давала внешний вид, непригодный для ручной доработки.
|
||||||
|
|
||||||
|
Поэтому правило такое:
|
||||||
|
- auth-flow, регистрация, вход, onboarding — переносить по одному экрану;
|
||||||
|
- после каждого экрана ждать визуального подтверждения пользователя;
|
||||||
|
- не объединять 5-10 экранов в один проход без отдельного разрешения и без промежуточной проверки.
|
||||||
|
|
||||||
|
## Рекомендуемый порядок переноса в Figma
|
||||||
|
|
||||||
|
### Вперёд: код -> Figma
|
||||||
|
|
||||||
|
1. Определить точный экран.
|
||||||
|
2. Найти файл экрана в `shine-UI/js/pages/`.
|
||||||
|
3. Найти используемые CSS-классы через поиск по файлу экрана.
|
||||||
|
4. Вытащить:
|
||||||
|
- тексты;
|
||||||
|
- состав кнопок;
|
||||||
|
- поля ввода;
|
||||||
|
- карточки;
|
||||||
|
- блоки статуса;
|
||||||
|
- последовательность секций.
|
||||||
|
5. Если экран длинный, всё равно переносить его как один frame, но собирать блоками сверху вниз.
|
||||||
|
6. В Figma создавать отдельный экран рядом с уже существующими экранами, а не смешивать всё в одну кучу.
|
||||||
|
7. После создания экрана проверить метаданные/скриншот Figma, если инструмент это позволяет.
|
||||||
|
|
||||||
|
### Назад: Figma -> код
|
||||||
|
|
||||||
|
1. Снять актуальный скриншот изменённого Figma-экрана.
|
||||||
|
2. Получить метаданные узла, если это помогает понять структуру.
|
||||||
|
3. Сравнить Figma с текущим кодом экрана.
|
||||||
|
4. Переносить обратно в код только реальные изменения:
|
||||||
|
- порядок блоков;
|
||||||
|
- тексты;
|
||||||
|
- размеры/отступы;
|
||||||
|
- наличие или отсутствие карточек;
|
||||||
|
- подписи кнопок;
|
||||||
|
- видимость блоков.
|
||||||
|
5. Не придумывать новые UX-решения без отдельного подтверждения пользователя, если их нет в Figma.
|
||||||
|
6. После правок проверять экран локально или как минимум по коду и зависимостям.
|
||||||
|
|
||||||
|
## Что переносить вручную
|
||||||
|
|
||||||
|
Вручную, а не автогенерацией, нужно переносить:
|
||||||
|
- экраны регистрации;
|
||||||
|
- экраны входа;
|
||||||
|
- длинные формы;
|
||||||
|
- экраны с несколькими карточками;
|
||||||
|
- экраны с длинными объясняющими текстами;
|
||||||
|
- экраны, где важен порядок блоков.
|
||||||
|
|
||||||
|
Причина:
|
||||||
|
- именно они чаще всего ломаются при слишком автоматическом переносе.
|
||||||
|
|
||||||
|
## Какие ошибки уже были
|
||||||
|
|
||||||
|
### Ошибка 1. Перенос пачкой
|
||||||
|
|
||||||
|
Проблема:
|
||||||
|
- несколько экранов были добавлены сразу;
|
||||||
|
- пользователь увидел, что на экранах в Figma «какая-то ерунда».
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
- переносить по одному.
|
||||||
|
|
||||||
|
### Ошибка 2. Видно только фон
|
||||||
|
|
||||||
|
Проблема:
|
||||||
|
- frame создавался, фон и свечения были видны;
|
||||||
|
- тексты и элементы либо не появлялись, либо получались непригодными.
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
- при сложных экранах собирать элементы вручную и явно.
|
||||||
|
|
||||||
|
### Ошибка 3. Слишком вольная реконструкция
|
||||||
|
|
||||||
|
Проблема:
|
||||||
|
- экран формально был перенесён, но визуально не соответствовал ожиданию пользователя.
|
||||||
|
|
||||||
|
Вывод:
|
||||||
|
- для SHiNE важнее узнаваемый и редактируемый экран, чем «формально похожий» экран.
|
||||||
|
|
||||||
|
## Обязательные проверки после переноса в Figma
|
||||||
|
|
||||||
|
После каждого нового экрана агент должен проверить:
|
||||||
|
- виден ли заголовок;
|
||||||
|
- видны ли кнопки;
|
||||||
|
- видны ли поля ввода;
|
||||||
|
- не исчезли ли длинные тексты;
|
||||||
|
- не сломан ли порядок секций;
|
||||||
|
- не оказался ли на холсте только фон и пустые прямоугольники.
|
||||||
|
|
||||||
|
Если хотя бы один пункт не выполнен:
|
||||||
|
- не считать перенос завершённым;
|
||||||
|
- либо переделать экран сразу;
|
||||||
|
- либо остановиться и показать пользователю только после исправления.
|
||||||
|
|
||||||
|
## Правила для длинных экранов
|
||||||
|
|
||||||
|
Если экран длинный, например регистрация:
|
||||||
|
- высота frame может быть больше стандартной мобильной высоты;
|
||||||
|
- секции должны идти в правильном вертикальном порядке;
|
||||||
|
- отдельные карточки должны быть вынесены в отдельные блоки;
|
||||||
|
- тексты лучше упрощённо располагать вручную, чем терять их совсем.
|
||||||
|
|
||||||
|
## Правила для экрана регистрации
|
||||||
|
|
||||||
|
Экран `register-view` особенно чувствительный.
|
||||||
|
|
||||||
|
При переносе нужно отдельно учитывать:
|
||||||
|
- заголовок и стрелку назад;
|
||||||
|
- поля логина и пароля;
|
||||||
|
- переключатель режима 12 слов;
|
||||||
|
- сетку слов;
|
||||||
|
- строку статуса длины пароля;
|
||||||
|
- строку статуса проверки логина;
|
||||||
|
- кнопку проверки логина;
|
||||||
|
- отдельную карточку первого сервера;
|
||||||
|
- отдельную карточку FAQ;
|
||||||
|
- нижние кнопки `Назад` и `Далее`.
|
||||||
|
|
||||||
|
## Правила для экрана входа
|
||||||
|
|
||||||
|
Для экранов входа важно не смешивать:
|
||||||
|
- экран выбора способа входа;
|
||||||
|
- вход по логину/паролю;
|
||||||
|
- вход через другое устройство;
|
||||||
|
- вход по QR.
|
||||||
|
|
||||||
|
Каждый из них переносить отдельно.
|
||||||
|
|
||||||
|
## Что делать после правок пользователя в Figma
|
||||||
|
|
||||||
|
Если пользователь изменил экран в Figma:
|
||||||
|
|
||||||
|
1. Считать Figma источником визуальной правки.
|
||||||
|
2. Сначала понять, что именно изменено:
|
||||||
|
- тексты;
|
||||||
|
- порядок блоков;
|
||||||
|
- наличие блоков;
|
||||||
|
- размеры;
|
||||||
|
- отступы;
|
||||||
|
- логика flow.
|
||||||
|
3. Переносить эти изменения назад в код минимально необходимыми правками.
|
||||||
|
4. Если из Figma следует уже не только визуальная, но и UX-логическая правка, отдельно проверить, что она согласована пользователем.
|
||||||
|
|
||||||
|
## Когда нужно добавить заметку в Pending_Features
|
||||||
|
|
||||||
|
Если после изменения по Figma:
|
||||||
|
- поменялась логика flow;
|
||||||
|
- поменялась регистрация/вход;
|
||||||
|
- нужен реальный прогон на test2;
|
||||||
|
- затронута интеграция с Solana;
|
||||||
|
|
||||||
|
тогда нужно добавить файл в `Dev_Docs/Pending_Features/`.
|
||||||
|
|
||||||
|
## Что пока не оформлено для Miro
|
||||||
|
|
||||||
|
По Miro пока нет устойчивого процесса.
|
||||||
|
|
||||||
|
Из того, что уже понятно:
|
||||||
|
- пока не стоит обещать такой же отлаженный перенос, как для Figma;
|
||||||
|
- сначала нужно накопить хотя бы 2-3 реальных сценария работы;
|
||||||
|
- только после этого оформлять отдельную папку и регламент.
|
||||||
|
|
||||||
|
## Краткая памятка для агента
|
||||||
|
|
||||||
|
Если задача звучит как:
|
||||||
|
- «перенеси экран в Figma»;
|
||||||
|
- «добавь экран в Figma»;
|
||||||
|
- «я поправил экран в Figma, перенеси назад»;
|
||||||
|
|
||||||
|
то агент должен:
|
||||||
|
|
||||||
|
1. Прочитать этот документ.
|
||||||
|
2. Работать по одному экрану.
|
||||||
|
3. Не переносить auth-flow пачкой.
|
||||||
|
4. Проверять результат после каждого экрана.
|
||||||
|
5. При переносе обратно в код не гадать, а опираться на Figma-правки.
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
# Стартовая загрузка `sync_servers` из server PDA
|
||||||
|
|
||||||
|
- Краткое описание:
|
||||||
|
- При запуске сервер читает свой логин из `server.SHiNE.login`, загружает свою server PDA из Solana, достаёт `sync_servers`, затем читает PDA партнёров и сохраняет их `login + server_address + updated_at_ms` в локальную таблицу `sync_servers`.
|
||||||
|
- Что проверять:
|
||||||
|
- В `application.properties` задан `server.SHiNE.login=shineupme`.
|
||||||
|
- После старта сервера в SQLite появилась/обновилась таблица `sync_servers`.
|
||||||
|
- В таблице лежат логины и адреса серверов из `sync_servers` текущего server PDA.
|
||||||
|
- При изменении `sync_servers` или `server_address` в Solana и перезапуске сервера локальная таблица обновляется.
|
||||||
|
- Ожидаемый результат:
|
||||||
|
- Сервер без ручного ввода адресов подтягивает партнёров синхронизации из Solana PDA и хранит их локально для следующих этапов репликации.
|
||||||
|
- Статус:
|
||||||
|
- `pending`
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
# Фоновая one-shot синхронизация `AddBlock` на `sync_servers`
|
||||||
|
|
||||||
|
- Краткое описание:
|
||||||
|
- После успешного локального `AddBlock` сервер в фоне пытается отправить тот же блок всем партнёрам из локальной таблицы `sync_servers`.
|
||||||
|
- Если партнёр отвечает `bad_prev_hash` или `bad_block_number`, сервер один раз делает backfill: читает недостающие блоки из БД по диапазону и досылает их по одному.
|
||||||
|
- Если в процессе возникает новая ошибка, попытка для этого партнёра прерывается без повторов.
|
||||||
|
- Что проверять:
|
||||||
|
- При добавлении нового блока клиент получает быстрый `OK`, не ожидая завершения межсерверной рассылки.
|
||||||
|
- В логах видно попытки отправки на адреса из `sync_servers`.
|
||||||
|
- При отставании партнёра сервер досылает пропущенный хвост блоков по одному.
|
||||||
|
- При ошибке после backfill сервер не зацикливается и не блокирует основной `AddBlock`.
|
||||||
|
- Ожидаемый результат:
|
||||||
|
- Репликация `AddBlock` работает в фоне и не ломает основной путь записи блока.
|
||||||
|
- Статус:
|
||||||
|
- `pending`
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
## Краткое описание
|
||||||
|
|
||||||
|
Доработан UX личного чата на мобильных устройствах:
|
||||||
|
- при открытой экранной клавиатуре нижний тулбар на 5 кнопок временно скрывается;
|
||||||
|
- после отправки собственного сообщения чат автоматически прокручивается вниз так, чтобы новое сообщение было видно сразу.
|
||||||
|
- при открытии уже существующего личного чата стартовая позиция выбирается по хвосту переписки:
|
||||||
|
- если есть непрочитанные сообщения, открытие происходит на линии `Новые сообщения`;
|
||||||
|
- если непрочитанных нет, чат открывается сразу в самом низу.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
|
||||||
|
- открыть личный чат на телефоне;
|
||||||
|
- тапнуть в поле ввода и убедиться, что нижний тулбар скрывается после появления клавиатуры;
|
||||||
|
- закрыть клавиатуру и убедиться, что тулбар возвращается;
|
||||||
|
- отправить короткое сообщение, находясь не в самом низу переписки;
|
||||||
|
- убедиться, что после отправки экран прокручивается вниз и новое сообщение видно сразу;
|
||||||
|
- проверить то же поведение после прихода подтверждения отправки/перерисовки списка.
|
||||||
|
- открыть существующий чат с непрочитанными сообщениями и убедиться, что видна линия `Новые сообщения` и сообщения ниже неё;
|
||||||
|
- открыть существующий чат без непрочитанных сообщений и убедиться, что открыт конец переписки, а не начало.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- клавиатура не конфликтует по высоте с нижним тулбаром;
|
||||||
|
- при наборе доступно больше вертикального места;
|
||||||
|
- собственное только что отправленное сообщение сразу попадает в видимую область.
|
||||||
|
- при открытии чата пользователь сразу попадает в актуальную часть переписки.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
`pending`
|
||||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
private static volatile SqliteDbController instance;
|
||||||
private static final int LATEST_SCHEMA_VERSION = 8;
|
private static final int LATEST_SCHEMA_VERSION = 9;
|
||||||
|
|
||||||
private final String jdbcUrl;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -91,6 +91,7 @@ public final class SqliteDbController {
|
|||||||
case 6 -> migrateToV6();
|
case 6 -> migrateToV6();
|
||||||
case 7 -> migrateToV7();
|
case 7 -> migrateToV7();
|
||||||
case 8 -> migrateToV8();
|
case 8 -> migrateToV8();
|
||||||
|
case 9 -> migrateToV9();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -269,6 +270,25 @@ public final class SqliteDbController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void migrateToV9() {
|
||||||
|
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||||
|
Statement st = c.createStatement()) {
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
ensureSyncServersTable(st);
|
||||||
|
setSchemaVersion(c, 9);
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
throw new RuntimeException("DB migration to v9 failed", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new RuntimeException("DB migration to v9 failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||||
@ -468,6 +488,20 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSyncServersTable(Statement st) throws SQLException {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_servers (
|
||||||
|
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
|
||||||
|
server_address TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_servers_updated
|
||||||
|
ON sync_servers (updated_at_ms);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import shine.db.SqliteDbController;
|
|||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
|
||||||
import java.sql.*;
|
import java.sql.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DAO для таблицы blocks (новый формат).
|
* DAO для таблицы blocks (новый формат).
|
||||||
@ -191,6 +193,53 @@ public final class BlocksDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
login,
|
||||||
|
bch_name,
|
||||||
|
block_number,
|
||||||
|
msg_type,
|
||||||
|
msg_sub_type,
|
||||||
|
block_bytes,
|
||||||
|
to_login,
|
||||||
|
to_bch_name,
|
||||||
|
to_block_number,
|
||||||
|
to_block_hash,
|
||||||
|
block_hash,
|
||||||
|
block_signature,
|
||||||
|
edited_by_block_number,
|
||||||
|
line_code,
|
||||||
|
prev_line_number,
|
||||||
|
prev_line_hash,
|
||||||
|
this_line_number
|
||||||
|
FROM blocks
|
||||||
|
WHERE bch_name = ?
|
||||||
|
AND block_number >= ?
|
||||||
|
AND block_number <= ?
|
||||||
|
ORDER BY block_number ASC
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<BlockEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, bchName);
|
||||||
|
ps.setInt(2, fromBlockNumberInclusive);
|
||||||
|
ps.setInt(3, toBlockNumberInclusive);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------- INTERNAL --------------------
|
// -------------------- INTERNAL --------------------
|
||||||
|
|
||||||
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
|||||||
@ -0,0 +1,104 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import shine.db.SqliteDbController;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO локальной таблицы серверов-партнёров для будущей межсерверной синхронизации.
|
||||||
|
*/
|
||||||
|
public final class SyncServersDAO {
|
||||||
|
|
||||||
|
private static volatile SyncServersDAO instance;
|
||||||
|
private final SqliteDbController db = SqliteDbController.getInstance();
|
||||||
|
|
||||||
|
private SyncServersDAO() {}
|
||||||
|
|
||||||
|
public static SyncServersDAO getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (SyncServersDAO.class) {
|
||||||
|
if (instance == null) instance = new SyncServersDAO();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SyncServerEntry> listAll() throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
return listAll(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SyncServerEntry> listAll(Connection c) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT login, server_address, updated_at_ms
|
||||||
|
FROM sync_servers
|
||||||
|
ORDER BY login
|
||||||
|
""";
|
||||||
|
List<SyncServerEntry> result = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql);
|
||||||
|
ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
result.add(mapRow(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью заменяет список партнёров актуальным снимком из Solana PDA.
|
||||||
|
*/
|
||||||
|
public void replaceAll(List<SyncServerEntry> entries) throws SQLException {
|
||||||
|
try (Connection c = db.getConnection()) {
|
||||||
|
replaceAll(c, entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void replaceAll(Connection c, List<SyncServerEntry> entries) throws SQLException {
|
||||||
|
boolean oldAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try (Statement st = c.createStatement()) {
|
||||||
|
st.executeUpdate("DELETE FROM sync_servers");
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO sync_servers (
|
||||||
|
login, server_address, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
for (SyncServerEntry entry : entries) {
|
||||||
|
ps.setString(1, entry.getLogin());
|
||||||
|
ps.setString(2, safe(entry.getServerAddress()));
|
||||||
|
ps.setLong(3, entry.getUpdatedAtMs());
|
||||||
|
ps.addBatch();
|
||||||
|
}
|
||||||
|
ps.executeBatch();
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { c.rollback(); } catch (Exception ignored) {}
|
||||||
|
if (e instanceof SQLException sqlEx) throw sqlEx;
|
||||||
|
throw new SQLException("Не удалось обновить таблицу sync_servers", e);
|
||||||
|
} finally {
|
||||||
|
try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SyncServerEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
|
SyncServerEntry entry = new SyncServerEntry();
|
||||||
|
entry.setLogin(rs.getString("login"));
|
||||||
|
entry.setServerAddress(rs.getString("server_address"));
|
||||||
|
entry.setUpdatedAtMs(rs.getLong("updated_at_ms"));
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package shine.db.entities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись о сервере-партнёре, с которым текущий сервер должен синхронизироваться.
|
||||||
|
*/
|
||||||
|
public class SyncServerEntry {
|
||||||
|
|
||||||
|
private String login;
|
||||||
|
private String serverAddress;
|
||||||
|
private long updatedAtMs;
|
||||||
|
|
||||||
|
public SyncServerEntry() {}
|
||||||
|
|
||||||
|
public SyncServerEntry(String login, String serverAddress, long updatedAtMs) {
|
||||||
|
this.login = login;
|
||||||
|
this.serverAddress = serverAddress;
|
||||||
|
this.updatedAtMs = updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogin() {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogin(String login) {
|
||||||
|
this.login = login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServerAddress() {
|
||||||
|
return serverAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServerAddress(String serverAddress) {
|
||||||
|
this.serverAddress = serverAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUpdatedAtMs() {
|
||||||
|
return updatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAtMs(long updatedAtMs) {
|
||||||
|
this.updatedAtMs = updatedAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -82,7 +82,34 @@ public final class SolanaUserPdaImportService {
|
|||||||
return SessionTypeCheckResult.noRecord();
|
return SessionTypeCheckResult.noRecord();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение server PDA по логину сервера. Используется сервером при старте,
|
||||||
|
* чтобы получить актуальный server_address и список sync_servers.
|
||||||
|
*/
|
||||||
|
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
||||||
|
String login = normalizeLogin(loginRaw);
|
||||||
|
if (login == null) return null;
|
||||||
|
|
||||||
|
byte[] raw = fetchRawUserPda(login);
|
||||||
|
if (raw == null) return null;
|
||||||
|
|
||||||
|
ParsedServerProfile parsed = parseServerProfile(raw);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
if (!parsed.login.equalsIgnoreCase(login)) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
|
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
|
||||||
|
byte[] raw = fetchRawUserPda(login);
|
||||||
|
if (raw == null) return null;
|
||||||
|
ParsedSolanaUser parsed = parseUserPda(raw);
|
||||||
|
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] fetchRawUserPda(String login) throws Exception {
|
||||||
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
|
String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8));
|
||||||
String lenB58 = toBase58(new byte[]{(byte) login.length()});
|
String lenB58 = toBase58(new byte[]{(byte) login.length()});
|
||||||
|
|
||||||
@ -128,11 +155,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
if (!dataNode.isArray() || dataNode.size() < 1) continue;
|
if (!dataNode.isArray() || dataNode.size() < 1) continue;
|
||||||
String b64 = dataNode.get(0).asText("");
|
String b64 = dataNode.get(0).asText("");
|
||||||
if (b64.isBlank()) continue;
|
if (b64.isBlank()) continue;
|
||||||
byte[] raw = Base64.getDecoder().decode(b64);
|
return Base64.getDecoder().decode(b64);
|
||||||
ParsedSolanaUser parsed = parseUserPda(raw);
|
|
||||||
if (parsed != null && parsed.login.equalsIgnoreCase(login)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -258,6 +281,105 @@ public final class SolanaUserPdaImportService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||||
|
if (raw == null || raw.length < 128) return null;
|
||||||
|
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||||
|
|
||||||
|
int recordLen = u16le(raw, 7);
|
||||||
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
|
int c = 9;
|
||||||
|
c += 8; // created_at_ms
|
||||||
|
c += 8; // updated_at_ms
|
||||||
|
c += 4; // record_number
|
||||||
|
c += 32; // prev_record_hash
|
||||||
|
|
||||||
|
int loginLen = u8(raw, c++);
|
||||||
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
|
c += loginLen;
|
||||||
|
|
||||||
|
int blocksCount = u8(raw, c++);
|
||||||
|
boolean isServer = false;
|
||||||
|
String serverAddress = "";
|
||||||
|
List<String> syncServers = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
int blockType = u8(raw, c++);
|
||||||
|
int blockVer = u8(raw, c++);
|
||||||
|
if (blockVer != 0) return null;
|
||||||
|
|
||||||
|
if (blockType == 0 || blockType == 1 || blockType == 2) {
|
||||||
|
c += 32;
|
||||||
|
} else if (blockType == 3) {
|
||||||
|
int count = u8(raw, c++);
|
||||||
|
for (int j = 0; j < count; j++) {
|
||||||
|
c += 1; // blockchain_type
|
||||||
|
int bchLen = u8(raw, c++);
|
||||||
|
c += bchLen;
|
||||||
|
c += 32; // blockchain pubkey
|
||||||
|
c += 8; // paid_limit_bytes
|
||||||
|
c += 8; // used_bytes
|
||||||
|
c += 4; // last_block_number
|
||||||
|
c += 32; // last_block_hash
|
||||||
|
c += 64; // last_block_signature
|
||||||
|
int arweavePresent = u8(raw, c++);
|
||||||
|
if (arweavePresent == 1) {
|
||||||
|
int arLen = u8(raw, c++);
|
||||||
|
c += arLen;
|
||||||
|
} else if (arweavePresent != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (blockType == 30) {
|
||||||
|
int isServerValue = u8(raw, c++);
|
||||||
|
if (isServerValue == 1) {
|
||||||
|
isServer = true;
|
||||||
|
c += 1; // address_format_type
|
||||||
|
c += 1; // address_format_version
|
||||||
|
int addrLen = u8(raw, c++);
|
||||||
|
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
||||||
|
c += addrLen;
|
||||||
|
int syncCount = u8(raw, c++);
|
||||||
|
for (int j = 0; j < syncCount; j++) {
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
|
c += n;
|
||||||
|
syncServers.add(normalizeLogin(syncLogin));
|
||||||
|
}
|
||||||
|
} else if (isServerValue != 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (blockType == 40) {
|
||||||
|
int accessCount = u8(raw, c++);
|
||||||
|
for (int j = 0; j < accessCount; j++) {
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
}
|
||||||
|
} else if (blockType == 50) {
|
||||||
|
int sessionsMode = u8(raw, c++);
|
||||||
|
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||||
|
int sessionsCount = u8(raw, c++);
|
||||||
|
if (sessionsCount > 64) return null;
|
||||||
|
for (int j = 0; j < sessionsCount; j++) {
|
||||||
|
c += 1; // session_type
|
||||||
|
c += 1; // session_version
|
||||||
|
int n = u8(raw, c++);
|
||||||
|
c += n;
|
||||||
|
c += 32;
|
||||||
|
}
|
||||||
|
} else if (blockType == 70) {
|
||||||
|
c += 1;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c > recordLen) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
|
||||||
|
}
|
||||||
|
|
||||||
private static String normalizeLogin(String login) {
|
private static String normalizeLogin(String login) {
|
||||||
if (login == null) return null;
|
if (login == null) return null;
|
||||||
String s = login.trim();
|
String s = login.trim();
|
||||||
@ -350,4 +472,11 @@ public final class SolanaUserPdaImportService {
|
|||||||
return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName);
|
return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ParsedServerProfile(
|
||||||
|
String login,
|
||||||
|
boolean isServer,
|
||||||
|
String serverAddress,
|
||||||
|
List<String> syncServers
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
|||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import server.sync.AddBlockSyncService;
|
||||||
import shine.db.channels.ChannelNameRules;
|
import shine.db.channels.ChannelNameRules;
|
||||||
import shine.db.dao.BlockchainStateDAO;
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
@ -55,6 +56,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
|
||||||
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance();
|
||||||
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance();
|
||||||
|
private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService();
|
||||||
|
|
||||||
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO);
|
||||||
|
|
||||||
@ -484,6 +486,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||||||
login, blockchainName, block.blockNumber, newHashHex);
|
login, blockchainName, block.blockNumber, newHashHex);
|
||||||
|
|
||||||
|
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||||
|
|
||||||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,348 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.SyncServersDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers.
|
||||||
|
*/
|
||||||
|
public final class AddBlockSyncService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.build();
|
||||||
|
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
|
||||||
|
1,
|
||||||
|
Math.max(2, Runtime.getRuntime().availableProcessors()),
|
||||||
|
60L,
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new LinkedBlockingQueue<>(10_000),
|
||||||
|
new ThreadFactory() {
|
||||||
|
private final AtomicLong n = new AtomicLong(1);
|
||||||
|
@Override
|
||||||
|
public Thread newThread(Runnable r) {
|
||||||
|
Thread t = new Thread(r, "sync-addblock-" + n.getAndIncrement());
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new ThreadPoolExecutor.DiscardPolicy()
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
|
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
|
||||||
|
|
||||||
|
public void replicateAsync(String blockchainName, int blockNumber) {
|
||||||
|
EXECUTOR.execute(() -> {
|
||||||
|
try {
|
||||||
|
replicate(blockchainName, blockNumber);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("AddBlock sync failed unexpectedly (blockchainName={}, blockNumber={})",
|
||||||
|
blockchainName, blockNumber, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replicate(String blockchainName, int blockNumber) throws Exception {
|
||||||
|
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
|
||||||
|
if (ownerLogin == null) {
|
||||||
|
log.warn("AddBlock sync skipped: cannot derive owner login from blockchainName={}", blockchainName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SyncServerEntry> partners = syncServersDAO.listAll();
|
||||||
|
if (partners.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockEntry currentBlock = blocksDAO.getByNumber(blockchainName, blockNumber);
|
||||||
|
if (currentBlock == null || currentBlock.getBlockBytes() == null) {
|
||||||
|
log.warn("AddBlock sync skipped: block not found in DB (blockchainName={}, blockNumber={})",
|
||||||
|
blockchainName, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (SyncServerEntry partner : partners) {
|
||||||
|
if (partner == null) continue;
|
||||||
|
String partnerLogin = normalize(partner.getLogin());
|
||||||
|
if (partnerLogin == null) continue;
|
||||||
|
if (partnerLogin.equals(ownerLogin)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
replicateToPartner(partner, blockchainName, blockNumber, currentBlock);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("AddBlock sync aborted for partner login={} blockchainName={} blockNumber={} reason={}",
|
||||||
|
partnerLogin, blockchainName, blockNumber, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception {
|
||||||
|
String wsUrl = buildWsUrl(partner.getServerAddress());
|
||||||
|
if (wsUrl == null) {
|
||||||
|
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
|
||||||
|
partner.getLogin(), partner.getServerAddress());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock);
|
||||||
|
if (firstTry.ok()) {
|
||||||
|
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
|
||||||
|
partner.getLogin(), blockchainName, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstTry.needsBackfill()) {
|
||||||
|
log.warn("AddBlock sync failed without backfill: partner={} blockchainName={} blockNumber={} code={}",
|
||||||
|
partner.getLogin(), blockchainName, blockNumber, firstTry.code());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int remoteLast = firstTry.serverLastGlobalNumber();
|
||||||
|
int fromBlockNumber = remoteLast + 1;
|
||||||
|
if (fromBlockNumber > blockNumber) {
|
||||||
|
log.warn("AddBlock sync inconsistent backfill window: partner={} blockchainName={} remoteLast={} target={}",
|
||||||
|
partner.getLogin(), blockchainName, remoteLast, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BlockEntry> missingBlocks = blocksDAO.listRangeByNumber(blockchainName, fromBlockNumber, blockNumber);
|
||||||
|
if (missingBlocks.isEmpty()) {
|
||||||
|
log.warn("AddBlock sync backfill failed: local range empty partner={} blockchainName={} from={} to={}",
|
||||||
|
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (BlockEntry blockEntry : missingBlocks) {
|
||||||
|
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry);
|
||||||
|
if (!backfillResult.ok()) {
|
||||||
|
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
|
||||||
|
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("AddBlock sync backfill ok: partner={} blockchainName={} from={} to={}",
|
||||||
|
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
|
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry);
|
||||||
|
int status = response.path("status").asInt(500);
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
return AddBlockPushResult.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = textOrEmpty(response, "code");
|
||||||
|
if (code.isBlank()) {
|
||||||
|
code = textOrEmpty(response, "error");
|
||||||
|
}
|
||||||
|
JsonNode payload = response.path("payload");
|
||||||
|
int serverLastGlobalNumber = payload.path("serverLastGlobalNumber").asInt(Integer.MIN_VALUE);
|
||||||
|
String serverLastGlobalHash = payload.path("serverLastGlobalHash").asText("");
|
||||||
|
|
||||||
|
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
|
CompletableFuture<String> responseFuture = new CompletableFuture<>();
|
||||||
|
CountDownLatch openLatch = new CountDownLatch(1);
|
||||||
|
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
|
||||||
|
|
||||||
|
WebSocket webSocket = HTTP.newWebSocketBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(6))
|
||||||
|
.buildAsync(URI.create(wsUrl), listener)
|
||||||
|
.get(8, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
if (!openLatch.await(8, TimeUnit.SECONDS)) {
|
||||||
|
tryAbort(webSocket);
|
||||||
|
throw new TimeoutException("WS open timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
String requestId = "sync-" + UUID.randomUUID();
|
||||||
|
String json = buildAddBlockJson(requestId, blockchainName, blockEntry);
|
||||||
|
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
|
||||||
|
tryAbort(webSocket);
|
||||||
|
return MAPPER.readTree(responseJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
|
||||||
|
String prevHashHex = blockEntry.getBlockNumber() <= 0
|
||||||
|
? ""
|
||||||
|
: toHex(extractPrevHash32(blockEntry.getBlockBytes()));
|
||||||
|
String blockBytesB64 = Base64Ws.encode(blockEntry.getBlockBytes());
|
||||||
|
|
||||||
|
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
|
||||||
|
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
|
||||||
|
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
|
||||||
|
String safeRequestId = MAPPER.writeValueAsString(requestId);
|
||||||
|
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"op":"AddBlock",
|
||||||
|
"requestId":%s,
|
||||||
|
"payload":{
|
||||||
|
"blockchainName":%s,
|
||||||
|
"blockNumber":%d,
|
||||||
|
"prevBlockHash":%s,
|
||||||
|
"blockBytesB64":%s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] extractPrevHash32(byte[] blockBytes) {
|
||||||
|
if (blockBytes == null || blockBytes.length < 44) {
|
||||||
|
return new byte[32];
|
||||||
|
}
|
||||||
|
byte[] out = new byte[32];
|
||||||
|
System.arraycopy(blockBytes, 12, out, 0, 32);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOrEmpty(JsonNode node, String field) {
|
||||||
|
return node == null ? "" : node.path(field).asText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildWsUrl(String serverAddressRaw) {
|
||||||
|
String host = normalizeHostLike(serverAddressRaw);
|
||||||
|
if (host == null) return null;
|
||||||
|
return "wss://" + host + "/ws";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeHostLike(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String raw = value.trim();
|
||||||
|
if (raw.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
|
||||||
|
URI uri = URI.create(withScheme);
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (host == null || host.isBlank()) return null;
|
||||||
|
return host.trim().toLowerCase(Locale.ROOT);
|
||||||
|
} catch (Exception e) {
|
||||||
|
String cleaned = raw
|
||||||
|
.replaceFirst("^[a-zA-Z]+://", "")
|
||||||
|
.replaceFirst("/.*$", "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(Locale.ROOT);
|
||||||
|
return cleaned.isEmpty() ? null : cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toHex(byte[] bytes) {
|
||||||
|
if (bytes == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) {
|
||||||
|
sb.append(Character.forDigit((b >>> 4) & 0xF, 16));
|
||||||
|
sb.append(Character.forDigit(b & 0xF, 16));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tryAbort(WebSocket webSocket) {
|
||||||
|
try {
|
||||||
|
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
webSocket.abort();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AddBlockPushResult(
|
||||||
|
boolean ok,
|
||||||
|
int status,
|
||||||
|
String code,
|
||||||
|
int serverLastGlobalNumber,
|
||||||
|
String serverLastGlobalHash
|
||||||
|
) {
|
||||||
|
static AddBlockPushResult success() {
|
||||||
|
return new AddBlockPushResult(true, 200, "", Integer.MIN_VALUE, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean needsBackfill() {
|
||||||
|
return !ok && ("bad_prev_hash".equalsIgnoreCase(code) || "bad_block_number".equalsIgnoreCase(code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SyncWsListener implements WebSocket.Listener {
|
||||||
|
private final CompletableFuture<String> responseFuture;
|
||||||
|
private final CountDownLatch openLatch;
|
||||||
|
private final StringBuilder textBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
|
||||||
|
this.responseFuture = responseFuture;
|
||||||
|
this.openLatch = openLatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocket webSocket) {
|
||||||
|
openLatch.countDown();
|
||||||
|
webSocket.request(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||||
|
textBuffer.append(data);
|
||||||
|
if (last && !responseFuture.isDone()) {
|
||||||
|
responseFuture.complete(textBuffer.toString());
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
|
||||||
|
webSocket.request(1);
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
|
if (!responseFuture.isDone()) {
|
||||||
|
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
|
if (!responseFuture.isDone()) {
|
||||||
|
responseFuture.completeExceptionally(error);
|
||||||
|
}
|
||||||
|
openLatch.countDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package server.sync;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService;
|
||||||
|
import shine.db.dao.SyncServersDAO;
|
||||||
|
import shine.db.entities.SyncServerEntry;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* При старте сервера читает server PDA текущего сервера, затем PDA партнёров из
|
||||||
|
* sync_servers и сохраняет их логины/адреса в локальную таблицу sync_servers.
|
||||||
|
*/
|
||||||
|
public final class SyncServersBootstrapService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SyncServersBootstrapService.class);
|
||||||
|
private static final String CONFIG_KEY = "server.SHiNE.login";
|
||||||
|
|
||||||
|
private SyncServersBootstrapService() {}
|
||||||
|
|
||||||
|
public static void refreshFromSolanaOrLog() {
|
||||||
|
String serverLogin = normalize(AppConfig.getInstance().getParam(CONFIG_KEY));
|
||||||
|
if (serverLogin == null) {
|
||||||
|
log.warn("Sync bootstrap skipped: параметр {} не задан", CONFIG_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SolanaUserPdaImportService.ParsedServerProfile own =
|
||||||
|
SolanaUserPdaImportService.fetchServerProfileByLogin(serverLogin);
|
||||||
|
if (own == null) {
|
||||||
|
log.warn("Sync bootstrap skipped: server PDA не найдена для login={}", serverLogin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!own.isServer()) {
|
||||||
|
log.warn("Sync bootstrap skipped: PDA login={} не помечена как server", serverLogin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SyncServerEntry> entries = new ArrayList<>();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (String partnerLogin : own.syncServers()) {
|
||||||
|
String normalizedPartnerLogin = normalize(partnerLogin);
|
||||||
|
if (normalizedPartnerLogin == null) continue;
|
||||||
|
|
||||||
|
SolanaUserPdaImportService.ParsedServerProfile partner =
|
||||||
|
SolanaUserPdaImportService.fetchServerProfileByLogin(normalizedPartnerLogin);
|
||||||
|
if (partner == null) {
|
||||||
|
log.warn("Sync bootstrap: partner PDA не найдена для login={}", normalizedPartnerLogin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!partner.isServer()) {
|
||||||
|
log.warn("Sync bootstrap: partner login={} не является server PDA", normalizedPartnerLogin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String serverAddress = safe(partner.serverAddress());
|
||||||
|
if (serverAddress.isBlank()) {
|
||||||
|
log.warn("Sync bootstrap: у partner login={} пустой server_address", normalizedPartnerLogin);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.add(new SyncServerEntry(normalizedPartnerLogin, serverAddress, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncServersDAO.getInstance().replaceAll(entries);
|
||||||
|
log.info("Sync bootstrap: сохранено {} серверов синхронизации для login={}", entries.size(), serverLogin);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Sync bootstrap failed while loading server PDA and sync_servers from Solana", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String s = value.trim().toLowerCase();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import server.debug.DebugApiConfigurator;
|
import server.debug.DebugApiConfigurator;
|
||||||
|
import server.sync.SyncServersBootstrapService;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@ -50,6 +51,11 @@ public final class WsServer {
|
|||||||
log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port);
|
log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1.1) Загрузка списка серверов синхронизации из Solana PDA
|
||||||
|
// ============================================================
|
||||||
|
SyncServersBootstrapService.refreshFromSolanaOrLog();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 2) Запуск Jetty WS
|
// 2) Запуск Jetty WS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
server.1port=7070
|
server.1port=7070
|
||||||
db.path=data/shine.sqlite
|
db.path=data/shine.sqlite
|
||||||
|
server.SHiNE.login=shineupme
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Server public info
|
# Server public info
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.261
|
client.version=1.2.265
|
||||||
server.version=1.2.246
|
server.version=1.2.248
|
||||||
|
|||||||
@ -745,6 +745,19 @@ function renderApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshToolbarOnly() {
|
||||||
|
const route = getRoute();
|
||||||
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
|
const page = routes[pageId] || routes['start-view'];
|
||||||
|
const showAppChrome = page.pageMeta?.showAppChrome !== false;
|
||||||
|
|
||||||
|
toolbarEl.innerHTML = '';
|
||||||
|
if (showAppChrome) {
|
||||||
|
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
|
||||||
|
}
|
||||||
|
refreshConnectionUi();
|
||||||
|
}
|
||||||
|
|
||||||
async function tryAutoLogin() {
|
async function tryAutoLogin() {
|
||||||
if (!state.session.login || !state.session.sessionId) return;
|
if (!state.session.login || !state.session.sessionId) return;
|
||||||
try {
|
try {
|
||||||
@ -987,7 +1000,18 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pageId = getRoute().pageId || '';
|
const pageId = getRoute().pageId || '';
|
||||||
if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) {
|
if (pageId === 'chat-view') {
|
||||||
|
window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', {
|
||||||
|
detail: {
|
||||||
|
chatId,
|
||||||
|
messageType,
|
||||||
|
messageKey,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (shouldRefreshToolbarUnread) {
|
||||||
|
refreshToolbarOnly();
|
||||||
|
}
|
||||||
|
} else if (pageId === 'messages-list' || shouldRefreshToolbarUnread) {
|
||||||
renderApp();
|
renderApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
} 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 { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||||
import { showToast } from '../services/channels-ux.js';
|
import { showToast } from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
@ -60,6 +60,7 @@ function openMessageActionsMenu({
|
|||||||
anchorX = 0,
|
anchorX = 0,
|
||||||
anchorY = 0,
|
anchorY = 0,
|
||||||
messageText = '',
|
messageText = '',
|
||||||
|
showReadAloud = true,
|
||||||
canEdit = false,
|
canEdit = false,
|
||||||
canDelete = false,
|
canDelete = false,
|
||||||
onReadAloud,
|
onReadAloud,
|
||||||
@ -74,7 +75,7 @@ function openMessageActionsMenu({
|
|||||||
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
|
||||||
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
|
||||||
<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-copy">Скопировать как текст</button>
|
||||||
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>
|
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
|
||||||
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
|
||||||
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
|
||||||
</div>
|
</div>
|
||||||
@ -263,8 +264,13 @@ function resolveMessageEditedTimeMs(msg) {
|
|||||||
|
|
||||||
function scrollToLatestMessage(list) {
|
function scrollToLatestMessage(list) {
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
|
||||||
|
const lastBubble = list.lastElementChild;
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
list.scrollTop = list.scrollHeight;
|
if (lastBubble?.scrollIntoView) {
|
||||||
|
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' });
|
||||||
|
}
|
||||||
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
window.requestAnimationFrame(apply);
|
window.requestAnimationFrame(apply);
|
||||||
@ -275,7 +281,27 @@ function scrollToLatestMessage(list) {
|
|||||||
window.setTimeout(apply, 260);
|
window.setTimeout(apply, 260);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLog(list, chatId, { onOpenActions } = {}) {
|
function scrollToUnreadSeparator(list) {
|
||||||
|
if (!list) return false;
|
||||||
|
const separator = list.querySelector('.chat-unread-separator');
|
||||||
|
if (!separator) return false;
|
||||||
|
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
|
||||||
|
const apply = () => {
|
||||||
|
if (separator?.scrollIntoView) {
|
||||||
|
separator.scrollIntoView({ block: 'start', inline: 'nearest' });
|
||||||
|
}
|
||||||
|
const bottomSlack = 72;
|
||||||
|
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
window.requestAnimationFrame(apply);
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
|
||||||
|
window.setTimeout(apply, 60);
|
||||||
|
window.setTimeout(apply, 160);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const messages = getChatMessages(chatId);
|
const messages = getChatMessages(chatId);
|
||||||
let unreadSeparatorInserted = false;
|
let unreadSeparatorInserted = false;
|
||||||
@ -330,9 +356,44 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
|
|||||||
});
|
});
|
||||||
list.append(bubble);
|
list.append(bubble);
|
||||||
});
|
});
|
||||||
|
if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) {
|
||||||
scrollToLatestMessage(list);
|
scrollToLatestMessage(list);
|
||||||
|
} else if (scrollMode === 'latest') {
|
||||||
|
scrollToLatestMessage(list);
|
||||||
|
}
|
||||||
|
if (markAsRead) {
|
||||||
markChatRead(chatId);
|
markChatRead(chatId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveComposerSelection(input, callback) {
|
||||||
|
if (!input || typeof callback !== 'function') {
|
||||||
|
if (typeof callback === 'function') callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasFocused = document.activeElement === input;
|
||||||
|
const start = Number(input.selectionStart ?? input.value.length);
|
||||||
|
const end = Number(input.selectionEnd ?? input.value.length);
|
||||||
|
|
||||||
|
callback();
|
||||||
|
|
||||||
|
if (!wasFocused) return;
|
||||||
|
try {
|
||||||
|
input.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
input.setSelectionRange(start, end);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChatKeyboardOpen(isOpen) {
|
||||||
|
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const routeChatId = route.params.chatId || 'u1';
|
const routeChatId = route.params.chatId || 'u1';
|
||||||
@ -345,7 +406,10 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-chat-screen';
|
screen.className = 'stack dm-screen dm-chat-screen';
|
||||||
|
const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings);
|
||||||
|
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
|
||||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||||
|
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
|
||||||
|
|
||||||
const handleReadAloud = async (msg) => {
|
const handleReadAloud = async (msg) => {
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
@ -431,7 +495,7 @@ export function render({ navigate, route }) {
|
|||||||
</div>
|
</div>
|
||||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
|
||||||
<div class="dm-actions-col">
|
<div class="dm-actions-col">
|
||||||
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
|
${isSpeechToTextReady ? '<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>' : ''}
|
||||||
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -441,6 +505,17 @@ export function render({ navigate, route }) {
|
|||||||
const editBannerText = form.querySelector('#chat-edit-banner-text');
|
const editBannerText = form.querySelector('#chat-edit-banner-text');
|
||||||
const editCancelBtn = form.querySelector('#chat-edit-cancel');
|
const editCancelBtn = form.querySelector('#chat-edit-cancel');
|
||||||
let activeEdit = null;
|
let activeEdit = null;
|
||||||
|
let inputFocused = false;
|
||||||
|
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||||
|
|
||||||
|
const syncKeyboardUi = () => {
|
||||||
|
const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
|
||||||
|
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
|
||||||
|
setChatKeyboardOpen(inputFocused && viewportShrunk);
|
||||||
|
if (inputFocused) {
|
||||||
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const syncEditBanner = () => {
|
const syncEditBanner = () => {
|
||||||
if (!editBanner || !editBannerText) return;
|
if (!editBanner || !editBannerText) return;
|
||||||
@ -544,6 +619,7 @@ export function render({ navigate, route }) {
|
|||||||
const editing = activeEdit;
|
const editing = activeEdit;
|
||||||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||||||
|
scrollToLatestMessage(log);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
@ -621,6 +697,7 @@ export function render({ navigate, route }) {
|
|||||||
anchorX: Number(event?.clientX || 0),
|
anchorX: Number(event?.clientX || 0),
|
||||||
anchorY: Number(event?.clientY || 0),
|
anchorY: Number(event?.clientY || 0),
|
||||||
messageText: msg?.text || '',
|
messageText: msg?.text || '',
|
||||||
|
showReadAloud: isTextToSpeechReady,
|
||||||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||||
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||||||
onReadAloud: async () => handleReadAloud(msg),
|
onReadAloud: async () => handleReadAloud(msg),
|
||||||
@ -648,8 +725,14 @@ export function render({ navigate, route }) {
|
|||||||
autoResizeComposer(input);
|
autoResizeComposer(input);
|
||||||
input?.addEventListener('input', () => autoResizeComposer(input));
|
input?.addEventListener('input', () => autoResizeComposer(input));
|
||||||
input?.addEventListener('focus', () => {
|
input?.addEventListener('focus', () => {
|
||||||
|
inputFocused = true;
|
||||||
|
syncKeyboardUi();
|
||||||
scrollToLatestMessage(log);
|
scrollToLatestMessage(log);
|
||||||
});
|
});
|
||||||
|
input?.addEventListener('blur', () => {
|
||||||
|
inputFocused = false;
|
||||||
|
setChatKeyboardOpen(false);
|
||||||
|
});
|
||||||
input?.addEventListener('keydown', async (event) => {
|
input?.addEventListener('keydown', async (event) => {
|
||||||
if (event.key !== 'Enter') return;
|
if (event.key !== 'Enter') return;
|
||||||
if (event.ctrlKey) {
|
if (event.ctrlKey) {
|
||||||
@ -699,12 +782,42 @@ export function render({ navigate, route }) {
|
|||||||
await sendTextMessage(text);
|
await sendTextMessage(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleIncomingChatRefresh = async (event) => {
|
||||||
|
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
|
||||||
|
if (updatedChatId !== chatId) return;
|
||||||
|
preserveComposerSelection(input, () => {
|
||||||
|
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
|
||||||
|
});
|
||||||
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
|
void sendReadReceiptsForVisible(chatId);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
|
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
|
||||||
|
window.addEventListener('resize', syncKeyboardUi);
|
||||||
|
|
||||||
wrap.append(log, form);
|
wrap.append(log, form);
|
||||||
screen.append(wrap);
|
screen.append(wrap);
|
||||||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
renderLog(log, chatId, {
|
||||||
|
onOpenActions: handleOpenActions,
|
||||||
|
markAsRead: false,
|
||||||
|
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||||
|
});
|
||||||
|
if (hasUnreadIncoming) {
|
||||||
|
window.requestAnimationFrame(() => scrollToUnreadSeparator(log));
|
||||||
|
window.setTimeout(() => scrollToUnreadSeparator(log), 180);
|
||||||
|
} else {
|
||||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||||||
|
}
|
||||||
|
window.setTimeout(() => markChatRead(chatId), 220);
|
||||||
void sendReadReceiptsForVisible(chatId);
|
void sendReadReceiptsForVisible(chatId);
|
||||||
|
screen.cleanup = () => {
|
||||||
|
setChatKeyboardOpen(false);
|
||||||
|
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
|
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
|
||||||
|
window.removeEventListener('resize', syncKeyboardUi);
|
||||||
|
};
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,16 +35,20 @@ export function render({ navigate }) {
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="key-card stack">
|
<div class="key-card stack">
|
||||||
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
|
<label class="checkbox-row"><span class="field-label">Root Key</span></label>
|
||||||
|
<p class="meta-muted key-storage-option__description">Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.</p>
|
||||||
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
|
<input class="input" type="text" value="${state.keyStorage.rootKey}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="key-card stack">
|
<div class="key-card stack">
|
||||||
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
|
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
|
||||||
|
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
|
||||||
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
|
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="key-card stack">
|
<div class="key-card stack">
|
||||||
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
|
<label class="checkbox-row"><span class="field-label">Device Key</span></label>
|
||||||
|
<p class="meta-muted key-storage-option__description">Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.</p>
|
||||||
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
|
<input class="input" type="text" value="${state.keyStorage.clientKey}" />
|
||||||
</div>
|
</div>
|
||||||
|
<p class="key-storage-note key-storage-note--strong">Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.</p>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
card.children[0].querySelector('label').prepend(rootToggle);
|
card.children[0].querySelector('label').prepend(rootToggle);
|
||||||
|
|||||||
@ -42,6 +42,22 @@ export function render({ navigate }) {
|
|||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
|
|
||||||
|
const createKeyInfo = (toggle, titleText, descriptionText) => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'key-storage-option stack';
|
||||||
|
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'checkbox-row';
|
||||||
|
row.append(toggle, document.createTextNode(titleText));
|
||||||
|
|
||||||
|
const description = document.createElement('p');
|
||||||
|
description.className = 'meta-muted key-storage-option__description';
|
||||||
|
description.textContent = descriptionText;
|
||||||
|
|
||||||
|
wrap.append(row, description);
|
||||||
|
return wrap;
|
||||||
|
};
|
||||||
|
|
||||||
const rootToggle = document.createElement('input');
|
const rootToggle = document.createElement('input');
|
||||||
rootToggle.type = 'checkbox';
|
rootToggle.type = 'checkbox';
|
||||||
rootToggle.checked = state.keyStorage.saveRoot;
|
rootToggle.checked = state.keyStorage.saveRoot;
|
||||||
@ -55,19 +71,29 @@ export function render({ navigate }) {
|
|||||||
deviceToggle.checked = true;
|
deviceToggle.checked = true;
|
||||||
deviceToggle.disabled = true;
|
deviceToggle.disabled = true;
|
||||||
|
|
||||||
const rootRow = document.createElement('label');
|
const rootRow = createKeyInfo(
|
||||||
rootRow.className = 'checkbox-row';
|
rootToggle,
|
||||||
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
|
'Ключ root',
|
||||||
|
'Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.',
|
||||||
|
);
|
||||||
|
|
||||||
const blockchainRow = document.createElement('label');
|
const blockchainRow = createKeyInfo(
|
||||||
blockchainRow.className = 'checkbox-row';
|
blockchainToggle,
|
||||||
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
|
'Ключ blockchain',
|
||||||
|
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
|
||||||
|
);
|
||||||
|
|
||||||
const deviceRow = document.createElement('label');
|
const deviceRow = createKeyInfo(
|
||||||
deviceRow.className = 'checkbox-row';
|
deviceToggle,
|
||||||
deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)'));
|
'Ключ device (всегда)',
|
||||||
|
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
|
||||||
|
);
|
||||||
|
|
||||||
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status);
|
const simpleNote = document.createElement('p');
|
||||||
|
simpleNote.className = 'key-storage-note key-storage-note--strong';
|
||||||
|
simpleNote.textContent = 'Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.';
|
||||||
|
|
||||||
|
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, simpleNote, status);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
|
|||||||
@ -290,7 +290,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
rootKey: 'Ключ root хранится в зашифрованном виде',
|
rootKey: 'Ключ root хранится в зашифрованном виде',
|
||||||
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
||||||
clientKey: 'Ключ device хранится в зашифрованном виде',
|
clientKey: 'Ключ device хранится в зашифрованном виде',
|
||||||
saveRoot: false,
|
saveRoot: true,
|
||||||
saveBlockchain: true,
|
saveBlockchain: true,
|
||||||
saveDevice: true,
|
saveDevice: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -628,6 +628,25 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key-storage-option {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-storage-option__description {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-storage-note {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-storage-note--strong {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
.key-card {
|
.key-card {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@ -65,6 +65,18 @@ body::before {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||||
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
|
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-keyboard-open .screen-content {
|
||||||
|
bottom: 0;
|
||||||
|
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
body.chat-keyboard-open .toolbar-slot {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-retry-banner {
|
.connection-retry-banner {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user