Author SHA256 Message Date
AidarKC 823a41c027 chore idea vcs mappings cleanup 2026-06-21 13:10:51 +04:00
AidarKC 2a834f1b14 fix ui dm chatid lowercase normalization 2026-06-21 12:27:41 +04:00
AidarKC c8ffb6cf29 Настроить test2 как основной контур деплоя 2026-06-20 23:34:41 +04:00
AidarKC ecc9efd434 UI: скрыть пароль при режиме 12 слов 2026-06-20 21:33:44 +04:00
AidarKC dd35e56029 Добавить временную бесплатную загрузку аватаров в Arweave 2026-06-20 21:29:35 +04:00
AidarKC d0e7998650 UI: обновить экраны входа 2026-06-20 20:15:40 +04:00
AidarKC fec5e49304 UI: FAQ регистрации и режим пароля из 12 слов 2026-06-20 19:05:45 +04:00
AidarKC 3b12e14e71 Docs: добавить идею homeserver команд и обмена файлами 2026-06-20 17:21:47 +04:00
AidarKC 86eaf2139d UI: улучшить личные сообщения и поиск контактов 2026-06-20 17:19:32 +04:00
AidarKC 65fad993ad UI: вернуть старую вкладку Личные и починить аватары в Связях 2026-06-20 16:43:53 +04:00
AidarKC 55e6e477be merge(main): объединить esp-and-wallet и UI Pixel 2026-06-20 12:17:11 +04:00
PixelandClaude Opus 4.8 ba5efcc152 Merge: UI «Связи» (финал) + редизайн «Личные» (чистый прод) в main
- Граф «Связи»: обновлён до финальной версии поверх PR #3 «pixel-связи»
  (орбы 12/13.06, единый PNG-оверлей орбов, мягкий край, вибрация выключена).
- «Личные»: редизайн списка как формы «Связей» — фото-аватары/инициалы,
  золотая галочка подтверждения у имени, значок-цепочка связи с попапом пути
  (Ты → посредники → цель) и переходом в профиль, граница карточки по типу
  связи, шапка «Shine». Данные пока мок-плейсхолдер (реальные relations/чаты —
  отдельная задача с бэкендом).
- Чистый прод: сняты обе демо-лаборатории (граф/ЛС), demo-чат, гость-обвязка
  ЛС и demo-avatars; экран «Личные» под логином.
- Сохранена работа агента в main (DM-ревизии/редактирование, wallet/pairing, esp32).
- VERSION: client 1.2.217 (server 1.2.204 без изменений).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:10:59 +03:00
PixelandClaude Opus 4.8 26253564d5 chore: bump client 1.2.169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:10 +03:00
PixelandClaude Opus 4.8 92791c77a9 прод: убрать ЛС-лабу (demo-чат, гость-доступ, isDemo, demo-avatars); мок→плейсхолдер
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:09 +03:00
PixelandClaude Opus 4.8 465792b2ab прод: убрать граф-лабу (network/lab.js, selftest.js, граф-мок в mock-data)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:09 +03:00
PixelandClaude Opus 4.8 de269fd828 chore: bump client 1.2.168 + .gitignore (.claude, бэкап-ассеты)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:28 +03:00
PixelandClaude Opus 4.8 8c91484f37 nav: вкладка «Личные» (лейбл)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:27 +03:00
PixelandClaude Opus 4.8 6904ac8b7c ЛС: редизайн списка (фото-аватары, галочка/значок связи у имени, попап-цепочка→профиль) + demo-чат и lab-маршрут
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:27 +03:00
PixelandClaude Opus 4.8 aea6bbcb0e ЛС: токены связей + резолвер визуала + семантический мок (connectedVia/login)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:26 +03:00
PixelandClaude Opus 4.8 7ad74942e0 Связи: отключена вибрация в графе
haptic() сделан no-op — на экране «Связи» телефон не вибрирует ни на тапах по узлам,
ни на переходах (раскрытие/погружение/всплытие/пан). Вызовы haptic(...) оставлены, тело пустое.
Версия 1.2.167.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:03:32 +03:00
PixelandClaude Opus 4.8 ac1cc04637 nav: неоновые PNG-иконки вкладок бара + единый вид
- toolbar.js: data-driven иконки (iconImg/glow/hero); все 5 вкладок → <img> неон-PNG;
  «Связи» помечена hero.
- components.css: единый размер (--tab-icon-size 27px), «Связи» крупнее ВИЗУАЛЬНО через
  transform: scale (без сдвига раскладки — иконки на одной линии); active/tap-состояния;
  у «Связи» убран лишний drop-shadow-ореол (светится сама PNG); глобально
  -webkit-tap-highlight-color: transparent (нет синего tap-квадрата нигде).
- assets: icon_lichnye/kanaly/svyazi/uvedomleniya/profil.png. Иконка «Уведомления»
  приведена к прозрачному фону (была без альфы) и обрезана до ~92% заполнения, как у других.
Версия 1.2.166.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:03:04 +03:00
PixelandClaude Opus 4.8 b4480d89cf css: убрать закомментированный блок кластерной ауры (финально)
Решение по ауре финальное — не возвращаем. Удалён закомментированный «ТЕСТ»-блок
box-shadow по категориям; оставлено явное box-shadow:none для сияющих/фокуса.
Версия 1.2.165.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:13:17 +03:00
PixelandClaude Opus 4.8 ff584ba5d1 orb: мягкий край фото + убран фон-градиент категории из-под стекла
- растушёвка края фото радиальной маской (--feather-full 62% / --feather-edge 78%):
  фото сливается со стеклом без жёсткого ободка; параметры для подкрутки.
- убран фон-градиент категории на .node-dot (просвечивал через прозрачный центр
  стекла → читался как цветная «обводка»): селектор поднят до (0,4,0), чтобы
  перебить правила категории. Цвет категории остаётся на линиях.
- кластерная аура (box-shadow по категории) отключена.
Не тронуто: кромка PNG, свечение сияющих/фокуса/common, линии.
Версия 1.2.164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:55:09 +03:00
PixelandClaude Opus 4.8 69f0fdf120 orb: единый PNG-оверлей на все узлы + ретайр вектора
- glass_overlay_faithful.png в assets; орбы = фото (низ, круглая маска 78%) +
  стеклянный PNG (верх, бокс 119% от node-dot, контакт линий от ORB_R).
- PNG-оверлей применён ко ВСЕМ полным орбам (центр + спутники); tier-3 точки без изменений.
- ретайр мёртвого векторного стекла: удалён buildGlassOrb (+orbSeq) и CSS .fg-orb-svg,
  снят остаточный border .node-dot (синее кольцо) у PNG-хостов.
Версия 1.2.163.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:56 +03:00
PixelandClaude Opus 4.8 e3bebff618 anim (13.06): ускорение разлёта узлов — BLOOM_MS 900→550
Дети «выстреливают» из центра почти вдвое быстрее; easing и каскад (stagger)
прежние. Убирает ощущение тяжести/«подтупливания» при смене центра.
Версия 1.2.162.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:36:46 +03:00
PixelandClaude Opus 4.8 f19f7b0ec4 Связи (13.06): орбы по референсу, линии по категории, постоянная вселенная
Орбы:
- материал «хрусталь»: чистое лицо (виньетка 0.5→0.22), диффузный блик окна
  вместо «капли» (+мягкий blur sf), стекло прозрачнее (тело 0.38→0.3),
  полупрозрачная преломляющая кромка (blur + opacity 0.25→0.2).
- размер +11.5% (node-dot 52→58px); единый ORB_R=29 как источник радиуса.
- убран значок * у общих узлов (логика is-common цела).

Линии:
- цвет по категории на ВСЕХ рёбрах; плазма только сияющим.
- общий узел наследует сияние исходного человека (не серый).
- контакт линий ровно на кромке сферы орба (ORB_R), без зазора, все уровни.

Навигация:
- констелляция (паутина 2-3 уровней) — постоянный режим; кнопка «Вселенная»
  убрана; Семья/Друзья/Сияющие остаются фильтрами. Чистка осиротевшего CSS.

Версия 1.2.161.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:31:46 +03:00
PixelandClaude Opus 4.8 0b4374141e Связи (test 12.06): центр-орб крупнее (FOCUS_SCALE 1.78) + шире ореол центра (glowSpread 7)
Рычаг 1: glowSpread центра 4.5→7 (мягче/шире свечение), спутники без изменений. Рычаг 2: FOCUS_SCALE 1.5→1.78 (иерархия). Версия 1.2.160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:53:13 +03:00
PixelandClaude Opus 4.8 652ddc9d88 Связи (test 12.06): SVG-стеклянные орбы аватаров + цвет/свечение линий глубоких связей
Аватары → SVG GlassOrb (фото в стеклянной сфере, блик, rim, свечение). Линии глубоких связей (tier-2/3) — в цвете типа (друзья/семья/...), сияющие связи светятся (голубой ореол + ядро). Версия 1.2.159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:06 +03:00
71 changed files with 3510 additions and 992 deletions
+6
View File
@@ -104,3 +104,9 @@ ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим) # Полные серверные бэкапы (тяжёлые архивы, не коммитим)
server-backup/archive/** server-backup/archive/**
!server-backup/archive/.gitkeep !server-backup/archive/.gitkeep
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
.claude/
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
*.bak.png
shine-UI/assets/navbar_preview.png
Generated
+1 -3
View File
@@ -2,7 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
</component> </component>
</project> </project>
+15 -3
View File
@@ -83,14 +83,26 @@
## Deploy ## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`. - Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`shineup.me`). - Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`).
- Основной test-хост SHiNE: `player@193.8.215.70` (`test2.shineup.me`).
- Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`).
- Базовый путь на сервере для 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`. - Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя.
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`. - Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `test2.shineup.me`.
- Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`.
- Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`.
- Production server deploy: `./gradlew deployServerProduction`.
- Production UI deploy: `./gradlew deployUIProduction`.
- Резервный test deploy на `test.shineup.me`: `./gradlew deployServerTest` и `./gradlew deployUITest`, но пока их не использовать без отдельной причины.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`). - Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя. - Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
- Для продовой настройки тестового Arweave-кошелька JWK-файл нужно хранить только на сервере, например: `/home/player/SHiNE/secrets/test-free-avatar-wallet.json`.
- Для этой временной фичи на проде должны быть заданы параметры `test.freeAvatar.walletJwkPath` и `test.freeAvatar.walletAddress` через серверный override-конфиг/секреты на хосте.
- После изменения продовых значений `test.freeAvatar.*` нужно заново выполнить серверный деплой или перезапуск сервера, чтобы настройки были перечитаны приложением.
- При таких изменениях в git допускается коммитить только документацию и код чтения настроек, но не сам JWK, не содержимое секрета и не реальные приватные ключи.
## Логи звонков (установка соединения) ## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер). - Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
+2
View File
@@ -15,6 +15,8 @@
| `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) | | `AddUser` | `01_User_Registration_API.md` | отключено (`410 / ADD_USER_DISABLED`) |
| `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна |
| `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу |
| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара |
| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave |
| `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии |
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
@@ -0,0 +1,176 @@
# Временное Test API для бесплатной загрузки аватаров в Arweave
> Статус: **временное тестовое решение**.
> Все операции из этого файла начинаются с `Test...`, чтобы это было видно сразу и в коде, и в UI.
## Назначение
Этот временный API даёт пользователю ограниченную бесплатную загрузку маленьких аватаров в Arweave:
- загрузка идёт через **серверный Arweave-кошелёк**;
- лимит на пользователя: по умолчанию `3` загрузки за всё время;
- лимит хранится в SQLite-таблице `test_free_avatar_uploads`;
- если лимит исчерпан, сервер возвращает понятную ошибку;
- загружать можно только маленький итоговый файл аватара, по умолчанию до `128 KB`.
## Настройки сервера
В `application.properties`:
```properties
test.freeAvatar.enabled=true
test.freeAvatar.gateway=https://arweave.net
test.freeAvatar.limitPerUser=3
test.freeAvatar.maxBytes=131072
test.freeAvatar.walletAddress=
test.freeAvatar.walletJwkPath=
```
Пояснения:
- `test.freeAvatar.enabled` - включить или выключить временный API;
- `test.freeAvatar.gateway` - Arweave gateway для `price/tx/wallet`;
- `test.freeAvatar.limitPerUser` - пожизненный бесплатный лимит на пользователя;
- `test.freeAvatar.maxBytes` - максимальный размер итогового файла;
- `test.freeAvatar.walletAddress` - публичный адрес серверного Arweave-кошелька;
- `test.freeAvatar.walletJwkPath` - путь к приватному JWK-файлу серверного кошелька.
Важно:
- приватный JWK хранится вне кода;
- если `walletAddress` указан и не совпадает с адресом, вычисленным из JWK, сервер вернёт ошибку настройки.
## `TestGetFreeAvatarQuota`
Возвращает остаток бесплатных загрузок для текущего авторизованного пользователя.
### Запрос
```json
{
"op": "TestGetFreeAvatarQuota",
"requestId": "req-test-avatar-quota-1",
"payload": {}
}
```
### Успешный ответ
```json
{
"op": "TestGetFreeAvatarQuota",
"requestId": "req-test-avatar-quota-1",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"limit": 3,
"usedCount": 1,
"remainingCount": 2,
"maxBytes": 131072
}
}
```
### Поля ответа
- `enabled` - временный API сейчас включён на сервере или нет;
- `limit` - полный лимит бесплатных загрузок;
- `usedCount` - сколько уже израсходовано;
- `remainingCount` - сколько ещё осталось;
- `maxBytes` - максимальный размер итогового файла.
### Ошибки
- `422 NOT_AUTHENTICATED` - требуется авторизация.
## `TestUploadFreeAvatar`
Временная бесплатная загрузка маленькой аватарки в Arweave через серверный кошелёк.
### Правила
- операция требует авторизованную сессию;
- сервер использует текущий login из сессии;
- сервер принимает только:
- `image/jpeg`
- `image/png`
- `image/webp`
- размер итогового файла должен быть не больше `maxBytes` из квоты;
- если пользователь уже сделал `limit` бесплатных загрузок, операция запрещена.
### Запрос
`fileBytesBase64` - это обычный Base64 байт итогового подготовленного файла.
```json
{
"op": "TestUploadFreeAvatar",
"requestId": "req-test-avatar-upload-1",
"payload": {
"contentType": "image/webp",
"fileBytesBase64": "UklGRiQAAABXRUJQVlA4WAoAAAAQAAAA...",
"sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
}
```
### Успешный ответ
```json
{
"op": "TestUploadFreeAvatar",
"requestId": "req-test-avatar-upload-1",
"status": 200,
"ok": true,
"payload": {
"txId": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"sha256Hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"usedCount": 2,
"remainingCount": 1,
"limit": 3,
"gateway": "https://arweave.net"
}
}
```
### Поля ответа
- `txId` - Arweave Transaction ID загруженного файла;
- `sha256Hex` - SHA-256 загруженного файла;
- `usedCount` - сколько бесплатных загрузок уже израсходовано после этой операции;
- `remainingCount` - сколько бесплатных загрузок осталось;
- `limit` - общий лимит;
- `gateway` - gateway, через который сервер отправлял транзакцию.
### Ошибки
- `422 NOT_AUTHENTICATED` - требуется авторизация;
- `400 BAD_FIELDS` - не переданы `contentType` или `fileBytesBase64`;
- `400 BAD_BASE64` - `fileBytesBase64` не декодируется;
- `400 BAD_AVATAR_FILE` - файл не проходит ограничения сервера;
- `400 FREE_AVATAR_LIMIT_EXHAUSTED` - бесплатный лимит аватарок исчерпан;
- `501 FREE_AVATAR_TEMP_DISABLED` - временная функция выключена или сервер не настроен;
- `500 INTERNAL_ERROR` - внутренняя ошибка сервера.
## Как это используется в UI
На экране редактирования профиля в мастере смены аватара есть временный сценарий:
- `Залить аватар бесплатно`
UI:
1. вызывает `TestGetFreeAvatarQuota`;
2. показывает остаток лимита;
3. локально подготавливает уменьшенный файл аватара;
4. проверяет, что итоговый файл не превышает `maxBytes`;
5. вызывает `TestUploadFreeAvatar`;
6. после получения `txId` обычным путём записывает `avatar.ar` в профиль через `AddBlock`.
## Почему решение временное
- используется общий серверный Arweave-кошелёк;
- лимит хранится отдельной технической таблицей;
- операции имеют префикс `Test...`;
- сценарий нужен как переходный бесплатный путь для маленьких аватаров.
+1 -1
View File
@@ -45,4 +45,4 @@
### Дальнее будущее ### Дальнее будущее
- Сейчас задач нет. - `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`.
@@ -0,0 +1,114 @@
# Homeserver: технические команды и передача файлов через SHiNE/WebRTC
## Зачем нужна фича
Идея на дальнее будущее: дать возможность обращаться к homeserver не только как к участнику сети SHiNE, но и как к удалённой технической точке управления.
Цели:
- отправлять на homeserver технические команды в текстовом виде;
- получать текстовый ответ на команду;
- при наличии WebRTC DataChannel передавать части файлов в обе стороны;
- хранить полученные файлы на SD-карте homeserver;
- использовать единый механизм доставки как через сервер SHiNE, так и напрямую через DataChannel.
## Горизонт
`far` - идея без ближайшего срока реализации. Сейчас приоритет ниже, чем запуск и стабилизация основного проекта.
## Что именно имеется в виду
### 1. Единая модель технической команды
Техническая команда должна иметь единый смысл независимо от транспорта доставки:
- через любой доступный сервер SHiNE;
- через уже установленный WebRTC DataChannel.
Если конкретный транспорт недоступен, ответ по нему может не прийти. Это считается нормальным поведением протокола.
### 2. Команда как короткоживущий подписанный сигнал
У команды должны быть:
- `commandId`;
- временная метка;
- TTL около 10 секунд;
- криптографическая подпись.
Смысл такой:
- если команда быстро дошла, homeserver подтверждает принятие;
- если не дошла вовремя, команда считается протухшей;
- отправитель может безопасно послать повтор;
- при повторе homeserver отвечает либо `команда принята`, либо `уже выполнено ранее`.
Это даёт дедупликацию и безопасный resend без повторного выполнения действия.
### 3. Текстовые технические команды
Базовый сценарий похож на короткий удалённый shell-протокол, но на уровне строго ограниченных команд:
- отправил строку-команду;
- получил строку-ответ.
Команды не обязаны исполнять произвольный shell. Предпочтительная модель - белый список операций с контролируемым форматом аргументов и ответа.
### 4. Передача файлов только при наличии DataChannel
Если между устройствами есть WebRTC DataChannel, через него можно передавать технические сообщения для файлового обмена.
Предварительная модель:
- имя файла = `SHA-256` содержимого;
- можно запросить диапазон байт `from..to`;
- можно отправить диапазон байт `from..to`;
- homeserver хранит полученные данные на SD-карте;
- если DataChannel нет, на запрос файловой передачи возвращается ответ в духе `не могу передать, нет data channel`.
Фактически файл-обмен должен быть частным случаем общего протокола технических команд.
### 5. Установка data-соединения по явной команде
Нужна техническая команда уровня:
- `установить data-соединение`.
Ответ:
- либо `да`, после чего запускается обычная процедура `offer/answer/ICE`;
- либо `нет` и причина отказа.
### 6. Доставка на пользовательские сессии
Логика должна быть совместима с общей моделью SHiNE, где технические сигналы можно отправлять на конкретные активные сессии пользователя.
Идея:
- на любую активную сессию пользователя можно посылать техническую команду;
- контакт пользователя может инициировать такую техническую коммуникацию так же, как он уже инициирует звонок или другой служебный сигнал.
## Что нужно будет сделать при возврате к задаче
- Спроектировать отдельный формат технических команд и ack-ответов.
- Решить, будет ли это новый тип служебных сообщений в существующем протоколе блокчейн/сигналинга или отдельная ветка поверх уже имеющихся transport-операций.
- Отдельно продумать авторизацию: кто именно из контактов и какие команды имеет право слать.
- Ограничить набор допустимых команд, чтобы не превратить механизм в небезопасный удалённый shell.
- Спроектировать протокол чанков файлов: размер чанка, нумерация, повторная отправка, контроль целостности, дозагрузка, завершение файла.
- Продумать хранение на SD-карте: временные файлы, сборка чанков, проверка итогового `SHA-256`, очистка мусора.
- Продумать поведение при отсутствии DataChannel, таймаутах и дублирующихся командах.
- Проверить, как это лучше встраивать в текущие клиентские сессии, звонки и homeserver-логику.
## Вопросы для будущего уточнения
- Это должен быть строго служебный протокол или пользователь сможет вызывать его и вручную из UI.
- Нужен ли доступ только к заранее разрешённым каталогам/файлам.
- Нужна ли двусторонняя синхронизация файлов или достаточно ручных команд `запросить кусок` / `отправить кусок`.
- Нужно ли разрешать передачу файлов через сервер SHiNE как fallback, или файл-обмен должен идти только через DataChannel.
- Какой максимальный размер файлов и допустимый объём хранения на SD-карте.
## Что уже сделано
Пока только зафиксирована идея и базовая концепция. Реализация не начиналась.
## Какие документы нужно будет обновить при реализации
- `Dev_Docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд.
- `Dev_Docs/API/` если изменится публичный серверный API или появятся новые операции.
- `Dev_Docs/Personal_Messages/README.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий.
- Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве.
## С какого места продолжать позже
Возвращаться к задаче только после стабилизации запуска проекта и базовых текущих функций. Начинать с проектирования протокола команд и матрицы прав доступа, а уже потом переходить к DataChannel-файлообмену.
+4 -2
View File
@@ -1,5 +1,7 @@
# Дальнее будущее # Дальнее будущее
Сейчас в этом горизонте нет активных идей.
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании. Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
## Идеи
- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте.
@@ -0,0 +1,22 @@
# Тестовые deploy-контуры `test2.shineup.me` и `test.shineup.me`
- краткое описание:
- default deploy-задачи `deployServer` и `deployUI` переведены на основной тестовый сервер `test2.shineup.me`;
- production-задачи вынесены в `deployServerProduction` и `deployUIProduction`;
- `test.shineup.me` оставлен как резервный тестовый сервер без обычного deploy по умолчанию.
- что проверять:
- `./gradlew deployServer` и `./gradlew deployUI` действительно направлены на `test2.shineup.me`;
- `./gradlew deployServerProduction` и `./gradlew deployUIProduction` больше не используются как default;
- `https://test2.shineup.me` открывает UI;
- `wss://test2.shineup.me/ws` отвечает;
- на `test2.shineup.me` после deploy есть копия продовой `shine.sqlite` и `.bch`.
- ожидаемый результат:
- default deploy идёт только на `test2.shineup.me`;
- production `shineup.me` меняется только после отдельного подтверждения;
- `test.shineup.me` остаётся резервным тестовым сервером;
- тестовый deploy не гоняет удалённые тесты и не создаёт пустую БД.
- статус:
- pending
@@ -0,0 +1,25 @@
# Регистрация: FAQ и режим пароля из 12 слов
- краткое описание:
- на экране регистрации добавлен блок частых вопросов с переходом на отдельный экран справки;
- добавлен альтернативный режим ввода пароля через 12 полей-слов в кошелёчном формате, которые склеиваются в одну строку без изменения API;
- такой же режим добавлен и на экран входа по логину и паролю.
- что проверять:
- на стартовом экране открыть `Зарегистрироваться`;
- убедиться, что внизу экрана есть кнопки FAQ;
- открыть несколько вопросов и проверить возврат обратно на регистрацию;
- включить галочку `Представить пароль в виде 12 слов`;
- убедиться, что появляется сетка с нумерованными полями в 3 колонки;
- ввести часть слов, перейти дальше и проверить, что шаг подтверждения и генерация ключей работают;
- выключить галочку и проверить, что пароль остаётся собранным в одном поле;
- открыть экран входа по паролю и повторить те же проверки для режима `12 слов`;
- пройти регистрацию до шага оплаты без ошибок интерфейса.
- ожидаемый результат:
- FAQ открывается отдельным экраном и содержит понятные ответы;
- режим `12 слов` не ломает регистрацию и вход и даёт тот же поток, что и обычный пароль;
- пароль не отправляется в новом формате, а продолжает использоваться как одна строка.
- статус:
- pending
@@ -0,0 +1,25 @@
# Временная бесплатная загрузка аватара в Arweave
- краткое описание фичи:
Добавлены два временных `Test...` API для бесплатной загрузки маленьких аватаров в Arweave через серверный кошелёк с лимитом `3` загрузки на пользователя. В UI мастера смены аватара добавлен пункт `Залить аватар бесплатно`.
- что именно проверять:
1. Пользователь с активной сессией открывает редактирование профиля.
2. По нажатию на аватар открывается мастер `Сменить аватар`.
3. В мастере есть пункт `Залить аватар бесплатно`.
4. До первой загрузки UI показывает остаток `3 из 3`.
5. Маленький JPEG/PNG/WebP после уменьшения до файла <= `128 KB` успешно уходит через `TestUploadFreeAvatar`.
6. После загрузки приходит `txId`, и аватар сохраняется в профиль как `avatar.ar`.
7. Остаток уменьшается: `2`, `1`, `0`.
8. На четвёртой попытке сервер отвечает понятной ошибкой про исчерпанный бесплатный лимит.
9. Если итоговый уменьшенный файл всё ещё > `128 KB`, UI не отправляет его и показывает понятную ошибку.
10. Если серверный Arweave JWK/path не настроен, UI получает понятную ошибку временной функции.
- ожидаемый результат:
- первые 3 маленькие аватарки загружаются через серверный Arweave-кошелёк;
- после каждой успешной загрузки `ava` в профиле указывает на новый `txId`;
- после исчерпания лимита дальнейшая бесплатная загрузка блокируется без записи в профиль;
- обычная загрузка через свой Arweave-кошелёк продолжает работать отдельно.
- статус:
pending
@@ -0,0 +1,19 @@
# Исправление chatId личных сообщений через lowercase
- краткое описание фичи:
- В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB.
- Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина.
- что именно проверять:
- Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами.
- Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе.
- Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге.
- Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования.
- ожидаемый результат:
- Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина.
- Общий unread, список диалогов и содержимое открытого чата совпадают между собой.
- После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре.
- статус:
- pending
+46 -2
View File
@@ -13,12 +13,56 @@
- актуальный IP должен браться через DNS-резолв на момент подключения; - актуальный IP должен браться через DNS-резолв на момент подключения;
- ручное дублирование IP в документации и deploy-скриптах не поддерживать. - ручное дублирование IP в документации и deploy-скриптах не поддерживать.
## Контуры деплоя
- Production:
- SSH: `player@shineup.me`
- Домен: `shineup.me`
- IP: `185.229.109.118`
- Main test:
- SSH: `player@193.8.215.70`
- Домен: `test2.shineup.me`
- IP: `193.8.215.70`
- Reserve test:
- SSH: `player@93.170.12.154`
- Домен: `test.shineup.me`
- IP: `93.170.12.154`
## Локальные команды ## Локальные команды
- Деплой сервера: `./gradlew deployServer` - Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`
- Деплой UI: `./gradlew deployUI` - Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`
- Production server deploy: `./gradlew deployServerProduction`
- Production UI deploy: `./gradlew deployUIProduction`
- Reserve test server deploy: `./gradlew deployServerTest`
- Reserve test UI deploy: `./gradlew deployUITest`
- Локальный запуск: `./gradlew startLocal` - Локальный запуск: `./gradlew startLocal`
## Политика подтверждений
- `shineup.me` — production.
- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя.
- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `test2.shineup.me`.
## Main test deploy (`test2.shineup.me`)
- Это основной сервер для тестов.
- `deployServer` и `deployUI` по умолчанию направлены именно сюда.
- Серверный deploy не запускает JUnit/IT-тесты на удалённом сервере.
- `deployServer` / `deployServerTest2` делают:
- сборку fat-jar локально;
- синхронизацию `data/` и `shine.sqlite` с production `shineup.me`;
- перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`;
- установку `systemd` unit на `193.8.215.70`;
- перезапуск `shine-server.service`;
- установку/проверку Caddy для `test2.shineup.me`.
- `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`.
## Reserve test deploy (`test.shineup.me`)
- `test.shineup.me` пока не использовать для обычного deploy.
- Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины.
## UI-деплой и Caddy (обязательно) ## UI-деплой и Caddy (обязательно)
- Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`. - Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`.
@@ -0,0 +1,42 @@
# Сервер `193.8.215.70` — основной test (`test2.shineup.me`)
- Пользователь: `player`
- Домен: `test2.shineup.me`
- Каталог SHiNE: `/home/player/SHiNE`
- UI публикация для Caddy: `/home/player/SHiNE/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.
- Default deploy по умолчанию должен идти сюда.
- Источник данных для тестовой БД: production `shineup.me`.
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Сайты:
- `test2.shineup.me`
- `agent.shiningpeople.ru`
- Для `test2.shineup.me`:
- `root * /home/player/SHiNE/shine-ui`
- `try_files {path} /index.html`
- `reverse_proxy /ws* -> 127.0.0.1:7070`
## Deploy
- Default server deploy:
- `./gradlew deployServer`
- `./gradlew deployServerTest2`
- Default UI deploy:
- `./gradlew deployUI`
- `./gradlew deployUITest2`
@@ -1,14 +1,14 @@
# Сервер `93.170.12.154` — резервный # Сервер `93.170.12.154` — test.shineup.me
- Пользователь: `player` - Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE` - Каталог SHiNE: `/home/player/SHiNE`
- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI` - Домен: `test.shineup.me`
- UI публикация для Caddy: `/var/www/shine-ui` - UI публикация для Caddy: `/home/player/SHiNE/shine-ui`
- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar` - Сервер: `/home/player/SHiNE/shine-server/shine-server.jar`
- Данные: `/home/player/SHiNE/SHiNE-server/data/` - Данные: `/home/player/SHiNE/shine-server/data/`
- `shine.sqlite` - `shine.sqlite`
- `*.bch` - `*.bch`
- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log` - Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log`
## Сервисы ## Сервисы
@@ -17,8 +17,10 @@
## Статус ## Статус
- Резервный сервер для SHiNE. - Резервный тестовый сервер для SHiNE.
- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS). - Источник данных для тестовой БД: production `shineup.me`.
- Пока не использовать для обычного deploy.
- Основной прод-сервер: `shineup.me` (`185.229.109.118`).
## Caddy ## Caddy
@@ -26,4 +28,12 @@
- Настройки: - Настройки:
- `no-store/no-cache` заголовки; - `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback); - `try_files {path} /index.html` (SPA fallback);
- `reverse_proxy /ws* -> 127.0.0.1:7070`. - `reverse_proxy /ws* -> 127.0.0.1:7070`;
- целевой сайт: `test.shineup.me`.
## Deploy
- Резервные задачи:
- `./gradlew deployServerTest`
- `./gradlew deployUITest`
- Эти задачи пока не использовать без отдельной причины.
@@ -33,3 +33,15 @@
- `https://test-solana-tickets.shineup.me` - `https://test-solana-tickets.shineup.me`
- `https://test-solana-tickets.shiningpeople.ru` - `https://test-solana-tickets.shiningpeople.ru`
- Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP. - Для всех deploy-скриптов и инструкций использовать именно `player@shineup.me`, без жёсткой фиксации IP.
## Правило изменений
- `shineup.me` — production.
- Любые изменения на этом сервере делать только после отдельного явного подтверждения пользователя.
## Deploy
- Production deploy-задачи:
- `./gradlew deployServerProduction`
- `./gradlew deployUIProduction`
- Default deploy-задачи `./gradlew deployServer` и `./gradlew deployUI` сюда больше не относятся.
+20 -1
View File
@@ -56,9 +56,28 @@ shine-UI/server-ui.html
``` ```
./gradlew deployServer ./gradlew deployServer
./gradlew deployUI
``` ```
Хост по умолчанию: `player@93.170.12.154` (shineup.me). Default deploy по умолчанию идёт на `test2.shineup.me` (`player@193.8.215.70`).
Production deploy:
```
./gradlew deployServerProduction
./gradlew deployUIProduction
```
Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя.
Резервный test-контур:
```
./gradlew deployServerTest
./gradlew deployUITest
```
`test.shineup.me` пока не использовать для обычного deploy.
Логи на проде: Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log` - `/home/player/SHiNE/shine-server/logs/app.log`
@@ -264,6 +264,21 @@ public final class DatabaseInitializer {
ON ip_geo_cache (updated_at_ms); ON ip_geo_cache (updated_at_ms);
"""); """);
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
// 5. blockchain_state // 5. blockchain_state
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS blockchain_state ( CREATE TABLE IF NOT EXISTS blockchain_state (
@@ -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 = 7; private static final int LATEST_SCHEMA_VERSION = 8;
private final String jdbcUrl; private final String jdbcUrl;
@@ -90,6 +90,7 @@ public final class SqliteDbController {
case 5 -> migrateToV5(); case 5 -> migrateToV5();
case 6 -> migrateToV6(); case 6 -> migrateToV6();
case 7 -> migrateToV7(); case 7 -> migrateToV7();
case 8 -> migrateToV8();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
} }
} }
@@ -249,6 +250,25 @@ public final class SqliteDbController {
} }
} }
private void migrateToV8() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureTestFreeAvatarUploadsTable(st);
setSchemaVersion(c, 8);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v8 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v8 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 (
@@ -432,6 +452,22 @@ public final class SqliteDbController {
"""); """);
} }
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
}
private static void createConnectionsStateTable(Statement st) throws SQLException { private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate(""" st.executeUpdate("""
@@ -0,0 +1,82 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.TestFreeAvatarUploadEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class TestFreeAvatarUploadsDAO {
private static volatile TestFreeAvatarUploadsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private TestFreeAvatarUploadsDAO() {
}
public static TestFreeAvatarUploadsDAO getInstance() {
if (instance == null) {
synchronized (TestFreeAvatarUploadsDAO.class) {
if (instance == null) instance = new TestFreeAvatarUploadsDAO();
}
}
return instance;
}
public TestFreeAvatarUploadEntry getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT login, used_count, updated_at_ms, last_tx_id
FROM test_free_avatar_uploads
WHERE login = ? COLLATE NOCASE
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public TestFreeAvatarUploadEntry getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
public void upsertUsage(Connection c, String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
String sql = """
INSERT INTO test_free_avatar_uploads (login, used_count, updated_at_ms, last_tx_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(login) DO UPDATE SET
used_count = excluded.used_count,
updated_at_ms = excluded.updated_at_ms,
last_tx_id = excluded.last_tx_id
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, usedCount);
ps.setLong(3, updatedAtMs);
ps.setString(4, lastTxId);
ps.executeUpdate();
}
}
public void upsertUsage(String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
try (Connection c = db.getConnection()) {
upsertUsage(c, login, usedCount, updatedAtMs, lastTxId);
}
}
private static TestFreeAvatarUploadEntry mapRow(ResultSet rs) throws SQLException {
return new TestFreeAvatarUploadEntry(
rs.getString("login"),
rs.getInt("used_count"),
rs.getLong("updated_at_ms"),
rs.getString("last_tx_id")
);
}
}
@@ -0,0 +1,50 @@
package shine.db.entities;
public class TestFreeAvatarUploadEntry {
private String login;
private int usedCount;
private long updatedAtMs;
private String lastTxId;
public TestFreeAvatarUploadEntry() {
}
public TestFreeAvatarUploadEntry(String login, int usedCount, long updatedAtMs, String lastTxId) {
this.login = login;
this.usedCount = usedCount;
this.updatedAtMs = updatedAtMs;
this.lastTxId = lastTxId;
}
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public long getUpdatedAtMs() {
return updatedAtMs;
}
public void setUpdatedAtMs(long updatedAtMs) {
this.updatedAtMs = updatedAtMs;
}
public String getLastTxId() {
return lastTxId;
}
public void setLastTxId(String lastTxId) {
this.lastTxId = lastTxId;
}
}
@@ -45,7 +45,11 @@ import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
// --- NEW: SearchUsers --- // --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
@@ -127,6 +131,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", new Net_AddUser_Handler()), Map.entry("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()), Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth --- // --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
@@ -200,6 +206,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", Net_AddUser_Request.class), Map.entry("AddUser", Net_AddUser_Request.class),
Map.entry("GetUser", Net_GetUser_Request.class), Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class), Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth --- // --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
@@ -0,0 +1,37 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
public class Net_TestGetFreeAvatarQuota_Handler implements JsonMessageHandler {
private final TestFreeAvatarArweaveService service = new TestFreeAvatarArweaveService();
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_TestGetFreeAvatarQuota_Request req = (Net_TestGetFreeAvatarQuota_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String login = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
TestFreeAvatarArweaveService.Quota quota = service.getQuota(login);
Net_TestGetFreeAvatarQuota_Response resp = new Net_TestGetFreeAvatarQuota_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setEnabled(quota.enabled());
resp.setLimit(quota.limit());
resp.setUsedCount(quota.usedCount());
resp.setRemainingCount(quota.remainingCount());
resp.setMaxBytes(service.getMaxBytes());
return resp;
}
}
@@ -0,0 +1,60 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import java.util.Base64;
public class Net_TestUploadFreeAvatar_Handler implements JsonMessageHandler {
private final TestFreeAvatarArweaveService service = new TestFreeAvatarArweaveService();
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_TestUploadFreeAvatar_Request req = (Net_TestUploadFreeAvatar_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String fileBase64 = String.valueOf(req.getFileBytesBase64() == null ? "" : req.getFileBytesBase64()).trim();
String contentType = String.valueOf(req.getContentType() == null ? "" : req.getContentType()).trim();
if (fileBase64.isBlank() || contentType.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нужно передать contentType и fileBytesBase64.");
}
byte[] fileBytes;
try {
fileBytes = Base64.getDecoder().decode(fileBase64);
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", "fileBytesBase64 должен быть корректным Base64.");
}
String login = String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim();
try {
TestFreeAvatarArweaveService.UploadResult result = service.uploadAvatar(login, contentType, fileBytes, req.getSha256Hex());
Net_TestUploadFreeAvatar_Response resp = new Net_TestUploadFreeAvatar_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setTxId(result.txId());
resp.setSha256Hex(result.sha256Hex());
resp.setUsedCount(result.usedCount());
resp.setRemainingCount(result.remainingCount());
resp.setLimit(result.limit());
resp.setGateway(result.gateway());
return resp;
} catch (TestFreeAvatarArweaveService.FreeAvatarLimitExceededException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "FREE_AVATAR_LIMIT_EXHAUSTED", e.getMessage());
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_AVATAR_FILE", e.getMessage());
} catch (IllegalStateException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "FREE_AVATAR_TEMP_DISABLED", e.getMessage());
}
}
}
@@ -0,0 +1,391 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.TestFreeAvatarUploadsDAO;
import shine.db.entities.TestFreeAvatarUploadEntry;
import utils.config.AppConfig;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.sql.SQLException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class TestFreeAvatarArweaveService {
private static final Logger log = LoggerFactory.getLogger(TestFreeAvatarArweaveService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.build();
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
private static final Base64.Encoder B64URL_NOPAD = Base64.getUrlEncoder().withoutPadding();
private static final int DEFAULT_LIMIT = 3;
private static final int DEFAULT_MAX_BYTES = 128 * 1024;
private static final String DEFAULT_GATEWAY = "https://arweave.net";
private static final ConcurrentHashMap<String, Object> LOGIN_LOCKS = new ConcurrentHashMap<>();
private final AppConfig config = AppConfig.getInstance();
private final TestFreeAvatarUploadsDAO quotaDao = TestFreeAvatarUploadsDAO.getInstance();
public Quota getQuota(String login) throws SQLException {
int limit = getLimitPerUser();
TestFreeAvatarUploadEntry entry = quotaDao.getByLogin(login);
int used = entry == null ? 0 : Math.max(0, entry.getUsedCount());
int remaining = Math.max(0, limit - used);
return new Quota(limit, used, remaining, isEnabled());
}
public UploadResult uploadAvatar(String login, String contentType, byte[] fileBytes, String expectedSha256Hex)
throws Exception {
if (!isEnabled()) {
throw new IllegalStateException("Временная бесплатная загрузка аватаров сейчас отключена на сервере.");
}
String cleanLogin = String.valueOf(login == null ? "" : login).trim();
if (cleanLogin.isBlank()) {
throw new IllegalArgumentException("Пустой login для бесплатной загрузки аватара.");
}
String cleanType = normalizeContentType(contentType);
validatePayload(cleanType, fileBytes);
String actualSha256Hex = sha256Hex(fileBytes);
String expectedSha = String.valueOf(expectedSha256Hex == null ? "" : expectedSha256Hex).trim().toLowerCase();
if (!expectedSha.isBlank() && !actualSha256Hex.equals(expectedSha)) {
throw new IllegalArgumentException("SHA-256 файла не совпадает с присланным клиентом.");
}
Object lock = LOGIN_LOCKS.computeIfAbsent(cleanLogin.toLowerCase(), key -> new Object());
synchronized (lock) {
Quota before = getQuota(cleanLogin);
if (before.remainingCount() <= 0) {
throw new FreeAvatarLimitExceededException("Вы исчерпали бесплатный лимит аватарок.");
}
ArweaveConfig arConfig = loadArweaveConfig();
String txId = postAvatarTransaction(arConfig, cleanLogin, cleanType, fileBytes);
int usedAfter = before.usedCount() + 1;
int remainingAfter = Math.max(0, before.limit() - usedAfter);
quotaDao.upsertUsage(cleanLogin, usedAfter, System.currentTimeMillis(), txId);
return new UploadResult(txId, actualSha256Hex, usedAfter, remainingAfter, before.limit(), arConfig.gateway());
}
}
public boolean isEnabled() {
return config.getBoolean("test.freeAvatar.enabled", true);
}
public int getLimitPerUser() {
int configured = config.getInt("test.freeAvatar.limitPerUser", DEFAULT_LIMIT);
return Math.max(1, configured);
}
public int getMaxBytes() {
int configured = config.getInt("test.freeAvatar.maxBytes", DEFAULT_MAX_BYTES);
return Math.max(1024, configured);
}
private String postAvatarTransaction(ArweaveConfig arConfig, String login, String contentType, byte[] data)
throws IOException, InterruptedException, GeneralSecurityException {
String gateway = arConfig.gateway();
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + data.length);
if (!reward.matches("^\\d+$")) {
throw new IllegalStateException("Arweave gateway вернул некорректную цену загрузки.");
}
if (!arConfig.address().isBlank()) {
String balance = getRequiredText(gateway, "/wallet/" + arConfig.address() + "/balance");
if (balance.matches("^\\d+$")) {
BigInteger balanceWinston = new BigInteger(balance);
BigInteger rewardWinston = new BigInteger(reward);
if (balanceWinston.compareTo(rewardWinston) < 0) {
throw new IllegalStateException("На серверном Arweave-кошельке недостаточно AR для бесплатной загрузки аватара.");
}
}
}
byte[] dataRoot = singleChunkDataRoot(data);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String, String>> jsonTags = new ArrayList<>();
appendTag(jsonTags, tagList, "Content-Type", contentType);
appendTag(jsonTags, tagList, "App-Name", "SHiNE");
appendTag(jsonTags, tagList, "SHiNE-Type", "avatar-free-test");
appendTag(jsonTags, tagList, "SHiNE-Profile-Login", login);
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64UrlDecode(arConfig.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64UrlDecode(anchor),
tagList,
utf8(Integer.toString(data.length)),
dataRoot
));
byte[] rawSignature = signPayload(arConfig.privateKey(), signaturePayload);
String txId = b64UrlEncode(sha256(rawSignature));
Map<String, Object> body = new LinkedHashMap<>();
body.put("format", 2);
body.put("id", txId);
body.put("last_tx", anchor);
body.put("owner", arConfig.owner());
body.put("tags", jsonTags);
body.put("target", "");
body.put("quantity", "0");
body.put("data_root", b64UrlEncode(dataRoot));
body.put("data_size", Integer.toString(data.length));
body.put("data", b64UrlEncode(data));
body.put("reward", reward);
body.put("signature", b64UrlEncode(rawSignature));
String bodyJson = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx"))
.timeout(Duration.ofSeconds(40))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(bodyJson, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
int status = response.statusCode();
if (status != 200 && status != 208) {
throw new IllegalStateException("Arweave отклонил транзакцию: HTTP " + status + " " + safeBody(response.body()));
}
return txId;
}
private ArweaveConfig loadArweaveConfig() throws IOException, GeneralSecurityException {
String rawGateway = String.valueOf(config.getParam("test.freeAvatar.gateway") == null
? DEFAULT_GATEWAY
: config.getParam("test.freeAvatar.gateway")).trim();
String gateway = rawGateway.isBlank() ? DEFAULT_GATEWAY : rawGateway.replaceAll("/+$", "");
String walletPathRaw = String.valueOf(config.getParam("test.freeAvatar.walletJwkPath") == null
? ""
: config.getParam("test.freeAvatar.walletJwkPath")).trim();
if (walletPathRaw.isBlank()) {
throw new IllegalStateException("Не задан test.freeAvatar.walletJwkPath в настройках сервера.");
}
JsonNode jwk = MAPPER.readTree(Files.readString(Path.of(walletPathRaw), StandardCharsets.UTF_8));
String owner = requiredText(jwk, "n");
PrivateKey privateKey = buildPrivateKeyFromJwk(jwk);
String computedAddress = b64UrlEncode(sha256(b64UrlDecode(owner)));
String expectedAddress = String.valueOf(config.getParam("test.freeAvatar.walletAddress") == null
? ""
: config.getParam("test.freeAvatar.walletAddress")).trim();
if (!expectedAddress.isBlank() && !expectedAddress.equals(computedAddress)) {
throw new IllegalStateException("test.freeAvatar.walletAddress не совпадает с адресом из JWK.");
}
return new ArweaveConfig(gateway, owner, computedAddress, privateKey);
}
private static PrivateKey buildPrivateKeyFromJwk(JsonNode jwk) throws GeneralSecurityException {
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
asBigInt(requiredText(jwk, "n")),
asBigInt(requiredText(jwk, "e")),
asBigInt(requiredText(jwk, "d")),
asBigInt(requiredText(jwk, "p")),
asBigInt(requiredText(jwk, "q")),
asBigInt(requiredText(jwk, "dp")),
asBigInt(requiredText(jwk, "dq")),
asBigInt(requiredText(jwk, "qi"))
);
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
Signature signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] singleChunkDataRoot(byte[] data) throws GeneralSecurityException {
byte[] dataHash = sha256(data);
byte[] left = sha256(dataHash);
byte[] right = sha256(intToBuffer32(data.length));
return sha256(concat(left, right));
}
@SuppressWarnings("unchecked")
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) {
throw new IllegalArgumentException("deepHash поддерживает только byte[] и list.");
}
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
byte[] tagged = concat(sha384(tag), sha384(bytes));
return sha384(tagged);
}
private static byte[] deepHashChunks(List<?> chunks, byte[] acc) throws GeneralSecurityException {
if (chunks.isEmpty()) return acc;
byte[] pair = concat(acc, deepHash(chunks.get(0)));
return deepHashChunks(chunks.subList(1, chunks.size()), sha384(pair));
}
private static void appendTag(List<Map<String, String>> jsonTags, List<List<byte[]>> tagList, String name, String value) {
byte[] nameBytes = utf8(name);
byte[] valueBytes = utf8(value);
Map<String, String> item = new LinkedHashMap<>();
item.put("name", b64UrlEncode(nameBytes));
item.put("value", b64UrlEncode(valueBytes));
jsonTags.add(item);
tagList.add(List.of(nameBytes, valueBytes));
}
private static String getRequiredText(String gateway, String path) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path))
.timeout(Duration.ofSeconds(20))
.GET()
.build();
HttpResponse<String> response = HTTP.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("Arweave gateway вернул HTTP " + response.statusCode() + " для " + path);
}
String body = String.valueOf(response.body() == null ? "" : response.body()).trim();
if (body.isBlank()) {
throw new IllegalStateException("Arweave gateway вернул пустой ответ для " + path);
}
return body;
}
private void validatePayload(String contentType, byte[] fileBytes) {
if (fileBytes == null || fileBytes.length == 0) {
throw new IllegalArgumentException("Файл аватара пустой.");
}
if (fileBytes.length > getMaxBytes()) {
throw new IllegalArgumentException("Файл слишком большой для бесплатной загрузки. Максимум " + getMaxBytes() + " байт.");
}
if (!isSupportedContentType(contentType)) {
throw new IllegalArgumentException("Поддерживаются только JPEG, PNG или WebP.");
}
}
private static boolean isSupportedContentType(String contentType) {
return "image/jpeg".equals(contentType)
|| "image/png".equals(contentType)
|| "image/webp".equals(contentType);
}
private static String normalizeContentType(String contentType) {
return String.valueOf(contentType == null ? "" : contentType).trim().toLowerCase();
}
private static String requiredText(JsonNode node, String field) {
String value = node == null ? "" : String.valueOf(node.path(field).asText("")).trim();
if (value.isBlank()) {
throw new IllegalStateException("В JWK отсутствует поле " + field + ".");
}
return value;
}
private static BigInteger asBigInt(String b64Url) {
return new BigInteger(1, b64UrlDecode(b64Url));
}
private static byte[] b64UrlDecode(String value) {
return B64URL.decode(String.valueOf(value == null ? "" : value).trim());
}
private static String b64UrlEncode(byte[] value) {
return B64URL_NOPAD.encodeToString(value);
}
private static byte[] utf8(String value) {
return String.valueOf(value == null ? "" : value).getBytes(StandardCharsets.UTF_8);
}
private static byte[] concat(byte[]... arrays) {
int total = 0;
for (byte[] array : arrays) total += array.length;
byte[] out = new byte[total];
int offset = 0;
for (byte[] array : arrays) {
System.arraycopy(array, 0, out, offset, array.length);
offset += array.length;
}
return out;
}
private static byte[] intToBuffer32(int value) {
byte[] out = new byte[32];
long current = Integer.toUnsignedLong(value);
for (int i = out.length - 1; i >= 0; i--) {
out[i] = (byte) (current & 0xffL);
current >>>= 8;
}
return out;
}
private static byte[] sha256(byte[] data) throws GeneralSecurityException {
return MessageDigest.getInstance("SHA-256").digest(data);
}
private static byte[] sha384(byte[] data) throws GeneralSecurityException {
return MessageDigest.getInstance("SHA-384").digest(data);
}
private static String sha256Hex(byte[] data) throws GeneralSecurityException {
byte[] hash = sha256(data);
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString();
}
private static String safeBody(String body) {
String text = String.valueOf(body == null ? "" : body).replace('\n', ' ').replace('\r', ' ').trim();
if (text.length() <= 220) return text;
return text.substring(0, 220) + "...";
}
public record Quota(int limit, int usedCount, int remainingCount, boolean enabled) {
}
public record UploadResult(String txId, String sha256Hex, int usedCount, int remainingCount, int limit, String gateway) {
}
private record ArweaveConfig(String gateway, String owner, String address, PrivateKey privateKey) {
}
public static final class FreeAvatarLimitExceededException extends RuntimeException {
public FreeAvatarLimitExceededException(String message) {
super(message);
}
}
}
@@ -0,0 +1,6 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_TestGetFreeAvatarQuota_Request extends Net_Request {
}
@@ -0,0 +1,51 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_TestGetFreeAvatarQuota_Response extends Net_Response {
private boolean enabled;
private int limit;
private int usedCount;
private int remainingCount;
private int maxBytes;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public int getLimit() {
return limit;
}
public void setLimit(int limit) {
this.limit = limit;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public int getRemainingCount() {
return remainingCount;
}
public void setRemainingCount(int remainingCount) {
this.remainingCount = remainingCount;
}
public int getMaxBytes() {
return maxBytes;
}
public void setMaxBytes(int maxBytes) {
this.maxBytes = maxBytes;
}
}
@@ -0,0 +1,33 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_TestUploadFreeAvatar_Request extends Net_Request {
private String contentType;
private String fileBytesBase64;
private String sha256Hex;
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getFileBytesBase64() {
return fileBytesBase64;
}
public void setFileBytesBase64(String fileBytesBase64) {
this.fileBytesBase64 = fileBytesBase64;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
}
@@ -0,0 +1,60 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_TestUploadFreeAvatar_Response extends Net_Response {
private String txId;
private String sha256Hex;
private int usedCount;
private int remainingCount;
private int limit;
private String gateway;
public String getTxId() {
return txId;
}
public void setTxId(String txId) {
this.txId = txId;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public int getRemainingCount() {
return remainingCount;
}
public void setRemainingCount(int remainingCount) {
this.remainingCount = remainingCount;
}
public int getLimit() {
return limit;
}
public void setLimit(int limit) {
this.limit = limit;
}
public String getGateway() {
return gateway;
}
public void setGateway(String gateway) {
this.gateway = gateway;
}
}
@@ -61,3 +61,14 @@ call.ice.turn.servers.2.password=
# Если параметр отсутствует, по умолчанию считается false # Если параметр отсутствует, по умолчанию считается false
# ------------------------------------------------------------ # ------------------------------------------------------------
debug.tempApi.enabled=true debug.tempApi.enabled=true
# ------------------------------------------------------------
# Временная тестовая бесплатная загрузка маленьких аватаров в Arweave
# API только для тестового периода. Ключ хранится вне кода в JWK-файле.
# ------------------------------------------------------------
test.freeAvatar.enabled=true
test.freeAvatar.gateway=https://arweave.net
test.freeAvatar.limitPerUser=3
test.freeAvatar.maxBytes=131072
test.freeAvatar.walletAddress=
test.freeAvatar.walletJwkPath=
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.219 client.version=1.2.230
server.version=1.2.207 server.version=1.2.216
+48 -6
View File
@@ -185,16 +185,14 @@ tasks.named('build') {
finalizedBy tasks.named('integrationTest') finalizedBy tasks.named('integrationTest')
} }
tasks.register('deployServer', JavaExec) { tasks.register('deployServerProduction', JavaExec) {
group = "!!deployment" group = "!!deployment"
description = "Build → upload to server → restart service (без удаления БД, без IT тестов)" description = "Production deploy: build → upload to shineup.me → restart service (только после явного подтверждения)"
classpath = sourceSets.test.runtimeClasspath classpath = sourceSets.test.runtimeClasspath
mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain" mainClass = "test.it.IT_DeployRestartNoCleanNoTestsMain"
workingDir = file('SHiNE-server') workingDir = file('SHiNE-server')
// можно переопределить при запуске:
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
dependsOn shadowJar dependsOn shadowJar
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me") systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "shineup.me")
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player") systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
@@ -205,13 +203,57 @@ tasks.register('deployServer', JavaExec) {
dependsOn testClasses dependsOn testClasses
} }
tasks.register('deployUI', Exec) { tasks.register('deployUIProduction', Exec) {
group = "!!deployment" group = "!!deployment"
description = "Deploy WEB UI (production: shineup.me)" description = "Production UI deploy: shineup.me (только после явного подтверждения)"
workingDir = rootDir workingDir = rootDir
commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath commandLine 'bash', file('deploy_shine-PWA.sh').absolutePath
} }
tasks.register('deployServer', Exec) {
group = "!!deployment"
description = "Default deploy server: test2.shineup.me"
dependsOn shadowJar
workingDir = rootDir
environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath
commandLine 'bash', file('deploy_shine-server_test2.sh').absolutePath
}
tasks.register('deployUI', Exec) {
group = "!!deployment"
description = "Default deploy UI: test2.shineup.me"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-ui_test2.sh').absolutePath
}
tasks.register('deployServerTest2') {
group = "!!deployment"
description = "Явный алиас основного test deploy server: test2.shineup.me"
dependsOn tasks.named('deployServer')
}
tasks.register('deployUITest2') {
group = "!!deployment"
description = "Явный алиас основного test deploy UI: test2.shineup.me"
dependsOn tasks.named('deployUI')
}
tasks.register('deployServerTest', Exec) {
group = "!!deployment"
description = "Резервный test deploy: test.shineup.me (пока не использовать)"
dependsOn shadowJar
workingDir = rootDir
environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath
commandLine 'bash', file('deploy_shine-server_test.sh').absolutePath
}
tasks.register('deployUITest', Exec) {
group = "!!deployment"
description = "Резервный test UI deploy: test.shineup.me (пока не использовать)"
workingDir = rootDir
commandLine 'bash', file('deploy_shine-ui_test.sh').absolutePath
}
tasks.register('startLocal', Exec) { tasks.register('startLocal', Exec) {
group = "!!run" group = "!!run"
description = "Builds server, starts local WS server and local HTTP UI for end-to-end local testing" description = "Builds server, starts local WS server and local HTTP UI for end-to-end local testing"
+1 -1
View File
@@ -24,7 +24,7 @@ if [[ -z "$CLIENT_VERSION" ]]; then
fi fi
export CLIENT_VERSION export CLIENT_VERSION
TARGET_URL="https://shineup.me" TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}"
REMOTE_DIR="${REMOTE_UI_DIR}" REMOTE_DIR="${REMOTE_UI_DIR}"
cleanup() { cleanup() {
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
PROD_HOST="${PROD_HOST:-player@shineup.me}"
TEST_HOST="${TEST_HOST:-player@93.170.12.154}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}"
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}"
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}"
LOCAL_JAR="${LOCAL_JAR:-build/libs/shine-server.jar}"
PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}"
PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
require_file() {
local path="$1"
if [[ ! -f "$path" ]]; then
echo "ERROR: файл не найден: $path" >&2
exit 1
fi
}
echo "==> Проверка локального jar"
require_file "$LOCAL_JAR"
jar_size="$(stat -c %s "$LOCAL_JAR")"
if [[ "$jar_size" -lt 10485760 ]]; then
echo "ERROR: jar слишком маленький для fat-jar: $jar_size bytes" >&2
exit 1
fi
echo "==> Проверка SSH и sudo"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$PROD_HOST" "echo SSH OK" >/dev/null
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TEST_HOST" "echo SSH OK" >/dev/null
ssh "$TEST_HOST" "sudo -n true"
echo "==> Подготовка Caddy для $TARGET_DOMAIN"
TEST_HOST="$TEST_HOST" \
TARGET_DOMAIN="$TARGET_DOMAIN" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test_caddyfile.sh"
echo "==> Забираем продовые данные и application.properties"
mkdir -p "$TMP_DIR/data"
rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/"
scp -p "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties" >/dev/null
if grep -q '^server\.ui\.indexPath=' "$TMP_DIR/application.properties"; then
perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties"
else
printf '\nserver.ui.indexPath=/home/player/SHiNE/shine-ui/index.html\n' >>"$TMP_DIR/application.properties"
fi
cat >"$TMP_DIR/shine-server.service" <<EOF
[Unit]
Description=SHiNE Server
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=$REMOTE_SERVER_DIR
ExecStart=/usr/bin/java -Dserver.port=7070 -jar $REMOTE_SERVER_DIR/shine-server.jar
Restart=always
RestartSec=3
StandardOutput=append:$REMOTE_LOGS_DIR/app.log
StandardError=append:$REMOTE_LOGS_DIR/app.log
[Install]
WantedBy=multi-user.target
EOF
echo "==> Останавливаем текущий сервер на тестовом хосте"
ssh "$TEST_HOST" "sudo systemctl stop $REMOTE_SERVICE_NAME || true"
echo "==> Создаём каталоги"
ssh "$TEST_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR' '$REMOTE_BASE/caddy'"
echo "==> Копируем продовую БД и blockchain-данные"
rsync -az --delete "$TMP_DIR/data/" "$TEST_HOST:$REMOTE_DATA_DIR/"
echo "==> Загружаем новый jar и конфиг"
rsync -az --timeout=120 "$LOCAL_JAR" "$TEST_HOST:$REMOTE_SERVER_DIR/shine-server.jar.new"
rsync -az --timeout=30 "$TMP_DIR/application.properties" "$TEST_HOST:$REMOTE_SERVER_DIR/application.properties.new"
rsync -az --timeout=30 "$TMP_DIR/shine-server.service" "$TEST_HOST:/tmp/shine-server.service.new"
echo "==> Применяем systemd unit и файлы сервера"
ssh "$TEST_HOST" "set -euo pipefail; \
mv -f '$REMOTE_SERVER_DIR/shine-server.jar.new' '$REMOTE_SERVER_DIR/shine-server.jar'; \
mv -f '$REMOTE_SERVER_DIR/application.properties.new' '$REMOTE_SERVER_DIR/application.properties'; \
sudo mv -f /tmp/shine-server.service.new /etc/systemd/system/shine-server.service; \
sudo chown root:root /etc/systemd/system/shine-server.service; \
chmod 644 '$REMOTE_SERVER_DIR/application.properties'; \
chmod 664 '$REMOTE_SERVER_DIR/shine-server.jar'; \
mkdir -p '$REMOTE_LOGS_DIR'; \
touch '$REMOTE_LOGS_DIR/app.log'; \
chown -R player:player '$REMOTE_SERVER_DIR'; \
sudo systemctl daemon-reload; \
sudo systemctl enable '$REMOTE_SERVICE_NAME'; \
sudo systemctl restart '$REMOTE_SERVICE_NAME'"
echo "==> Ждём порт 7070"
for _ in $(seq 1 50); do
if ssh "$TEST_HOST" "ss -ltn '( sport = :7070 )' | grep -q 7070"; then
echo "==> Порт 7070 поднялся"
break
fi
sleep 1
done
if ! ssh "$TEST_HOST" "ss -ltn '( sport = :7070 )' | grep -q 7070"; then
echo "ERROR: тестовый сервер не поднял порт 7070" >&2
exit 1
fi
echo "==> Проверяем статус сервиса"
ssh "$TEST_HOST" "sudo systemctl --no-pager --full status '$REMOTE_SERVICE_NAME' | sed -n '1,20p'"
echo "test_server_deploy_done"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
PROD_HOST="${PROD_HOST:-player@shineup.me}"
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}"
REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}"
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}"
REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}"
REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}"
LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}"
PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}"
PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
if [[ ! -f "$LOCAL_JAR" ]]; then
echo "ERROR: локальный jar не найден: $LOCAL_JAR" >&2
exit 1
fi
ssh -o BatchMode=yes -o ConnectTimeout=20 "$PROD_HOST" "echo SSH OK" >/dev/null
ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null
ssh "$TARGET_HOST" "sudo -n true"
ssh "$TARGET_HOST" "java -version >/dev/null 2>&1"
mkdir -p "$TMP_DIR/data"
rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/"
rsync -az "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties"
perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties"
cat >"$TMP_DIR/shine-server.service" <<EOF
[Unit]
Description=SHiNE Server
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=$REMOTE_SERVER_DIR
ExecStart=/usr/bin/java -Dserver.port=7070 -jar $REMOTE_SERVER_DIR/shine-server.jar
Restart=always
RestartSec=3
StandardOutput=append:$REMOTE_LOGS_DIR/app.log
StandardError=append:$REMOTE_LOGS_DIR/app.log
[Install]
WantedBy=multi-user.target
EOF
TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh"
ssh "$TARGET_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_UI_DIR'"
rsync -az --delete "$TMP_DIR/data/" "$TARGET_HOST:$REMOTE_DATA_DIR/"
rsync -az --timeout=120 "$LOCAL_JAR" "$TARGET_HOST:$REMOTE_SERVER_DIR/shine-server.jar"
rsync -az "$TMP_DIR/application.properties" "$TARGET_HOST:$REMOTE_SERVER_DIR/application.properties"
rsync -az "$TMP_DIR/shine-server.service" "$TARGET_HOST:/tmp/shine-server.service"
ssh "$TARGET_HOST" "set -euo pipefail; \
sudo mv -f /tmp/shine-server.service /etc/systemd/system/shine-server.service; \
sudo chown root:root /etc/systemd/system/shine-server.service; \
touch '$REMOTE_LOGS_DIR/app.log'; \
chown -R player:player '$REMOTE_SERVER_DIR'; \
sudo systemctl daemon-reload; \
sudo systemctl enable '$REMOTE_SERVICE_NAME'; \
sudo systemctl restart '$REMOTE_SERVICE_NAME'"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
TEST_HOST="${TEST_HOST:-player@93.170.12.154}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
echo "==> Подготовка Caddy для $TARGET_DOMAIN"
TEST_HOST="$TEST_HOST" \
TARGET_DOMAIN="$TARGET_DOMAIN" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test_caddyfile.sh"
echo "==> Деплой UI на $TARGET_DOMAIN"
REMOTE_HOST="$TEST_HOST" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \
EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \
TARGET_URL="https://$TARGET_DOMAIN" \
bash "$(dirname "$0")/deploy_shine-PWA.sh"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
TARGET_HOST="$TARGET_HOST" TARGET_DOMAIN="$TARGET_DOMAIN" REMOTE_UI_DIR="$REMOTE_UI_DIR" \
bash "$(dirname "$0")/scripts/install_test2_caddyfile.sh"
REMOTE_HOST="$TARGET_HOST" \
REMOTE_UI_DIR="$REMOTE_UI_DIR" \
EXPECTED_CADDY_UI_ROOT="$REMOTE_UI_DIR" \
EXPECTED_CADDY_SITE="$TARGET_DOMAIN" \
TARGET_URL="https://$TARGET_DOMAIN" \
bash "$(dirname "$0")/deploy_shine-PWA.sh"
ssh "$TARGET_HOST" "sudo chmod o+x /home/player /home/player/SHiNE '$REMOTE_UI_DIR'; \
sudo find '$REMOTE_UI_DIR' -type d -exec chmod o+rx {} +; \
sudo find '$REMOTE_UI_DIR' -type f -exec chmod o+r {} +"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cat >"$TMP_DIR/Caddyfile" <<EOF
{
auto_https disable_redirects
}
agent.shiningpeople.ru {
redir / /agent/index.html 308
redir /agent /agent/index.html 308
handle_path /agent/* {
reverse_proxy 127.0.0.1:8765
}
}
$TARGET_DOMAIN {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * $REMOTE_UI_DIR
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
}
:80 {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * $REMOTE_UI_DIR
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
}
EOF
ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null
ssh "$TARGET_HOST" "sudo -n true"
rsync -az "$TMP_DIR/Caddyfile" "$TARGET_HOST:/tmp/caddy-test2.new"
ssh "$TARGET_HOST" "set -euo pipefail; \
sudo mv -f /tmp/caddy-test2.new '$REMOTE_CADDYFILE'; \
sudo chown root:root '$REMOTE_CADDYFILE'; \
sudo caddy validate --config '$REMOTE_CADDYFILE'; \
sudo systemctl restart caddy"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
TEST_HOST="${TEST_HOST:-player@93.170.12.154}"
TARGET_DOMAIN="${TARGET_DOMAIN:-test.shineup.me}"
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cat >"$TMP_DIR/Caddyfile" <<EOF
{
auto_https disable_redirects
}
$TARGET_DOMAIN {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * $REMOTE_UI_DIR
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
}
:80 {
encode zstd gzip
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:7070
}
handle {
root * $REMOTE_UI_DIR
try_files {path} /index.html
file_server
header -Etag
header {
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
}
EOF
echo "==> Проверка SSH и sudo на $TEST_HOST"
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TEST_HOST" "echo SSH OK" >/dev/null
ssh "$TEST_HOST" "sudo -n true"
echo "==> Установка Caddy-конфига для $TARGET_DOMAIN"
scp -p "$TMP_DIR/Caddyfile" "$TEST_HOST:/tmp/shine-test-caddyfile.new" >/dev/null
ssh "$TEST_HOST" "sudo mkdir -p \"$(dirname "$REMOTE_CADDYFILE")\" && \
sudo mv -f /tmp/shine-test-caddyfile.new \"$REMOTE_CADDYFILE\" && \
sudo chown root:root \"$REMOTE_CADDYFILE\" && \
sudo caddy validate --config \"$REMOTE_CADDYFILE\" && \
sudo systemctl reload caddy"
echo "==> Caddy настроен для $TARGET_DOMAIN"
Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+74
View File
@@ -0,0 +1,74 @@
# Личные сообщения (messages-list) — дизайн v2
Экран ЛС — **списочная форма экрана «Связи»**: тип отношения читается через цвет
обода/ауры аватара и один правый статус. Тёмный космический фон + золотой header.
> Reference source: owner-approved chat visual reference; image asset is not yet stored in repository.
## Источник данных
- **Demo/lab:** мок `js/mock-data.js``directMessages` (семантические поля, цвет не хранится).
- **Прод:** те же поля придут из реальных relations (`relationFlagsForTarget` / `shineConfirmed` / `shine`).
- **Маршруты:**
- `/messages-list` — защищённый (требует сессии).
- `/messages-list/lab` — гость-демо (мок, без сети/WS, пригоден для скриншотов).
## Семантика → визуал
Решает **только** `js/pages/messages/dm-visual-resolver.js`. В данных цвет НЕ хранится.
Поля сообщения: `relationType` (contact|friend|family), `relationRole`, `isShining`,
`isConfirmed`, `hasActiveLink`, `unreadCount`, `preview`. (`toneOverride` — только для теста.)
### Цвета (значение)
| Цвет | Токен | Значение |
|------|-------|----------|
| violet | `--rel-contact` `#8C63FF` | обычный контакт (дефолт) |
| gold | `--rel-family` `#F0B82E` | семья / близкий круг / важная связь |
| celestial | `--rel-shining` `#68D8FF` | сияющий |
| emerald | `--rel-link` `#19E58A` | ТОЛЬКО активный статус «Связь» |
Обод аватара: `isShining → celestial; иначе family → gold; иначе → violet`.
**«Подтверждён» НЕ красит обод золотым** (золото = семья; подтверждение — правый статус).
### Приоритет правого статуса
`hasActiveLink → «Связь» (emerald)` > `isConfirmed → «Подтверждён» (gold shield)` > ничего.
На карточке максимум ОДИН главный статус.
### Unread
Отдельная **violet/cool сфера** (НЕ изумруд). Только при `>0`; `199`, далее `99+`.
Идёт после статуса, перед chevron.
## Матрица состояний (demo-мок покрывает все)
| # | relationType | shining | confirmed | link | unread | Обод | Правый статус | Бейдж |
|---|---|---|---|---|---|---|---|---|
| M01 | contact | | ✓ | | 0 | violet | 🛡 Подтверждён | – |
| M02 | contact | | | ✓ | 2 | violet | 🔗 Связь | 2 |
| M03 | contact | ✓ | | ✓ | 5 | celestial | 🔗 Связь | 5 |
| M04 | contact | | | | 0 | violet | — | |
| M05 | family | | ✓ | | 0 | gold | 🛡 Подтверждён | – |
| M06 | family | | ✓ | ✓ | 1 | gold | 🔗 Связь (приоритет link>confirmed) | 1 |
## Размеры
- Карточка: `min-height 92px`, `radius 26px`.
- Зазор списка: `8px` (flex-column).
- Аватар-обод `.dm-av`: `56px` (фото/инициалы — 50px внутри).
- Капсула «Связь»: высота `32px`, radius `16px`, изумрудный бордер, почти прозрачный fill.
- Header: grid `1fr auto 1fr` (бренд слева / title строго по центру / «+» справа), title `18px`.
## Сияющая сфера — связь с «Связями» (обязательно)
DM-сияние НЕ изобретает свой эффект, а **повторяет язык сияющего узла графа**:
- те же общие keyframes из `styles/network-graph.css`: `fg-shine-glow` (пульс box-shadow)
+ `fg-shine-halo` (дыхание ореола: scale/opacity);
- та же небесная палитра и тот же rim `rgba(150,240,255,0.62)`;
- тот же радиальный ореол (те же стопы градиента), `inset: -12px` — как у узла графа
(узел 58px ↔ аватар 56px, scale ≈ 1, отдельный масштабный коэффициент не нужен);
- `filter: blur(3.4px)``feGaussianBlur stdDeviation="3.4"` SVG-фильтра `#fg-shine-glow`
графа. CSS-blur используется потому, что SVG-фильтр объявлен только на странице «Связи»;
- `prefers-reduced-motion` → анимации выключаются.
Разрешён только controlled scale factor; никаких отдельных hardcoded-параметров,
если они уже существуют в визуальном языке «Связей».
## Фон
Фон `.dm-screen` (`#05070A` + орбы `dm-orbs-drift`) — утверждённая база, **НЕ меняется**.
Все эффекты редизайна ограничены `.dm-*` (карточка, обод аватара, статус, бейдж, header, «+»).
Критерий: если скрыть карточки/аватары/header/nav/статусы — фон остаётся прежним.
+6 -3
View File
@@ -29,12 +29,14 @@ import {
addSignedMessageToChat, addSignedMessageToChat,
markIncomingReadByBaseKey, markIncomingReadByBaseKey,
markOutgoingReadByBaseKey, markOutgoingReadByBaseKey,
normalizeDmChatId,
setContacts, setContacts,
} from './state.js'; } from './state.js';
import * as startView from './pages/start-view.js?v=202606142105'; import * as startView from './pages/start-view.js?v=202606142105';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240'; import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
import * as registerView from './pages/register-view.js'; import * as registerView from './pages/register-view.js?v=202606201650';
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940'; import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
@@ -43,7 +45,7 @@ import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginView from './pages/login-view.js?v=202606150110';
import * as loginCameraView from './pages/login-camera-view.js'; import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940'; import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js'; import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
import * as keyStorageView from './pages/key-storage-view.js'; import * as keyStorageView from './pages/key-storage-view.js';
import * as profileView from './pages/profile-view.js'; import * as profileView from './pages/profile-view.js';
@@ -81,6 +83,7 @@ const routes = {
'start-view': startView, 'start-view': startView,
'entry-settings-view': entrySettingsView, 'entry-settings-view': entrySettingsView,
'register-view': registerView, 'register-view': registerView,
'registration-faq-view': registrationFaqView,
'registration-payment-view': registrationPaymentView, 'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView, 'registration-keys-view': registrationKeysView,
'registration-draft-keys-view': registrationDraftKeysView, 'registration-draft-keys-view': registrationDraftKeysView,
@@ -910,7 +913,7 @@ async function init() {
const fromLogin = parsed.fromLogin || ''; const fromLogin = parsed.fromLogin || '';
const toLogin = parsed.toLogin || ''; const toLogin = parsed.toLogin || '';
const messageType = Number(parsed.messageType || 0); const messageType = Number(parsed.messageType || 0);
const chatId = messageType === 2 ? toLogin : fromLogin; const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
const text = (messageType === 1 || messageType === 2) const text = (messageType === 1 || messageType === 2)
? String(parsed.text || '') ? String(parsed.text || '')
: ''; : '';
+174 -1
View File
@@ -1,3 +1,4 @@
import { authService } from '../state.js';
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js'; import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import { import {
buildArweaveDataUrl, buildArweaveDataUrl,
@@ -9,8 +10,11 @@ import {
validateSha256Hex, validateSha256Hex,
validateAvatarSourceFile, validateAvatarSourceFile,
} from '../services/arweave-file-service.js'; } from '../services/arweave-file-service.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js'; import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
function escapeHtml(text) { function escapeHtml(text) {
return String(text || '') return String(text || '')
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -72,6 +76,8 @@ export function openAvatarWizard({
let priceInfo = null; let priceInfo = null;
let uploadedTxId = ''; let uploadedTxId = '';
let uploadedSha256Hex = ''; let uploadedSha256Hex = '';
let uploadedInfoText = '';
let freeQuotaInfo = null;
function revokePreviewUrl() { function revokePreviewUrl() {
if (!lastPreviewUrl) return; if (!lastPreviewUrl) return;
@@ -105,10 +111,11 @@ export function openAvatarWizard({
<div class="modal" data-avatar-wizard-modal="true"> <div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card"> <div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3> <h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave или загрузить новый файл.</p> <p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
<div class="avatar-wizard-choice-grid"> <div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button> <button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button> <button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button> <button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div> </div>
</div> </div>
@@ -121,6 +128,7 @@ export function openAvatarWizard({
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve)); root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput); root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); }); root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
}; };
const showStepExistingInput = () => { const showStepExistingInput = () => {
@@ -339,6 +347,7 @@ export function openAvatarWizard({
uploadBtn.disabled = true; uploadBtn.disabled = true;
try { try {
uploadedInfoText = '';
const uploaded = await uploadArweaveFile({ const uploaded = await uploadArweaveFile({
gateway: cleanGateway, gateway: cleanGateway,
jwk: walletCtx?.jwk, jwk: walletCtx?.jwk,
@@ -363,6 +372,169 @@ export function openAvatarWizard({
}); });
}; };
const showStepFreeLimitExhausted = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Лимит исчерпан</h3>
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepFreeUploadForm = () => {
if (closed) return;
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
const limit = Number(freeQuotaInfo?.limit || 3);
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Залить аватар бесплатно</h3>
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-free-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
optimized = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
const selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
if (Number(optimized?.file?.size || 0) > maxBytes) {
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
}
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
`;
uploadBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
uploadBtn.disabled = true;
try {
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
const uploaded = await authService.uploadTestFreeAvatar({
contentType: optimized.contentType,
fileBytesBase64: bytesToBase64(fileBytes),
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
});
uploadedTxId = String(uploaded.txId || '').trim();
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
if (!uploadedTxId) {
throw new Error('Сервер не вернул Transaction ID.');
}
showStepUploaded();
} catch (error) {
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
uploadBtn.disabled = false;
}
});
};
const showStepFreeUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
} catch (error) {
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
return;
}
if (!freeQuotaInfo?.enabled) {
setNodeText(loadingEl, '');
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
return;
}
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
showStepFreeLimitExhausted();
return;
}
showStepFreeUploadForm();
};
const showStepUploaded = () => { const showStepUploaded = () => {
if (closed) return; if (closed) return;
root.innerHTML = ` root.innerHTML = `
@@ -373,6 +545,7 @@ export function openAvatarWizard({
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p> <p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p> <p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p> <p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
<p class="avatar-wizard-error" data-error="true"></p> <p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions"> <div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button> <button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
+17 -8
View File
@@ -2,14 +2,23 @@ import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js'; import { openAuthRequiredModal } from '../services/auth-required-modal.js';
// iconImg — путь к неоновой PNG (если есть, рисуем картинку вместо эмодзи); glow — цвет доп.свечения
// активной/нажатой вкладки (var --tab-glow); hero — «герой»-вкладка (крупнее/ярче, всегда светится).
// Пока подключена только «Связи»; остальные 4 — эмодзи до подготовки ассетов (имена подставлю).
const ITEMS = [ const ITEMS = [
{ pageId: 'messages-list', label: 'личные', icon: '💬' }, { pageId: 'messages-list', label: 'Личные', icon: '💬', iconImg: '/assets/icon_lichnye.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' }, { pageId: 'channels-list', label: 'Каналы', icon: '📢', iconImg: '/assets/icon_kanaly.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸' }, { pageId: 'network-view', label: 'Связи', icon: '🕸', iconImg: '/assets/icon_svyazi.png', glow: 'rgba(0, 229, 255, .6)', hero: true },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔', iconImg: '/assets/icon_uvedomleniya.png', glow: 'rgba(0, 229, 255, .6)' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' }, { pageId: 'profile-view', label: 'Профиль', icon: '👤', iconImg: '/assets/icon_profil.png', glow: 'rgba(0, 229, 255, .6)' },
]; ];
function iconHtml(item) {
return item.iconImg
? `<img class="toolbar-icon-img" src="${item.iconImg}" alt="" aria-hidden="true" style="--tab-glow:${item.glow}" />`
: `<span>${item.icon}</span>`;
}
function getTotalUnreadMessages() { function getTotalUnreadMessages() {
const chats = Object.values(state.chats || {}); const chats = Object.values(state.chats || {});
let total = 0; let total = 0;
@@ -62,10 +71,10 @@ export function renderToolbar(currentPageId, navigate) {
const isProfile = item.pageId === 'profile-view'; const isProfile = item.pageId === 'profile-view';
const isMessages = item.pageId === 'messages-list'; const isMessages = item.pageId === 'messages-list';
const isNetwork = item.pageId === 'network-view'; const isNetwork = item.pageId === 'network-view';
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}`; btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
if (isProfile) { if (isProfile) {
btn.innerHTML = ` btn.innerHTML = `
<span>${item.icon}</span> ${iconHtml(item)}
<span class="toolbar-label-wrap"> <span class="toolbar-label-wrap">
<span>${item.label}</span> <span>${item.label}</span>
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown"> <span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
@@ -75,7 +84,7 @@ export function renderToolbar(currentPageId, navigate) {
</span> </span>
`; `;
} else { } else {
btn.innerHTML = `<span>${item.icon}</span><span>${item.label}</span>`; btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
} }
if (isMessages && unreadTotal > 0) { if (isMessages && unreadTotal > 0) {
const badge = document.createElement('span'); const badge = document.createElement('span');
+18 -165
View File
@@ -39,39 +39,25 @@ export const deviceSessions = [
}, },
]; ];
// Экран «Личные сообщения» — списочная форма «Связей». Храним СЕМАНТИКУ, не цвет.
// Цвет/режим вычисляет js/pages/messages/dm-visual-resolver.js (resolveDmVisualState):
// relationType: 'contact' | 'friend' | 'family' (family → золотой обод)
// relationRole: 'parent'|'child'|'sibling'|'spouse'|null
// isShining: true → небесный (celestial) обод/свечение (важнее relationType)
// isConfirmed: true → статус доверия «Подтверждён» (золотой shield) — НЕ красит обод
// hasActiveLink: true → статус «Связь» (изумруд) — приоритетнее «Подтверждён»
// unreadCount: number; preview: string
// toneOverride: 'default'|'family'|'shining' — ТОЛЬКО для тестового мока, в проде не использовать
// (на проде поля придут из relationFlagsForTarget/shineConfirmed/shine — пока мок для оффлайн-демо)
// ЛС — мок-плейсхолдер (на проде заменяется реальными relations/chats). Поля СЕМАНТИЧЕСКИЕ
// (без хранения цвета) — визуал решает dm-visual-resolver.js. Аватары — через профиль (инициалы, пока нет фото).
export const directMessages = [ export const directMessages = [
{ { id: 'u1', name: 'Марина К.', initials: 'МК', preview: 'Вечером скину обновления по макетам.', lastMessage: 'Вечером скину обновления по макетам.', time: '15:08', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 },
id: 'u1', { id: 'u2', login: 'ilya', name: 'Илья П.', initials: 'ИП', preview: 'Спасибо, уже проверяю!', lastMessage: 'Спасибо, уже проверяю!', time: '14:31', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: true, unreadCount: 2, connectedVia: [{ login: 'pavel', name: 'Павел С.' }] },
name: 'Марина К.', { id: 'u3', login: 'elena', name: 'Елена Д.', initials: 'ЕД', preview: 'Тестовый стенд снова доступен.', lastMessage: 'Тестовый стенд снова доступен.', time: '13:02', relationType: 'contact', relationRole: null, isShining: true, isConfirmed: false, hasActiveLink: true, unreadCount: 5, connectedVia: [{ login: 'pavel', name: 'Павел С.' }, { login: 'marina', name: 'Марина К.' }] },
initials: 'МК', { id: 'u4', name: 'Никита О.', initials: 'НО', preview: 'Отлично, давай так и сделаем.', lastMessage: 'Отлично, давай так и сделаем.', time: 'вчера', relationType: 'contact', relationRole: null, isShining: false, isConfirmed: false, hasActiveLink: false, unreadCount: 0 },
lastMessage: 'Вечером скину обновления по макетам.', { id: 'u6', login: 'pavel', name: 'Павел С.', initials: 'ПС', preview: 'Семейный архив обновил.', lastMessage: 'Семейный архив обновил.', time: 'вчера', relationType: 'family', relationRole: 'parent', isShining: false, isConfirmed: true, hasActiveLink: false, unreadCount: 0 },
time: '15:08', { id: 'u7', login: 'anya', name: 'Аня В.', initials: 'АВ', preview: 'Семейный чат: жду в 19:00.', lastMessage: 'Семейный чат: жду в 19:00.', time: 'пн', relationType: 'family', relationRole: 'sibling', isShining: false, isConfirmed: true, hasActiveLink: true, unreadCount: 1, connectedVia: [{ login: 'marina', name: 'Марина К.' }] },
unread: 2,
},
{
id: 'u2',
name: 'Илья П.',
initials: 'ИП',
lastMessage: 'Спасибо, уже проверяю!',
time: '14:31',
unread: 0,
},
{
id: 'u3',
name: 'Елена Д.',
initials: 'ЕД',
lastMessage: 'Тестовый стенд снова доступен.',
time: '13:02',
unread: 5,
},
{
id: 'u4',
name: 'Никита О.',
initials: 'НО',
lastMessage: 'Отлично, давай так и сделаем.',
time: 'вчера',
unread: 0,
},
]; ];
export const contactDirectory = [ export const contactDirectory = [
@@ -271,136 +257,3 @@ export const notifications = {
], ],
}; };
export const networkGraph = {
center: { id: 'me', name: 'Вы', initials: 'ВЫ', x: 50, y: 50 },
peers: [
{ id: 'p1', name: 'Марина', initials: 'МК', x: 20, y: 24 },
{ id: 'p2', name: 'Илья', initials: 'ИП', x: 80, y: 22 },
{ id: 'p3', name: 'Елена', initials: 'ЕД', x: 18, y: 78 },
{ id: 'p4', name: 'Никита', initials: 'НО', x: 82, y: 76 },
],
};
// Мок интерактивной карты связей в форме ТЗ (focusUser + connections[]).
// Используется лабораторным режимом `network-view/lab` для проверки физики/центрирования.
// relationType: family | friend | business | contact; connectionStrength: 0..1 (сильнее → ближе к центру);
// status: 'shining' даёт эффект свечения; hasOwnConnections — есть ли у узла свои связи (для глубины).
export const networkGraphMock = {
focusUser: { id: 'u_100', login: 'ivan', name: 'Иван', avatar: 'url_to_image', status: 'shining' },
connections: [
{ id: 'u_101', login: 'alisa', name: 'Алиса', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.95, hasOwnConnections: true, status: 'shining' },
{ id: 'u_102', login: 'pavel', name: 'Павел', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.45, hasOwnConnections: false },
{ id: 'u_103', login: 'marina', name: 'Марина', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.8, hasOwnConnections: true },
{ id: 'u_104', login: 'ilya', name: 'Илья', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.6, hasOwnConnections: true },
{ id: 'u_105', login: 'elena', name: 'Елена', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.88, hasOwnConnections: false },
{ id: 'u_106', login: 'nikita', name: 'Никита', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.3, hasOwnConnections: false },
{ id: 'u_107', login: 'oleg', name: 'Олег', avatar: 'url_to_image', relationType: 'business', connectionStrength: 0.55, hasOwnConnections: true, status: 'shining' },
{ id: 'u_108', login: 'sveta', name: 'Света', avatar: 'url_to_image', relationType: 'friend', connectionStrength: 0.7, hasOwnConnections: false },
{ id: 'u_109', login: 'dmitry', name: 'Дмитрий', avatar: 'url_to_image', relationType: 'contact', connectionStrength: 0.4, hasOwnConnections: true },
{ id: 'u_110', login: 'anna', name: 'Анна', avatar: 'url_to_image', relationType: 'family', connectionStrength: 0.92, hasOwnConnections: false },
],
};
// Связанный мульти-пользовательский граф для лаборатории (network-view/lab):
// у каждого пользователя свой набор связей, тап по узлу переключает карту на его сеть.
// Сияющими считаем ivan/alisa/oleg — у них статус подсвечивается и в их карточках у других.
const NETWORK_NAMES = {
ivan: 'Иван', alisa: 'Алиса', pavel: 'Павел', elena: 'Елена', dmitry: 'Дмитрий',
oleg: 'Олег', nina: 'Нина', marina: 'Марина', sveta: 'Света', kirill: 'Кирилл',
};
const NETWORK_SHINING = new Set(['ivan', 'alisa', 'oleg', 'marina', 'nina']);
// Тестовые аватарки-фото (реальные лица по сид-номеру pravatar) — только для лаборатории.
// Если сети нет — узлы мягко падают на инициалы (img.onerror).
const NETWORK_PHOTOS = {
ivan: 'https://i.pravatar.cc/150?img=12', alisa: 'https://i.pravatar.cc/150?img=5',
pavel: 'https://i.pravatar.cc/150?img=13', elena: 'https://i.pravatar.cc/150?img=9',
dmitry: 'https://i.pravatar.cc/150?img=33', oleg: 'https://i.pravatar.cc/150?img=52',
nina: 'https://i.pravatar.cc/150?img=47', marina: 'https://i.pravatar.cc/150?img=44',
sveta: 'https://i.pravatar.cc/150?img=24', kirill: 'https://i.pravatar.cc/150?img=60',
};
function networkConn(login, relationType, connectionStrength) {
return {
id: login,
login,
name: NETWORK_NAMES[login] || login,
avatar: null,
photo: NETWORK_PHOTOS[login] || null,
relationType,
connectionStrength,
hasOwnConnections: true,
status: NETWORK_SHINING.has(login) ? 'shining' : '',
};
}
function networkPerson(login, connections) {
return {
focusUser: {
id: login,
login,
name: NETWORK_NAMES[login] || login,
avatar: null,
photo: NETWORK_PHOTOS[login] || null,
status: NETWORK_SHINING.has(login) ? 'shining' : '',
},
connections,
};
}
export const networkGraphUsers = {
ivan: networkPerson('ivan', [
networkConn('alisa', 'friend', 0.9),
networkConn('pavel', 'friend', 0.7),
networkConn('elena', 'family', 0.95),
networkConn('dmitry', 'family', 0.95),
networkConn('oleg', 'business', 0.5),
networkConn('nina', 'contact', 0.35),
networkConn('kirill', 'friend', 0.6),
]),
alisa: networkPerson('alisa', [
networkConn('ivan', 'friend', 0.9),
networkConn('marina', 'friend', 0.8),
networkConn('sveta', 'contact', 0.4),
networkConn('elena', 'contact', 0.3),
]),
pavel: networkPerson('pavel', [
networkConn('ivan', 'friend', 0.7),
networkConn('oleg', 'business', 0.6),
networkConn('kirill', 'friend', 0.5),
]),
elena: networkPerson('elena', [
networkConn('ivan', 'family', 0.95),
networkConn('dmitry', 'family', 0.9),
networkConn('alisa', 'contact', 0.3),
]),
dmitry: networkPerson('dmitry', [
networkConn('ivan', 'family', 0.95),
networkConn('elena', 'family', 0.9),
networkConn('pavel', 'business', 0.4),
]),
oleg: networkPerson('oleg', [
networkConn('pavel', 'business', 0.6),
networkConn('ivan', 'business', 0.5),
networkConn('nina', 'contact', 0.45),
]),
nina: networkPerson('nina', [
networkConn('ivan', 'contact', 0.35),
networkConn('oleg', 'contact', 0.45),
networkConn('sveta', 'friend', 0.5),
]),
marina: networkPerson('marina', [
networkConn('alisa', 'friend', 0.8),
networkConn('sveta', 'friend', 0.7),
networkConn('kirill', 'contact', 0.4),
]),
sveta: networkPerson('sveta', [
networkConn('marina', 'friend', 0.7),
networkConn('alisa', 'contact', 0.4),
networkConn('nina', 'friend', 0.5),
]),
kirill: networkPerson('kirill', [
networkConn('ivan', 'friend', 0.6),
networkConn('pavel', 'friend', 0.5),
networkConn('marina', 'contact', 0.4),
]),
};
+11 -5
View File
@@ -10,6 +10,7 @@ import {
markChatRead, markChatRead,
markOutgoingSent, markOutgoingSent,
markReadReceiptSentByBaseKey, markReadReceiptSentByBaseKey,
normalizeDmChatId,
authService, authService,
setContacts, setContacts,
state, state,
@@ -71,7 +72,7 @@ function openMessageActionsMenu({
const menuId = `chat-message-actions-menu-${Date.now()}`; const menuId = `chat-message-actions-menu-${Date.now()}`;
root.innerHTML = ` root.innerHTML = `
<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-dialog-card 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> <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>' : ''}
@@ -334,11 +335,12 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
} }
export function render({ navigate, route }) { export function render({ navigate, route }) {
const chatId = route.params.chatId || 'u1'; const routeChatId = route.params.chatId || 'u1';
const contact = directMessages.find((d) => d.id === chatId) || { const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
id: chatId, id: chatId,
name: chatId, name: String(routeChatId || chatId),
initials: (chatId[0] || '?').toUpperCase(), initials: (String(routeChatId || chatId)[0] || '?').toUpperCase(),
}; };
const screen = document.createElement('section'); const screen = document.createElement('section');
@@ -579,6 +581,10 @@ export function render({ navigate, route }) {
} }
renderLog(log, chatId, { onOpenActions: handleOpenActions }); renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessage(log);
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 90);
window.setTimeout(() => scrollToLatestMessage(log), 220);
addAppLogEntry({ addAppLogEntry({
level: 'info', level: 'info',
source: 'outgoing-dm', source: 'outgoing-dm',
+47 -16
View File
@@ -61,9 +61,11 @@ function createSearchAvatar(login) {
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-search-screen'; screen.className = 'stack dm-screen dm-search-screen';
let searchTimer = 0;
let searchSeq = 0;
const input = document.createElement('input'); const input = document.createElement('input');
input.className = 'input dm-input'; input.className = 'input dm-input contact-search-input';
input.type = 'text'; input.type = 'text';
input.name = 'contact'; input.name = 'contact';
input.placeholder = 'Введите начало логина'; input.placeholder = 'Введите начало логина';
@@ -71,26 +73,28 @@ export function render({ navigate }) {
input.maxLength = 80; input.maxLength = 80;
const resultsCard = document.createElement('section'); const resultsCard = document.createElement('section');
resultsCard.className = 'card stack dm-dialog-card'; resultsCard.className = 'card stack contact-search-results-card';
resultsCard.hidden = true; resultsCard.hidden = true;
const status = document.createElement('p'); const status = document.createElement('p');
status.className = 'meta-muted'; status.className = 'contact-search-results-title';
const resultsList = document.createElement('div'); const resultsList = document.createElement('div');
resultsList.className = 'stack dm-list'; resultsList.className = 'stack dm-list';
const renderResults = (matches, query) => { const renderResults = (matches, query) => {
resultsList.innerHTML = ''; resultsList.innerHTML = '';
resultsCard.hidden = false;
if (!query.trim()) { if (!query.trim()) {
status.textContent = 'Введите начало логина пользователя.'; status.textContent = '';
resultsCard.hidden = true;
return; return;
} }
resultsCard.hidden = false;
if (!matches.length) { if (!matches.length) {
status.textContent = 'Совпадений не найдено.'; status.textContent = 'Найдено пользователей: 0';
return; return;
} }
@@ -101,11 +105,10 @@ export function render({ navigate }) {
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const avatarEl = createSearchAvatar(login); const avatarEl = createSearchAvatar(login);
row.innerHTML = ` row.innerHTML = `
<div> <div class="contact-search-result-main">
<strong>${login}</strong> <strong class="dm-row-title">${login}</strong>
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
</div> </div>
<div class="meta-muted">Профиль</div> <span class="dm-chevron" aria-hidden="true"></span>
`; `;
row.prepend(avatarEl); row.prepend(avatarEl);
row.addEventListener('click', () => { row.addEventListener('click', () => {
@@ -115,12 +118,9 @@ export function render({ navigate }) {
}); });
}; };
const searchButton = document.createElement('button'); const runSearch = async () => {
searchButton.className = 'primary-btn dm-send-btn';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
const query = input.value.trim(); const query = input.value.trim();
const seq = ++searchSeq;
if (!query) { if (!query) {
renderResults([], ''); renderResults([], '');
return; return;
@@ -128,11 +128,38 @@ export function render({ navigate }) {
try { try {
const logins = await authService.searchUsers(query); const logins = await authService.searchUsers(query);
if (seq !== searchSeq) return;
renderResults((logins || []).slice(0, 5), query); renderResults((logins || []).slice(0, 5), query);
} catch (e) { } catch (e) {
if (seq !== searchSeq) return;
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`; status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
resultsCard.hidden = false; resultsCard.hidden = false;
resultsList.innerHTML = '';
} }
};
const scheduleSearch = () => {
if (searchTimer) window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
searchTimer = 0;
void runSearch();
}, 2000);
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn dm-send-btn';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
if (searchTimer) {
window.clearTimeout(searchTimer);
searchTimer = 0;
}
await runSearch();
});
input.addEventListener('input', () => {
scheduleSearch();
}); });
const controls = document.createElement('div'); const controls = document.createElement('div');
@@ -140,7 +167,7 @@ export function render({ navigate }) {
controls.append(searchButton); controls.append(searchButton);
const formCard = document.createElement('section'); const formCard = document.createElement('section');
formCard.className = 'card stack dm-dialog-card'; formCard.className = 'card stack contact-search-form-card';
formCard.append(input, controls); formCard.append(input, controls);
resultsCard.append(status, resultsList); resultsCard.append(status, resultsList);
@@ -154,5 +181,9 @@ export function render({ navigate }) {
resultsCard, resultsCard,
); );
screen.cleanup = () => {
if (searchTimer) window.clearTimeout(searchTimer);
};
return screen; return screen;
} }
+7 -2
View File
@@ -60,7 +60,7 @@ function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expi
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack auth-screen auth-screen--lower';
let pollTimer = 0; let pollTimer = 0;
let countdownTimer = 0; let countdownTimer = 0;
let activePairingId = ''; let activePairingId = '';
@@ -77,6 +77,10 @@ export function render({ navigate }) {
}), }),
); );
const panel = document.createElement('section');
panel.className = 'login-panel login-panel--wide stack';
panel.innerHTML = '<h1 class="login-panel-title">Войти через другое устройство</h1>';
const formCard = document.createElement('div'); const formCard = document.createElement('div');
formCard.className = 'card stack'; formCard.className = 'card stack';
formCard.innerHTML = ` formCard.innerHTML = `
@@ -387,6 +391,7 @@ export function render({ navigate }) {
resultActions.append(cancelBtn); resultActions.append(cancelBtn);
resultWrap.append(resultActions); resultWrap.append(resultActions);
screen.append(formCard, status, resultWrap); panel.append(formCard, status, resultWrap);
screen.append(panel);
return screen; return screen;
} }
+142 -20
View File
@@ -7,6 +7,55 @@ import {
state, state,
} from '../state.js'; } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Можно вводить любые слова на любых языках. Можно заполнить не все 12 полей. В конце они просто склеиваются в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false }; export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
@@ -19,6 +68,9 @@ export function render({ navigate }) {
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'card stack'; form.className = 'card stack';
let passwordMode = String(state.loginDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
const loginInput = document.createElement('input'); const loginInput = document.createElement('input');
loginInput.className = 'input'; loginInput.className = 'input';
loginInput.type = 'text'; loginInput.type = 'text';
@@ -35,38 +87,102 @@ export function render({ navigate }) {
passwordInput.autocomplete = 'new-password'; passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off'; passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false; passwordInput.spellcheck = false;
passwordInput.value = state.loginDraft.password; passwordInput.maxLength = PASSWORD_MAX_LENGTH;
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)'; passwordInput.value = passwordMode === 'single' ? state.loginDraft.password : '';
passwordInput.placeholder = 'Введите пароль';
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
syncDraftState();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const hint = document.createElement('p'); const hint = document.createElement('p');
hint.className = 'meta-muted'; hint.className = 'meta-muted';
hint.textContent = 'Введите логин. Пароль может быть пустым. На следующем шаге сохраните ключи на устройстве.'; hint.textContent = 'Введите логин. На следующем шаге сохраните ключи на устройстве.';
const advanced = document.createElement('details');
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: логин нормализуется как trim().toLowerCase(). При непустом пароле используется Argon2id, затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">Если пароль пустой используется прежний детерминированный режим совместимости.</p>
<p class="meta-muted">Для тестов можно оставить пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MiB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
const status = document.createElement('p'); const status = document.createElement('p');
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.style.display = 'none'; status.style.display = 'none';
const testLoginsHint = document.createElement('p'); let passwordField = null;
testLoginsHint.className = 'meta-muted'; const passwordLengthText = document.createElement('p');
testLoginsHint.textContent = 'Основные тестовые логины: 1, 2, 3 (вход без пароля).'; passwordLengthText.className = 'status-line';
function getCurrentPassword() {
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
}
function syncDraftState() {
state.loginDraft.login = loginInput.value.trim();
state.loginDraft.passwordMode = passwordMode;
state.loginDraft.passwordWords = normalizePasswordWords(passwordWords);
state.loginDraft.password = getCurrentPassword();
}
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
updateWordsPreview();
}
form.innerHTML = ` form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label> <label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label> <label class="stack"><span class="field-label">Пароль</span></label>
`; `;
form.children[0].append(loginInput); form.children[0].append(loginInput);
form.children[1].append(passwordInput); passwordField = form.children[1];
form.append(hint, advanced, status, testLoginsHint); passwordField.append(passwordInput);
form.append(passwordModeToggle, wordsSection, passwordLengthText, hint, status);
updatePasswordModeVisibility();
syncDraftState();
loginInput.addEventListener('input', syncDraftState);
passwordInput.addEventListener('input', syncDraftState);
passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
syncDraftState();
});
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';
@@ -83,14 +199,18 @@ export function render({ navigate }) {
enterButton.textContent = 'Войти'; enterButton.textContent = 'Войти';
enterButton.addEventListener('click', async () => { enterButton.addEventListener('click', async () => {
status.style.display = 'none'; status.style.display = 'none';
state.loginDraft.login = loginInput.value.trim(); syncDraftState();
state.loginDraft.password = passwordInput.value;
if (!state.loginDraft.login) { if (!state.loginDraft.login) {
status.textContent = 'Введите логин.'; status.textContent = 'Введите логин.';
status.style.display = ''; status.style.display = '';
return; return;
} }
if (state.loginDraft.password.length > PASSWORD_MAX_LENGTH) {
status.textContent = `Пароль слишком длинный. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
status.style.display = '';
return;
}
setAuthBusy(true); setAuthBusy(true);
setAuthError(''); setAuthError('');
@@ -103,6 +223,8 @@ export function render({ navigate }) {
state.registrationDraft.flowType = 'login'; state.registrationDraft.flowType = 'login';
state.registrationDraft.login = result.login; state.registrationDraft.login = result.login;
state.registrationDraft.password = state.loginDraft.password; state.registrationDraft.password = state.loginDraft.password;
state.registrationDraft.passwordMode = state.loginDraft.passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(state.loginDraft.passwordWords);
state.registrationDraft.sessionId = result.sessionId; state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd; state.registrationDraft.storagePwd = result.storagePwd;
state.registrationDraft.pendingKeyBundle = result.keyBundle; state.registrationDraft.pendingKeyBundle = result.keyBundle;
+10 -12
View File
@@ -4,29 +4,23 @@ export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome:
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack auth-screen auth-screen--lower';
const cameraButton = document.createElement('button');
cameraButton.className = 'primary-btn';
cameraButton.type = 'button';
cameraButton.textContent = 'Отсканировать QR-код';
cameraButton.addEventListener('click', () => navigate('login-camera-view'));
const loginButton = document.createElement('button'); const loginButton = document.createElement('button');
loginButton.className = 'ghost-btn'; loginButton.className = 'ghost-btn';
loginButton.type = 'button'; loginButton.type = 'button';
loginButton.textContent = 'Войти по логину'; loginButton.textContent = 'Войти по паролю';
loginButton.addEventListener('click', () => navigate('login-password-view')); loginButton.addEventListener('click', () => navigate('login-password-view'));
const otherDeviceButton = document.createElement('button'); const otherDeviceButton = document.createElement('button');
otherDeviceButton.className = 'text-btn'; otherDeviceButton.className = 'ghost-btn';
otherDeviceButton.type = 'button'; otherDeviceButton.type = 'button';
otherDeviceButton.textContent = 'Войти через другое устройство'; otherDeviceButton.textContent = 'Войти через другое устройство';
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view')); otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-actions login-actions-wide'; actions.className = 'auth-actions login-actions-wide';
actions.append(cameraButton, loginButton, otherDeviceButton); actions.append(loginButton, otherDeviceButton);
const backButton = document.createElement('button'); const backButton = document.createElement('button');
backButton.className = 'ghost-btn'; backButton.className = 'ghost-btn';
@@ -34,13 +28,17 @@ export function render({ navigate }) {
backButton.textContent = 'Назад'; backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view')); backButton.addEventListener('click', () => navigate('start-view'));
const panel = document.createElement('section');
panel.className = 'login-panel stack';
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
panel.append(actions, backButton);
screen.append( screen.append(
renderHeader({ renderHeader({
title: 'Войти', title: 'Войти',
leftAction: { label: '←', onClick: () => navigate('start-view') }, leftAction: { label: '←', onClick: () => navigate('start-view') },
}), }),
actions, panel,
backButton,
); );
return screen; return screen;
+38 -19
View File
@@ -1,8 +1,8 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js'; import { directMessages } from '../mock-data.js';
import { import {
getChatMessages, getChatMessages,
isSessionInvalidError, isSessionInvalidError,
normalizeDmChatId,
setContacts, setContacts,
state, state,
terminateCurrentSession, terminateCurrentSession,
@@ -66,7 +66,7 @@ function createDmAvatar(login) {
function formatChatRowTime(ts) { function formatChatRowTime(ts) {
const value = Number(ts || 0); const value = Number(ts || 0);
if (!Number.isFinite(value) || value <= 0) return '-'; if (!Number.isFinite(value) || value <= 0) return '';
return new Intl.DateTimeFormat('ru-RU', { return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
@@ -75,17 +75,29 @@ function formatChatRowTime(ts) {
}).format(new Date(value)); }).format(new Date(value));
} }
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen'; screen.className = 'stack dm-screen dm-list-screen';
const login = String(state.session.login || '').trim();
screen.append( const head = document.createElement('header');
renderHeader({ head.className = 'dm-head';
title: 'Личные сообщения', head.innerHTML = `
leftLabel: String(state.session.login || '').trim(), <div class="dm-head-brand">
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }], <div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
}), <div class="dm-head-id">
); <span class="dm-head-name">${login}</span>
</div>
</div>
<h1 class="dm-head-title">Контакты</h1>
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
`;
head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view'));
const divider = document.createElement('div');
divider.className = 'dm-divider';
const list = document.createElement('div'); const list = document.createElement('div');
list.className = 'stack dm-list'; list.className = 'stack dm-list';
@@ -95,21 +107,27 @@ export function render({ navigate }) {
row.className = 'list-item dm-dialog-card'; row.className = 'list-item dm-dialog-card';
const avatarEl = createDmAvatar(item.id); const avatarEl = createDmAvatar(item.id);
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div');
avatarWrap.className = 'dm-av dm-av--default';
avatarWrap.append(avatarEl);
row.innerHTML = ` row.innerHTML = `
<div class="dm-row-main"> <div class="dm-row-main">
<div class="dm-row-title-wrap"> <div class="dm-row-titleline dm-row-titlewrap">
<strong class="dm-row-title">${item.name}</strong> <strong class="dm-row-title">${item.name}</strong>
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''} ${item.notInContacts ? '<span class="dm-contact-note">не в контактах</span>' : ''}
</div> </div>
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p> <p class="dm-row-last-message">${item.lastMessage}</p>
</div> </div>
<div class="dm-row-meta-col"> <div class="dm-row-meta-col">
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'} ${item.unread ? `<span class="dm-unread-badge">${item.unread > 99 ? '99+' : item.unread}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
<span class="meta-muted dm-row-time">${item.time}</span> <div class="dm-row-meta-line">
${item.time ? `<span class="dm-row-time">${item.time}</span>` : '<span class="dm-row-time dm-row-time--empty"></span>'}
<span class="dm-chevron">${SVG_CHEVRON}</span>
</div>
</div> </div>
`; `;
row.prepend(avatarEl); row.prepend(avatarWrap);
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`)); row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`));
return row; return row;
} }
@@ -122,12 +140,13 @@ export function render({ navigate }) {
const contactRows = contacts.map((login) => { const contactRows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase()); const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const chat = getChatMessages(login); const canonicalLogin = normalizeDmChatId(login);
const chat = getChatMessages(canonicalLogin);
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); const lastTimeMs = Number(lastChat?.createdAtMs || 0);
return { return {
id: login, id: canonicalLogin,
name: preview?.name || login, name: preview?.name || login,
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
time: formatChatRowTime(lastTimeMs), time: formatChatRowTime(lastTimeMs),
@@ -206,7 +225,7 @@ export function render({ navigate }) {
} }
} }
screen.append(list); screen.append(head, divider, list);
loadList(); loadList();
return screen; return screen;
} }
@@ -0,0 +1,34 @@
// Экран «Личные сообщения» — единый слой «семантика отношения → визуальное состояние».
// messages-list.js только рендерит готовый результат; здесь вся логика выбора тона/статуса/бейджа.
// Источник полей — мок directMessages (оффлайн-демо). На проде сюда же подставятся реальные
// relationFlagsForTarget / shineConfirmed / shine — UI карточек переписывать не придётся.
// Тон обода аватара. ВАЖНО: «Подтверждён» НЕ красит обод золотым (золото = семья/близкий круг).
// isShining → 'shining' (небесный) ; relationType==='family' → 'family' (золотой) ; иначе 'default' (violet).
// toneOverride — только для тестового мока (в проде не задавать).
export function resolveAvatarTone(msg) {
const o = String(msg?.toneOverride || '').trim();
if (o === 'default' || o === 'family' || o === 'shining') return o;
if (msg?.isShining) return 'shining';
if (msg?.relationType === 'family') return 'family';
return 'default';
}
// Непрочитанные: показываем только при >0; 1–99, далее «99+». Отдельная violet-сфера (НЕ изумруд).
export function resolveUnreadStyle(msg) {
const n = Math.max(0, Math.trunc(Number(msg?.unreadCount ?? msg?.unread ?? 0)) || 0);
if (n <= 0) return null;
return { count: n, label: n > 99 ? '99+' : String(n) };
}
// Итоговое визуальное состояние карточки.
export function resolveDmVisualState(msg) {
const via = Array.isArray(msg?.connectedVia) && msg.connectedVia.length ? msg.connectedVia : null;
return {
tone: resolveAvatarTone(msg), // 'default' | 'family' | 'shining'
shining: Boolean(msg?.isShining),
confirmed: Boolean(msg?.isConfirmed), // галочка ✓ у имени (без слова «Подтверждён»)
via, // путь «через кого»: [{name, photo}, …] | null
unread: resolveUnreadStyle(msg), // { count, label } | null
};
}
-5
View File
@@ -2,7 +2,6 @@ import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js'; import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileRoute } from '../services/shine-routes.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { renderNetworkLab } from './network/lab.js';
import { createForceGraph } from './network/force-graph.js'; import { createForceGraph } from './network/force-graph.js';
import { engineModelFromGraphModel } from './network/adapter.js'; import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu, relationLabelRu } from './network/node-menu.js'; import { openNodeMenu, relationLabelRu } from './network/node-menu.js';
@@ -217,10 +216,6 @@ let persistedCenterLogin = '';
let persistedCenterHistory = []; let persistedCenterHistory = [];
export function render({ navigate, route }) { export function render({ navigate, route }) {
// Лабораторный режим force-графа (Фаза 1): рендерится из мока, не трогая реальный путь ниже.
if (String(route?.params?.mode || '').trim().toLowerCase() === 'lab') {
return renderNetworkLab({ navigate });
}
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 || ''); const routeLogin = normalizeLogin(route?.params?.login || '');
if (!keepHistory) { if (!keepHistory) {
+101 -26
View File
@@ -15,6 +15,8 @@
// model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] } // model = { focusId, nodes: [{ id, login, name, avatar, relationType, strength, shining, tier }] }
import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js'; import { renderUserAvatar, buildAvatarInitials } from '../../components/avatar-image.js';
import { state } from '../../state.js';
import { buildArweaveDataUrl } from '../../services/arweave-file-service.js';
const SVGNS = 'http://www.w3.org/2000/svg'; const SVGNS = 'http://www.w3.org/2000/svg';
@@ -33,9 +35,9 @@ const SLEEP_V = 0.03; // порог суммарной |vx|+|vy| дл
const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра const INTRO_FACTOR = 0.22; // стартовый радиус пера (доля от целевого) — узлы «вылетают» из центра
const PAN_FRICTION = 0.93; // трение инерционного скролла карты const PAN_FRICTION = 0.93; // трение инерционного скролла карты
const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк) const TWEEN_MS = 560; // длительность анимации центрирования (фильтр/фолбэк)
const BLOOM_MS = 900; // длительность разлёта узлов из центра (физика выключена → ноль тряски) const BLOOM_MS = 550; // длительность разлёта узлов из центра (физика выключена → ноль тряски)
const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс const BLOOM_STAGGER = 40; // задержка между «выстреливанием» соседних узлов (волна), мс
const FOCUS_SCALE = 1.5; // базовый масштаб фокуса (CSS-дыхание колеблет ±~1.3% → 1.481.52x) const FOCUS_SCALE = 1.78; // базовый масштаб фокуса — центр крупнее (иерархия, рычаг 2; ±дыхание)
const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня const PRIMARY_SCALE = 1.0; // масштаб обычного узла 1-го уровня
const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей) const SECONDARY_SCALE = 0.72; // масштаб узлов 2-го уровня (друзья друзей)
const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap const PAN_THRESHOLD = 8; // порог смещения (px), после которого жест считается pan, а не tap
@@ -90,6 +92,11 @@ const RELATION_COLORS = {
// Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла. // Неоновый цвет центра — из него «вытекает» градиент каждой связи к цвету периферийного узла.
const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)'; const FOCUS_NEON = 'rgba(140, 240, 255, 0.95)';
// Радиус видимой сферы орба (world-единицы), синхронно с CSS `.fg-node .node-dot` = 58px → радиус 29
// (сфера орба ≈ радиусу node-dot). ЕДИНЫЙ источник радиуса для контакта линий с кромкой и раскладки детей —
// меняешь размер орба → меняй здесь и в CSS вместе, линии останутся впритык.
const ORB_R = 29;
function easeOutCubic(t) { function easeOutCubic(t) {
const x = 1 - t; const x = 1 - t;
@@ -100,6 +107,26 @@ function relationColor(relationType) {
return RELATION_COLORS[relationType] || RELATION_COLORS.contact; return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
} }
function resolveAvatarPhotoSrc(src) {
const directPhoto = String(src?.photo || '').trim();
if (directPhoto) return directPhoto;
const rawAvatar = src?.avatar;
if (!rawAvatar || rawAvatar === 'url_to_image') return null;
if (typeof rawAvatar === 'string') return String(rawAvatar).trim() || null;
const txId = String(rawAvatar?.ar || '').trim();
if (!txId) return null;
try {
return buildArweaveDataUrl({
gateway: state?.entrySettings?.arweaveServer,
txId,
});
} catch {
return null;
}
}
// Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие). // Однократно внедряем скрытый SVG-фильтр свечения «сияющих» узлов (мягкое гауссово размытие).
// Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается. // Применяется к псевдо-ореолу ::before, а НЕ к самой аватарке — поэтому фото не размывается.
function ensureShineFilter() { function ensureShineFilter() {
@@ -419,6 +446,39 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return wrap; return wrap;
} }
// Векторный SVG-орб (buildGlassOrb) ретайрнут 13.06 — все орбы рисует buildPngOrb (PNG-оверлей).
// A/B-вариант (ветка glass-png-overlay): орб = фото + запечённый стеклянный PNG поверх.
// Слой 1 — фото круглой маской ~78% от бокса оверлея (сидит внутри кромки); слой 2 — glass_overlay.png
// на весь бокс (альфа уже в PNG). Кодовый glow не рисуем — у картинки своё свечение запечено (нет двойного).
const GLASS_OVERLAY_SRC = '/assets/glass_overlay_faithful.png';
function buildPngOrb(src, opts) {
const o = opts || {};
const wrap = document.createElement('div');
wrap.className = 'fg-pngorb';
function makeInit() {
const d = document.createElement('div');
d.className = 'fg-pngorb-photo fg-pngorb-init';
d.textContent = String(o.initials || '').slice(0, 2);
return d;
}
let photo;
if (src) {
photo = document.createElement('img');
photo.className = 'fg-pngorb-photo';
photo.src = src; photo.alt = '';
photo.addEventListener('error', () => { try { photo.replaceWith(makeInit()); } catch (e) { /* fallback */ } });
} else {
photo = makeInit();
}
const glass = document.createElement('img');
glass.className = 'fg-pngorb-glass';
glass.src = GLASS_OVERLAY_SRC; glass.alt = '';
glass.setAttribute('aria-hidden', 'true');
wrap.append(photo, glass);
return wrap;
}
function buildNodeElement(src, isFocus, tier, dotOnly = false) { function buildNodeElement(src, isFocus, tier, dotOnly = false) {
const el = document.createElement('button'); const el = document.createElement('button');
el.type = 'button'; el.type = 'button';
@@ -447,17 +507,15 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
el.dataset.nodeId = String(src.id); el.dataset.nodeId = String(src.id);
// тестовое фото (лаборатория) — прямой URL; иначе штатный аватар (Arweave/инициалы) // Аватар = SVG-«стеклянный орб» (фото в стеклянной сфере). Хост — .node-dot (масштаб/состояния/
const avatar = src.photo // синхронизация позиций движка). Меняем ТОЛЬКО аватар; бейдж/имя/линии — как раньше.
? buildPhotoAvatar(src) const photoSrc = resolveAvatarPhotoSrc(src);
: renderUserAvatar({ const initials = buildAvatarInitials({ login: src.login || src.name || String(src.id), firstName: src.name || '' });
login: src.login || src.name || String(src.id), const dot = document.createElement('div');
firstName: src.name || '', dot.className = 'avatar node-dot fg-orb-host';
avatar: src.avatar || null, // Единый PNG-оверлей на ВСЕХ полных орбах (фокус + спутники). tier-3 точки (dotOnly) сюда не идут.
size: 'node', dot.appendChild(buildPngOrb(photoSrc, { isFocus, initials }));
title: src.name || src.login || '', el.append(dot);
});
el.append(avatar);
// Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0. // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0.
const badge = document.createElement('span'); const badge = document.createElement('span');
@@ -597,7 +655,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель // АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель
// и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей. // и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей.
const baseR = tier === 2 ? DEEP_R2 : DEEP_R3; const baseR = tier === 2 ? DEEP_R2 : DEEP_R3;
const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы) const pr = ORB_R * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
const cnt = childCountByParent.get(n.parentId) || 1; const cnt = childCountByParent.get(n.parentId) || 1;
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место) const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место)
const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите
@@ -707,7 +765,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const parent = (n.parentId && nodeById.get(n.parentId)) || focus; const parent = (n.parentId && nodeById.get(n.parentId)) || focus;
const fx = centerX + camX + parent.x * Z; const fx = centerX + camX + parent.x * Z;
const fy = centerY + camY + parent.y * Z; const fy = centerY + camY + parent.y * Z;
const fr = parent.dotRadius * parent.scale * (parent.depthScale ?? 1) * Z + 4; // радиус контакта = реальный радиус сферы орба: полный орб = ORB_R (см. renderNodes pr=ORB_R*…),
// лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) — легаси, давало разный зазор.
const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z;
const nx = tx(n); const nx = tx(n);
const ny = ty(n); const ny = ty(n);
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
@@ -724,8 +784,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const len = Math.hypot(dx, dy) || 1; const len = Math.hypot(dx, dy) || 1;
const ux = dx / len; const ux = dx / len;
const uy = dy / len; const uy = dy / len;
const nr = n.dotRadius * n.scale * (n.depthScale ?? 1) * Z + 4; const nr = (n.dotOnly ? n.dotRadius : ORB_R) * n.scale * (n.depthScale ?? 1) * Z;
// концы линии — у краёв кружков // концы линии — ровно на кромке сферы орба (радиус ORB_R для полных орбов), без зазора и без захода внутрь
const x1 = fx + ux * fr; const x1 = fx + ux * fr;
const y1 = fy + uy * fr; const y1 = fy + uy * fr;
const x2 = ex - ux * nr; const x2 = ex - ux * nr;
@@ -772,14 +832,28 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
} }
const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми)
if (n.tier >= 3) { if (n.tier >= 3) {
// 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии // 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(150, 205, 255, 0.7)" stroke-width="0.6" opacity="${(0.1 * pe * sp).toFixed(2)}" />`); if (pe > 0.02) {
if (shine) {
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
}
}
} else if (n.tier === 2) { } else if (n.tier === 2) {
// 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom) // 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
if (pe > 0.02) parts.push(`<path d="${d}" fill="none" stroke="rgba(175, 200, 235, 0.9)" stroke-width="0.8" stroke-linecap="round" opacity="${(0.14 * pe * sp).toFixed(2)}" />`); if (pe > 0.02) {
} else if (shine || n.track || onPath) { if (shine) {
// СИЯЮЩАЯ связь — плазменный композитинг: ОДИН центральный S-путь (cubic) + ТРИ наложенных слоя parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
// с ОДИНАКОВЫМ d (объём из толщины+blur, не из геометрии). Слои: широкое поле / трубка / ядро. parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
} else {
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
}
}
} else if (shine) {
// СИЯЮЩАЯ связь → цвет сияющей линии (плазма). Только сияющим — несияющие (в т.ч. активный путь
// погружения track/onPath) идут ниже в ЦВЕТ КАТЕГОРИИ. Плазма: ОДИН S-путь + ТРИ слоя на одном d.
const pnx = -uy; const pnx = -uy;
const pny = ux; // перпендикуляр к хорде const pny = ux; // перпендикуляр к хорде
const amp = Math.min(13, 5 + segLen0 * 0.05); // амплитуда S — спокойная изящная волна (∝ длине) const amp = Math.min(13, 5 + segLen0 * 0.05); // амплитуда S — спокойная изящная волна (∝ длине)
@@ -1181,8 +1255,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба
let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки) let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки)
let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс) let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс)
// Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует. // Виброотклик отключён по запросу: на экране «Связи» телефон не вибрирует ни на тапах, ни на переходах.
const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } }; // (no-op; вызовы haptic(...) ниже оставлены, но ничего не делают — легко вернуть, восстановив тело.)
const haptic = () => {};
// Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера. // Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера.
const prefetched = new Set(); const prefetched = new Set();
-341
View File
@@ -1,341 +0,0 @@
// Лабораторный режим карты связей (network-view/lab).
//
// Назначение: на мок-данных (без бэкенда) щупать физику force-графа, панорамирование,
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
// карту на сеть этого человека (как реальный путь, но локально).
//
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
// Это чисто визуальный лабораторный эксперимент на мок-данных.
import { renderHeader } from '../../components/header.js';
import { networkGraphUsers } from '../../mock-data.js';
import { createForceGraph, buildModelFromTz } from './force-graph.js';
import { openNodeMenu } from './node-menu.js';
const START_LOGIN = 'ivan';
// Фильтры слоёв — те же, что в реальном пути network-view (предикат по периферийным узлам;
// фокус виден всегда). Позволяют пощупать в лаборатории в т.ч. слой «Сияющие».
const FILTERS = {
all: { label: 'Все', pred: () => true },
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
};
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
function seed01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return ((h >>> 0) % 100000) / 100000;
}
function helpText() {
return [
'Лаборатория карты связей (мок-данные, без сервера).',
'• Тащите по экрану — карта свободно перемещается (pan).',
'• Тап по узлу — он стягивается в центр и карта перестраивается под его сеть.',
'• Тап по центральному узлу — здесь открылся бы профиль.',
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный',
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
' превращаются в аватарки. Свайп — pan.',
'',
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
].join('\n');
}
// Граф пользователя по логину; если такого нет в датасете — одинокий узел (без связей).
function graphForLogin(login) {
const key = String(login || '').trim().toLowerCase();
if (networkGraphUsers[key]) return networkGraphUsers[key];
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
}
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
function synthTier1(focusId) {
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
const out = [];
for (let i = 0; i < k; i += 1) {
const id = `${focusId}__t1_${i}`;
const s = seed01(id);
out.push({
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
avatar: null, photo: null,
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
connectionStrength: 0.5 + s * 0.4,
status: s > 0.78 ? 'shining' : '',
hasOwnConnections: true, tier: 1,
});
}
return out;
}
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
function addDeepLevels(model) {
const focusId = model.focusId;
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
const extra = [];
tier1.forEach((p) => {
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
// «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь).
const others = tier1.filter((o) => String(o.id) !== String(p.id));
const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null;
for (let i = 0; i < k2; i += 1) {
const id2 = `${p.id}__d2_${i}`;
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
// i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★)
if (i === 0 && common) {
extra.push({
id: id2, login: id2, name: common.name || common.login,
avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend',
strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true,
});
continue;
}
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
extra.push({
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
avatar: null, photo: face2, relationType: p.relationType || 'contact',
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
});
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
for (let j = 0; j < k3; j += 1) {
const id3 = `${id2}_d3_${j}`;
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
extra.push({
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
avatar: null, photo: face3, relationType: 'contact',
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
});
}
}
});
return { focusId, nodes: [...model.nodes, ...extra] };
}
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
function buildLabModel(login, deep, fromLogin) {
const tz = graphForLogin(login);
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
else tz.connections = [];
}
const base = buildModelFromTz(tz);
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
const fid = String(fromLogin);
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
if (found) {
found.track = true; // уже среди связей — просто подсветим трек
} else {
const f = graphForLogin(fromLogin).focusUser || {};
base.nodes.push({
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
photo: f.photo || null, relationType: 'friend', strength: 0.97,
shining: false, tier: 1, track: true,
});
}
}
return deep ? addDeepLevels(base) : base;
}
export function renderNetworkLab({ navigate }) {
const screen = document.createElement('section');
screen.className = 'network-screen';
const appScreenEl = document.getElementById('app-screen');
appScreenEl?.classList.add('network-scroll-lock');
const stage = document.createElement('div');
stage.className = 'network-stage fg-stage';
const header = renderHeader({
title: 'Связи · лаборатория',
leftAction: { label: '←', onClick: () => navigate('network-view') },
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
});
header.classList.add('network-header-overlay');
let centerLogin = START_LOGIN;
let deepMode = false;
// Состояние активного слоя (как в network-view): фокус всегда виден.
let activeFilter = 'all';
const filterChips = {};
function applyFilter(key) {
if (!FILTERS[key]) return;
activeFilter = key;
FILTER_ORDER.forEach((k) => {
const el = filterChips[k];
if (el) el.classList.toggle('is-active', k === activeFilter);
});
graph.setFilter(FILTERS[key].pred);
}
stage.append(header);
screen.append(stage);
const model = buildLabModel(centerLogin, deepMode);
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
const graph = createForceGraph({
stage,
model,
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
onNodeTap: (node) => {
if (deepMode) {
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
graph.diveTo(node);
return;
}
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)
const from = centerLogin;
centerLogin = node.login || node.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
},
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
// Изменение пути погружения → перерисовываем хлебные крошки (Иван › Нина › Ада).
onDiveChange: (path) => renderBreadcrumb(path),
onCenterTap: (node) => {
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
if (deepMode) { graph.collapseAll(); return; }
window.alert(`Профиль: ${node.name || node.login || node.id}`);
},
onNodeLongPress: (node, point) => openNodeMenu({
login: node.name || node.login || node.id,
relationType: node.relationType,
point,
actions: [
{ label: 'Профиль', onClick: () => window.alert(`Профиль: ${node.name || node.login}`) },
{ label: 'Написать', onClick: () => window.alert(`Написать: ${node.name || node.login}`) },
],
}),
});
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
const filterBar = document.createElement('div');
filterBar.className = 'fg-filter-bar';
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
FILTER_ORDER.forEach((key) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = `fg-filter-chip${key === activeFilter ? ' is-active' : ''}`;
chip.textContent = FILTERS[key].label;
chip.addEventListener('click', () => applyFilter(key));
filterChips[key] = chip;
filterBar.append(chip);
});
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
const deepChip = document.createElement('button');
deepChip.type = 'button';
deepChip.className = 'fg-filter-chip fg-deep-chip';
deepChip.textContent = '🌌 Вселенная';
deepChip.addEventListener('click', () => {
deepMode = !deepMode;
deepChip.classList.toggle('is-active', deepMode);
graph.setModel(buildLabModel(centerLogin, deepMode));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
});
filterBar.append(deepChip);
stage.append(filterBar);
// --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) ---
const searchWrap = document.createElement('div');
searchWrap.className = 'fg-search';
searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
const searchIco = document.createElement('span');
searchIco.className = 'fg-search-ico';
searchIco.textContent = '🔍';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = 'Найти человека…';
searchInput.setAttribute('aria-label', 'Поиск по имени');
function doSearch() {
const hit = graph.findNode(searchInput.value);
if (!hit) return;
if (deepMode) {
graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного
} else {
const from = centerLogin;
centerLogin = hit.id;
graph.setModel(buildLabModel(centerLogin, deepMode, from));
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
}
searchInput.blur();
}
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
searchWrap.append(searchIco, searchInput);
stage.append(searchWrap);
// --- Хлебные крошки: стек погружений (Иван › Нина › Ада); клик по крошке — навигация назад ---
breadcrumbEl = document.createElement('div');
breadcrumbEl.className = 'fg-breadcrumb';
breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation());
stage.append(breadcrumbEl);
// hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI)
function renderBreadcrumb(path) {
if (!breadcrumbEl) return;
breadcrumbEl.innerHTML = '';
const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении
breadcrumbEl.classList.toggle('is-open', open);
if (!open) return;
path.forEach((p, i) => {
if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = ''; breadcrumbEl.append(sep); }
const c = document.createElement('button');
c.type = 'button';
c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`;
c.textContent = p.name;
if (i < path.length - 1) {
c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); });
}
breadcrumbEl.append(c);
});
}
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
window.__fg = graph;
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
}
screen.cleanup = () => {
graph.destroy();
appScreenEl?.classList.remove('network-scroll-lock');
};
return screen;
}
-150
View File
@@ -1,150 +0,0 @@
// Автопроверки интерактивного графа связей (dev-only).
//
// Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные
// dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда
// requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя).
//
// Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }.
const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас)
export async function runNetworkSelfTest(graph, deepChipEl) {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); };
const st = () => graph.debugState();
// 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout).
if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click();
await wait(1700);
let s = st();
const focusId = s.focusId;
const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId);
const parent = tier1.find((n) => n.id === 'nina') || tier1[0];
if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); }
// === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) ===
graph.diveTo({ id: parent.id });
const framesA = graph.pumpForTest();
s = st();
const p = s.nodes.find((n) => n.id === parent.id);
const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_'));
check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`);
check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`);
check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36,
`offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`);
check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`);
// расталкивание: дети не слипаются
let minD = Infinity;
for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) {
minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y));
}
check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`);
// полукруг наружу: все дети в секторе вокруг направления от центра к родителю
const outward = Math.atan2(p.y, p.x);
const maxDev = kids.reduce((mx, k) => {
let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward);
if (d > Math.PI) d = 2 * Math.PI - d;
return Math.max(mx, d * 180 / Math.PI);
}, 0);
check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`);
// === Тест B: Spotlight открыт — путь горит, остальное тускнеет ===
const offPath = tier1.filter((n) => n.id !== parent.id);
const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; });
const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9);
check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9');
check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4');
// === Тест C: переключение веток сбрасывает прежнюю (нет накопления) ===
if (offPath.length) {
graph.diveTo({ id: offPath[0].id });
graph.pumpForTest();
s = st();
const prev = s.nodes.find((n) => n.id === parent.id);
check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`);
check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`);
}
// === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме ===
const t2withKids = st().nodes.find((n) => n.tier === 2);
if (t2withKids) {
graph.diveTo({ id: t2withKids.id });
graph.pumpForTest();
s = st();
const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_'));
const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true;
check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`);
}
// === Тест F: поиск по имени находит узел (для строки поиска + телепорта) ===
const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId);
if (named && typeof graph.findNode === 'function') {
const byId = graph.findNode(named.id);
check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`);
}
// === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) ===
if (typeof graph.getDivePath === 'function' && t2withKids) {
const path = graph.getDivePath();
const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id;
check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' ')}`);
}
// === Тест H: бейдж числа связей виден и числовой (DOM) ===
if (typeof document !== 'undefined') {
const fb = document.querySelector('.fg-node.is-focus .fg-node-badge');
const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0;
check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`);
}
// === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) ===
if (typeof document !== 'undefined') {
const commonCount = document.querySelectorAll('.fg-node.is-common').length;
check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`);
}
// === Тест J: доступность — текстовый список графа для скринридеров ===
if (typeof document !== 'undefined') {
const a11y = document.querySelector('.fg-a11y');
const liCount = a11y ? a11y.querySelectorAll('li').length : 0;
check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`);
}
// === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает ===
graph.exitDive();
graph.pumpForTest();
s = st();
const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95);
check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95');
check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`);
check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`);
// === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) ===
if (typeof document !== 'undefined') {
const flare = document.querySelectorAll('.fg-plasma-flare');
const tube = document.querySelectorAll('.fg-plasma-tube');
const core = document.querySelectorAll('.fg-plasma-core');
const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length;
const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d')
&& tube[0].getAttribute('d') === core[0].getAttribute('d');
check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`);
}
return finish(results);
}
function finish(results) {
const pass = results.filter((r) => r.pass).length;
const out = { pass, total: results.length, results };
if (typeof window !== 'undefined') window.__fgTestResults = out;
const tag = pass === results.length ? '✅ PASS' : '❌ FAIL';
// eslint-disable-next-line no-console
console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`);
results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name}${r.detail}`));
return out;
}
-2
View File
@@ -599,8 +599,6 @@ export function render({ navigate }) {
} }
async function onChangeAvatarClick() { async function onChangeAvatarClick() {
const confirmed = window.confirm('Сменить аватар?');
if (!confirmed) return;
status.className = 'status-line'; status.className = 'status-line';
status.textContent = 'Открываем мастер аватара...'; status.textContent = 'Открываем мастер аватара...';
+205 -62
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js'; import { authService, clearAuthMessages, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
import { import {
@@ -6,9 +6,59 @@ import {
formatSolanaErrorDetails, formatSolanaErrorDetails,
precheckLoginClassOnSolana, precheckLoginClassOnSolana,
} from '../services/solana-register-service.js'; } from '../services/solana-register-service.js';
import {
composePasswordFromWords,
emptyPasswordWords,
normalizePasswordWords,
PASSWORD_MAX_LENGTH,
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
function createWordsLayout({ words, onInput }) {
const section = document.createElement('div');
section.className = 'registration-words-block';
const grid = document.createElement('div');
grid.className = 'registration-words-grid';
const inputs = Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => {
const row = document.createElement('label');
row.className = 'registration-word-row';
const number = document.createElement('span');
number.className = 'registration-word-number';
number.textContent = `${index + 1}.`;
const input = document.createElement('input');
input.className = 'input registration-word-input';
input.type = 'text';
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 32;
input.value = words[index];
input.addEventListener('input', () => onInput(index, input.value));
row.append(number, input);
grid.append(row);
return input;
});
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@@ -18,6 +68,9 @@ export function render({ navigate }) {
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'card stack'; form.className = 'card stack';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
const loginInput = document.createElement('input'); const loginInput = document.createElement('input');
loginInput.className = 'input'; loginInput.className = 'input';
loginInput.type = 'text'; loginInput.type = 'text';
@@ -34,8 +87,33 @@ export function render({ navigate }) {
passwordInput.autocomplete = 'new-password'; passwordInput.autocomplete = 'new-password';
passwordInput.autocapitalize = 'off'; passwordInput.autocapitalize = 'off';
passwordInput.spellcheck = false; passwordInput.spellcheck = false;
passwordInput.value = state.registrationDraft.password; passwordInput.maxLength = PASSWORD_MAX_LENGTH;
passwordInput.placeholder = 'Введите пароль (можно оставить пустым)'; passwordInput.value = passwordMode === 'single' ? state.registrationDraft.password : '';
passwordInput.placeholder = 'Введите пароль';
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
passwordWords[index] = value;
syncDraftState();
},
});
const passwordModeToggle = document.createElement('label');
passwordModeToggle.className = 'registration-toggle';
const passwordModeCheckbox = document.createElement('input');
passwordModeCheckbox.type = 'checkbox';
passwordModeCheckbox.checked = passwordMode === 'words';
const passwordModeLabel = document.createElement('span');
passwordModeLabel.textContent = 'Представить пароль в виде 12 слов';
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const statusText = document.createElement('p'); const statusText = document.createElement('p');
statusText.className = 'meta-muted'; statusText.className = 'meta-muted';
@@ -47,35 +125,85 @@ export function render({ navigate }) {
<p class="field-label">Первый сервер SHiNE</p> <p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p> <p class="meta-muted">Сейчас вашим первым и основным сервером будет серверный аккаунт <strong>${state.entrySettings.shineServerLogin || 'shineupme'}</strong>.</p>
<p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p> <p class="meta-muted">Текущий адрес этого сервера: <strong>${state.entrySettings.shineServerHttp || 'https://shineup.me'}</strong>.</p>
<p class="meta-muted">При регистрации этот сервер будет записан в вашу PDA как первый сервер доступа.</p> <p class="meta-muted">Это первый сервер, на который вам будут писать и звонить после регистрации. Позже его можно будет сменить, а в будущем использовать и несколько серверов сразу.</p>
<p class="meta-muted">При желании его можно изменить в настройках до регистрации или позже. В будущем будет поддерживаться мультисерверная модель, но сейчас используется один основной сервер.</p>
`; `;
const faqCard = document.createElement('div');
faqCard.className = 'card stack registration-faq-card';
const faqTitle = document.createElement('p');
faqTitle.className = 'field-label';
faqTitle.textContent = 'Частые вопросы перед регистрацией';
const faqText = document.createElement('p');
faqText.className = 'meta-muted';
faqText.textContent = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.';
const faqButton = document.createElement('button');
faqButton.className = 'ghost-btn';
faqButton.type = 'button';
faqButton.textContent = 'Частые вопросы';
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
faqCard.append(faqTitle, faqText, faqButton);
const formError = document.createElement('p'); const formError = document.createElement('p');
formError.className = 'status-line is-unavailable'; formError.className = 'status-line is-unavailable';
formError.style.display = 'none'; formError.style.display = 'none';
const advanced = document.createElement('details');
advanced.className = 'card stack';
advanced.innerHTML = `
<summary>Расширенные</summary>
<p class="meta-muted">Схема derivation ключей: при непустом пароле используется Argon2id (средний профиль), затем из результата строится секрет для root/bch/dev ключей.</p>
<p class="meta-muted">В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.</p>
<p class="meta-muted">Если пароль пустой используется прежний тестовый режим совместимости (старый детерминированный вариант).</p>
<p class="meta-muted">Для тесто оставьте пустой пароль.</p>
<p class="meta-muted">Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.</p>
`;
const checkButton = document.createElement('button'); const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn'; checkButton.className = 'ghost-btn';
checkButton.type = 'button'; checkButton.type = 'button';
checkButton.textContent = 'Проверить логин'; checkButton.textContent = 'Проверить логин';
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
nextButton.textContent = 'Далее';
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'status-line';
let lastCheckedLogin = ''; let lastCheckedLogin = '';
let lastCheckedFree = false; let lastCheckedFree = false;
let lastCheckedClassName = ''; let lastCheckedClassName = '';
let generationRunId = 0; let generationRunId = 0;
function getCurrentPassword() {
return passwordMode === 'words' ? composePasswordFromWords(passwordWords) : String(passwordInput.value || '');
}
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
function updatePasswordModeVisibility() {
const wordsMode = passwordMode === 'words';
wordsSection.style.display = wordsMode ? 'grid' : 'none';
if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid';
passwordInput.style.display = wordsMode ? 'none' : '';
updateWordsPreview();
}
function syncDraftState() {
state.registrationDraft.login = String(loginInput.value.trim());
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
state.registrationDraft.password = getCurrentPassword();
}
async function runAvailabilityCheck() { async function runAvailabilityCheck() {
const login = loginInput.value.trim(); const login = loginInput.value.trim();
if (!login) { if (!login) {
@@ -87,19 +215,19 @@ export function render({ navigate }) {
if (login === lastCheckedLogin) { if (login === lastCheckedLogin) {
if (!lastCheckedFree) { if (!lastCheckedFree) {
statusText.textContent = 'Логин уже занят ❌'; statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} else if (lastCheckedClassName === 'free') { } else if (lastCheckedClassName === 'free') {
statusText.textContent = 'Логин свободен ✅'; statusText.textContent = 'Логин свободен ✅';
statusText.className = 'is-available'; statusText.className = 'status-line is-available';
} else if (lastCheckedClassName === 'premium') { } else if (lastCheckedClassName === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} else if (lastCheckedClassName === 'company') { } else if (lastCheckedClassName === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} else { } else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} }
formError.style.display = 'none'; formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free'; return lastCheckedFree && lastCheckedClassName === 'free';
@@ -132,21 +260,21 @@ export function render({ navigate }) {
lastCheckedClassName = className; lastCheckedClassName = className;
if (!isFree) { if (!isFree) {
statusText.textContent = 'Логин уже занят ❌'; statusText.textContent = 'Логин уже занят ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} else if (className === 'free') { } else if (className === 'free') {
statusText.textContent = precheckWarning statusText.textContent = precheckWarning
? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})`
: 'Логин свободен ✅'; : 'Логин свободен ✅';
statusText.className = 'is-available'; statusText.className = 'status-line is-available';
} else if (className === 'premium') { } else if (className === 'premium') {
statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} else if (className === 'company') { } else if (className === 'company') {
statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} else { } else {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
} }
formError.style.display = 'none'; formError.style.display = 'none';
return isFree && className === 'free'; return isFree && className === 'free';
@@ -154,7 +282,7 @@ export function render({ navigate }) {
const base = toUserMessage(error, 'Не удалось проверить логин'); const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error); const details = formatSolanaErrorDetails(error);
statusText.textContent = `${base}. Детали: ${details}`; statusText.textContent = `${base}. Детали: ${details}`;
statusText.className = 'is-unavailable'; statusText.className = 'status-line is-unavailable';
return false; return false;
} finally { } finally {
checkButton.disabled = false; checkButton.disabled = false;
@@ -164,19 +292,32 @@ export function render({ navigate }) {
checkButton.addEventListener('click', runAvailabilityCheck); checkButton.addEventListener('click', runAvailabilityCheck);
const actions = document.createElement('div'); loginInput.addEventListener('input', () => {
actions.className = 'auth-footer-actions'; syncDraftState();
lastCheckedLogin = '';
});
const backButton = document.createElement('button'); passwordInput.addEventListener('input', () => {
backButton.className = 'ghost-btn'; syncDraftState();
backButton.type = 'button'; });
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view')); passwordModeCheckbox.addEventListener('change', () => {
const nextMode = passwordModeCheckbox.checked ? 'words' : 'single';
if (nextMode === passwordMode) return;
if (nextMode === 'words') {
passwordWords = emptyPasswordWords();
wordInputs.forEach((input) => {
input.value = '';
});
passwordInput.value = '';
} else {
passwordInput.value = composePasswordFromWords(passwordWords);
}
passwordMode = nextMode;
updatePasswordModeVisibility();
syncDraftState();
});
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
nextButton.textContent = 'Далее';
nextButton.addEventListener('click', async () => { nextButton.addEventListener('click', async () => {
formError.style.display = 'none'; formError.style.display = 'none';
const isFree = await runAvailabilityCheck(); const isFree = await runAvailabilityCheck();
@@ -185,16 +326,23 @@ export function render({ navigate }) {
const prevLogin = String(state.registrationDraft.login || ''); const prevLogin = String(state.registrationDraft.login || '');
const prevPassword = String(state.registrationDraft.password || ''); const prevPassword = String(state.registrationDraft.password || '');
const nextLogin = String(loginInput.value.trim()); const nextLogin = String(loginInput.value.trim());
const nextPassword = String(passwordInput.value || ''); const nextPassword = getCurrentPassword();
if (nextPassword.length === 0) { if (nextPassword.length === 0) {
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.'; formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
formError.style.display = ''; formError.style.display = '';
return; return;
} }
if (nextPassword.length > PASSWORD_MAX_LENGTH) {
formError.textContent = `Пароль получился слишком длинным. Максимальная длина: ${PASSWORD_MAX_LENGTH} символов.`;
formError.style.display = '';
return;
}
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword; const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
state.registrationDraft.login = nextLogin; state.registrationDraft.login = nextLogin;
state.registrationDraft.password = nextPassword; state.registrationDraft.password = nextPassword;
state.registrationDraft.passwordMode = passwordMode;
state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords);
if (credsChanged) { if (credsChanged) {
state.registrationDraft.preGeneratedKeyBundle = null; state.registrationDraft.preGeneratedKeyBundle = null;
} }
@@ -202,20 +350,20 @@ export function render({ navigate }) {
renderSecurityConfirmStage(); renderSecurityConfirmStage();
}); });
actions.append(backButton, nextButton);
function renderInputStage() { function renderInputStage() {
form.innerHTML = ` form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label> <label class="stack"><span class="field-label">Логин</span></label>
<label class="stack"><span class="field-label">Пароль</span></label> <label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
`; `;
form.children[0].append(loginInput); const loginField = form.children[0];
form.children[1].append(passwordInput); passwordField = form.children[1];
form.append(serverNotice, checkButton, statusText, advanced, formError); loginField.append(loginInput);
passwordField.append(passwordInput);
form.append(passwordModeToggle, wordsSection, passwordLengthText, serverNotice, checkButton, statusText, faqCard, formError);
actions.innerHTML = ''; actions.innerHTML = '';
actions.append(backButton, nextButton); actions.append(backButton, nextButton);
backButton.disabled = false; updatePasswordModeVisibility();
nextButton.disabled = false; syncDraftState();
} }
function renderSecurityConfirmStage() { function renderSecurityConfirmStage() {
@@ -223,8 +371,7 @@ export function render({ navigate }) {
const info = document.createElement('p'); const info = document.createElement('p');
info.className = 'auth-copy'; info.className = 'auth-copy';
info.textContent = info.textContent = 'Для повышения безопасности мы генерируем секрет из вашего логина и пароля с помощью Argon2id.';
'Для повышения безопасности мы генерируем секрет из вашего пароля с помощью Argon2id.';
const details = document.createElement('p'); const details = document.createElement('p');
details.className = 'meta-muted'; details.className = 'meta-muted';
@@ -232,14 +379,17 @@ export function render({ navigate }) {
const details2 = document.createElement('p'); const details2 = document.createElement('p');
details2.className = 'meta-muted'; details2.className = 'meta-muted';
details2.textContent = details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.';
const details3 = document.createElement('p'); const details3 = document.createElement('p');
details3.className = 'meta-muted'; details3.className = 'meta-muted';
details3.textContent = 'Это необходимо, чтобы усложнить подбор пароля и секрета.'; details3.textContent = 'Замедление нужно специально: оно усложняет подбор пароля и повышает цену атак на видеокартах и GPU.';
form.append(info, details, details2, details3); const details4 = document.createElement('p');
details4.className = 'meta-muted';
details4.textContent = `Длина вашего текущего пароля: ${getCurrentPassword().length} символов.`;
form.append(info, details, details2, details3, details4);
const back2 = document.createElement('button'); const back2 = document.createElement('button');
back2.className = 'ghost-btn'; back2.className = 'ghost-btn';
@@ -270,17 +420,10 @@ export function render({ navigate }) {
subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.'; subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.';
const progressWrap = document.createElement('div'); const progressWrap = document.createElement('div');
progressWrap.style.width = '100%'; progressWrap.className = 'registration-progress';
progressWrap.style.height = '10px';
progressWrap.style.border = '1px solid rgba(180,180,180,.5)';
progressWrap.style.borderRadius = '6px';
progressWrap.style.overflow = 'hidden';
const progressBar = document.createElement('div'); const progressBar = document.createElement('div');
progressBar.style.height = '100%'; progressBar.className = 'registration-progress-bar';
progressBar.style.width = '0%';
progressBar.style.background = 'rgba(80, 160, 255, 0.9)';
progressBar.style.transition = 'width 180ms linear';
progressWrap.append(progressBar); progressWrap.append(progressBar);
const progressText = document.createElement('p'); const progressText = document.createElement('p');
+229
View File
@@ -0,0 +1,229 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
export const REGISTRATION_FAQ_TOPICS = [
{
id: 'keys-storage',
shortTitle: 'Где ключи',
title: 'У кого хранятся ключи?',
paragraphs: [
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
],
},
{
id: 'reliability',
shortTitle: 'Надёжность',
title: 'Насколько это надёжно?',
paragraphs: [
'Мы делаем ставку на открытость: клиентский код открыт, серверный код открыт, протокол открыт. Это позволяет проверять систему независимо, а не верить обещаниям на слово.',
'Мы рекомендуем использовать браузеры с открытым исходным кодом. Позже планируются отдельные приложения для Android, iPhone, Ubuntu Touch и Linux, тоже с открытым кодом.',
'Проект распространяется по лицензии AGPL v3. Часть важных данных и регистрационных записей также опирается на блокчейн-слой, чтобы уменьшать зависимость от одной закрытой стороны.',
],
},
{
id: 'key-derivation',
shortTitle: 'Деривация',
title: 'Как генерируются ключи и что делает пароль?',
paragraphs: [
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
'Уже из этого секрета детерминированно строятся три основных ключа: root key, blockchain key и device key.',
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
],
},
{
id: 'three-keys',
shortTitle: 'Три ключа',
title: 'Зачем нужны три ключа?',
paragraphs: [
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
'Device key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
],
},
{
id: 'generation-time',
shortTitle: 'Зачем время',
title: 'Зачем нужна заметная пауза при генерации?',
paragraphs: [
'Генерация специально сделана не мгновенной. Это усложняет массовый подбор паролей.',
'Argon2id расходует и время, и память, поэтому атаки на GPU и видеокартах становятся заметно дороже и медленнее.',
'Небольшая задержка при создании секрета здесь работает как дополнительная защита.',
],
},
{
id: 'strong-password',
shortTitle: 'Какой пароль',
title: 'Какой пароль считается надёжным?',
paragraphs: [
'Минимально разумный уровень сейчас начинается примерно от 8 символов.',
'Хороший практический ориентир для большинства людей: 12 символов и больше. Пароль у нас может быть длиной до 256 символов.',
'Если вам удобнее думать словами, можно использовать режим из 12 полей ниже: слова просто склеиваются в один длинный пароль, и система не проверяет орфографию.',
],
},
{
id: 'one-or-twelve',
shortTitle: '1 или 12 слов',
title: 'Чем отличается один пароль от режима 12 слов?',
paragraphs: [
'Технически ничем: это один и тот же пароль. Режим 12 слов нужен только для удобства запоминания и ввода.',
'Можно заполнить все 12 полей, можно только первые 6, можно использовать слова от другого кошелька, разные языки и любые нестандартные символы.',
'Главное помнить, что в конце всё равно получается одна строка длиной до 256 символов.',
],
},
{
id: 'why-own-password',
shortTitle: 'Зачем свой',
title: 'Почему лучше иметь свой пароль и свои ключи?',
paragraphs: [
'Чем дальше, тем проще будет подделывать фотографию, голос, интонацию и даже другие привычные признаки личности с помощью нейросетей.',
'На расстоянии всё сложнее будет понять, что перед вами действительно вы, если опираться только на внешние признаки.',
'Поэтому персональные ключи, которые храните только вы, становятся надёжнее, чем зависимость от сторонней организации, которая держит ключи у себя.',
],
},
{
id: 'first-server',
shortTitle: 'Первый сервер',
title: 'Что такое первый сервер SHiNE?',
paragraphs: [
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
],
},
{
id: 'hardware-device',
shortTitle: 'ESP32',
title: 'Нужно ли отдельное устройство для ключей?',
paragraphs: [
'Идеальный вариант для важных ключей: отдельное физическое устройство, которое вы контролируете сами.',
'Если пока не хотите покупать отдельное устройство, можно пользоваться телефоном. Но отдельный контроллер или мини-устройство обычно даёт лучший контроль и более понятную модель доверия.',
'Красивая готовая модель Waveshare на ESP32-S3 Touch AMOLED 2.16 стоит около 32 долларов. Есть и более дешёвые варианты на открытых чипах, примерно от 10 до 15 долларов.',
'Если у вас другая модель, под неё можно адаптировать открытую прошивку. Для простых переносов это реально сделать довольно быстро.',
],
links: [
{
label: 'Документация Waveshare ESP32-S3 Touch AMOLED 2.16',
href: 'https://docs.waveshare.com/ESP32-S3-Touch-AMOLED-2.16',
},
],
},
{
id: 'wallet-device',
shortTitle: 'Кошелёк',
title: 'Можно ли использовать такое устройство как кошелёк?',
paragraphs: [
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
],
},
];
function getTopicById(topicId) {
return REGISTRATION_FAQ_TOPICS.find((topic) => topic.id === topicId) || REGISTRATION_FAQ_TOPICS[0];
}
export function openRegistrationFaq(navigate, topicId) {
state.registrationHelp.selectedTopic = getTopicById(topicId).id;
navigate('registration-faq-view');
}
export function render({ navigate }) {
const selectedTopic = getTopicById(state.registrationHelp?.selectedTopic);
const screen = document.createElement('section');
screen.className = 'stack';
const heroCard = document.createElement('div');
heroCard.className = 'card stack registration-faq-hero';
heroCard.innerHTML = `
<div class="badge alt">Вопросы о регистрации</div>
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
`;
const topicCard = document.createElement('div');
topicCard.className = 'card stack registration-faq-topic';
const question = document.createElement('h2');
question.className = 'registration-faq-title';
question.textContent = selectedTopic.title;
topicCard.append(question);
selectedTopic.paragraphs.forEach((paragraph) => {
const p = document.createElement('p');
p.className = 'auth-copy';
p.textContent = paragraph;
topicCard.append(p);
});
if (Array.isArray(selectedTopic.links) && selectedTopic.links.length > 0) {
selectedTopic.links.forEach((linkItem) => {
const link = document.createElement('a');
link.className = 'link-card';
link.href = linkItem.href;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = linkItem.label;
topicCard.append(link);
});
}
const topicsCard = document.createElement('div');
topicsCard.className = 'card stack';
const topicsLabel = document.createElement('p');
topicsLabel.className = 'field-label';
topicsLabel.textContent = 'Другие вопросы';
const topicsGrid = document.createElement('div');
topicsGrid.className = 'registration-faq-grid';
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
const button = document.createElement('button');
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
button.type = 'button';
button.textContent = topic.shortTitle;
button.addEventListener('click', () => {
state.registrationHelp.selectedTopic = topic.id;
navigate('registration-faq-view');
});
topicsGrid.append(button);
});
topicsCard.append(topicsLabel, topicsGrid);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('register-view'));
const registerButton = document.createElement('button');
registerButton.className = 'primary-btn';
registerButton.type = 'button';
registerButton.textContent = 'К регистрации';
registerButton.addEventListener('click', () => navigate('register-view'));
actions.append(backButton, registerButton);
screen.append(
renderHeader({
title: 'Вопросы о регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
}),
heroCard,
topicCard,
topicsCard,
actions,
);
return screen;
}
@@ -11,6 +11,7 @@ import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js'; import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false }; export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
@@ -122,8 +123,12 @@ export function render({ navigate }) {
state.loginDraft.login = state.registrationDraft.login; state.loginDraft.login = state.registrationDraft.login;
state.loginDraft.password = ''; state.loginDraft.password = '';
state.loginDraft.passwordMode = 'single';
state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.flowType = ''; state.registrationDraft.flowType = '';
state.registrationDraft.password = ''; state.registrationDraft.password = '';
state.registrationDraft.passwordMode = 'single';
state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice();
state.registrationDraft.storagePwd = ''; state.registrationDraft.storagePwd = '';
state.registrationDraft.sessionId = ''; state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null; state.registrationDraft.pendingKeyBundle = null;
+1
View File
@@ -6,6 +6,7 @@ export const PRE_AUTH_PAGES = [
'start-view', 'start-view',
'entry-settings-view', 'entry-settings-view',
'register-view', 'register-view',
'registration-faq-view',
'registration-payment-view', 'registration-payment-view',
'registration-draft-keys-view', 'registration-draft-keys-view',
'registration-keys-view', 'registration-keys-view',
+16
View File
@@ -2208,6 +2208,22 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
async getTestFreeAvatarQuota() {
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
return response.payload || {};
}
async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) {
const response = await this.ws.request('TestUploadFreeAvatar', {
contentType,
fileBytesBase64,
sha256Hex,
}, 60000);
if (response.status !== 200) throw opError('TestUploadFreeAvatar', response);
return response.payload || {};
}
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) { async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
const cleanKind = String(kind || '').trim().toLowerCase(); const cleanKind = String(kind || '').trim().toLowerCase();
const kinds = CONNECTION_SUBTYPES[cleanKind]; const kinds = CONNECTION_SUBTYPES[cleanKind];
+18
View File
@@ -0,0 +1,18 @@
export const PASSWORD_WORDS_COUNT = 12;
export const PASSWORD_MAX_LENGTH = 256;
export function normalizePasswordWords(wordsLike) {
const words = Array.isArray(wordsLike) ? wordsLike : [];
return Array.from({ length: PASSWORD_WORDS_COUNT }, (_, index) => String(words[index] || ''));
}
export function composePasswordFromWords(wordsLike) {
return normalizePasswordWords(wordsLike)
.map((word) => word.trim())
.filter(Boolean)
.join(' ');
}
export function emptyPasswordWords() {
return Array.from({ length: PASSWORD_WORDS_COUNT }, () => '');
}
+30 -13
View File
@@ -7,6 +7,7 @@ import {
DEFAULT_SHINE_SERVER_WS, DEFAULT_SHINE_SERVER_WS,
resolveShineServerByServerLogin, resolveShineServerByServerLogin,
} from './services/shine-server-resolver.js'; } from './services/shine-server-resolver.js';
import { emptyPasswordWords } from './services/password-words.js';
const clone = (value) => JSON.parse(JSON.stringify(value)); const clone = (value) => JSON.parse(JSON.stringify(value));
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
@@ -91,6 +92,10 @@ const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net';
const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000; const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000;
const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'; const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
export function normalizeDmChatId(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeToolsSettings(rawTools) { function normalizeToolsSettings(rawTools) {
const source = rawTools && typeof rawTools === 'object' ? rawTools : {}; const source = rawTools && typeof rawTools === 'object' ? rawTools : {};
const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {}; const stt = source.speechToText && typeof source.speechToText === 'object' ? source.speechToText : {};
@@ -260,15 +265,22 @@ function createInitialState({ withStoredSession = true } = {}) {
flowType: '', flowType: '',
login: '', login: '',
password: '', password: '',
passwordMode: 'single',
passwordWords: emptyPasswordWords(),
sessionId: '', sessionId: '',
storagePwd: '', storagePwd: '',
pendingKeyBundle: null, pendingKeyBundle: null,
pendingSessionMaterial: null, pendingSessionMaterial: null,
preGeneratedKeyBundle: null, preGeneratedKeyBundle: null,
}, },
registrationHelp: {
selectedTopic: 'keys-storage',
},
loginDraft: { loginDraft: {
login: storedSession?.login || '', login: storedSession?.login || '',
password: '', password: '',
passwordMode: 'single',
passwordWords: emptyPasswordWords(),
}, },
registrationPayment: { registrationPayment: {
walletAddress: '', walletAddress: '',
@@ -368,11 +380,12 @@ function sortChatMessagesInPlace(chatId) {
} }
function persistMessageRecord(chatId, row) { function persistMessageRecord(chatId, row) {
if (!chatId || !row?.messageKey) return; const normalizedChatId = normalizeDmChatId(chatId);
if (!normalizedChatId || !row?.messageKey) return;
const resolvedTs = resolveChatMessageTimeMs(row); const resolvedTs = resolveChatMessageTimeMs(row);
void putStoredMessage({ void putStoredMessage({
messageKey: row.messageKey, messageKey: row.messageKey,
chatId, chatId: normalizedChatId,
from: row.from || 'in', from: row.from || 'in',
text: String(row.text || ''), text: String(row.text || ''),
baseKey: String(row.baseKey || ''), baseKey: String(row.baseKey || ''),
@@ -400,7 +413,7 @@ export async function hydrateMessagesFromStore() {
rows rows
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0)) .sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
.forEach((row) => { .forEach((row) => {
const chatId = String(row?.chatId || '').trim(); const chatId = normalizeDmChatId(row?.chatId);
const messageKey = String(row?.messageKey || '').trim(); const messageKey = String(row?.messageKey || '').trim();
if (!chatId || !messageKey) return; if (!chatId || !messageKey) return;
if (state.knownMessageKeys[messageKey]) return; if (state.knownMessageKeys[messageKey]) return;
@@ -429,10 +442,12 @@ export async function hydrateMessagesFromStore() {
} }
export function getChatMessages(chatId) { export function getChatMessages(chatId) {
if (!state.chats[chatId]) { const normalizedChatId = normalizeDmChatId(chatId);
state.chats[chatId] = []; if (!normalizedChatId) return [];
if (!state.chats[normalizedChatId]) {
state.chats[normalizedChatId] = [];
} }
return state.chats[chatId]; return state.chats[normalizedChatId];
} }
export function addChatMessage(chatId, text) { export function addChatMessage(chatId, text) {
@@ -575,9 +590,10 @@ export function addSignedMessageToChat({
revisionTimeMs = 0, revisionTimeMs = 0,
deleted = false, deleted = false,
} = {}) { } = {}) {
const normalizedChatId = normalizeDmChatId(chatId);
const id = String(messageKey || '').trim(); const id = String(messageKey || '').trim();
if (!chatId || !id) return false; if (!normalizedChatId || !id) return false;
const list = getChatMessages(chatId); const list = getChatMessages(normalizedChatId);
const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id); const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id);
const existing = existingIndex >= 0 ? list[existingIndex] : null; const existing = existingIndex >= 0 ? list[existingIndex] : null;
const nextRevision = Number(revisionTimeMs || 0); const nextRevision = Number(revisionTimeMs || 0);
@@ -591,7 +607,7 @@ export function addSignedMessageToChat({
if (existingIndex >= 0) { if (existingIndex >= 0) {
list.splice(existingIndex, 1); list.splice(existingIndex, 1);
removeStoredMessageRecord(id); removeStoredMessageRecord(id);
sortChatMessagesInPlace(chatId); sortChatMessagesInPlace(normalizedChatId);
return true; return true;
} }
return false; return false;
@@ -615,17 +631,18 @@ export function addSignedMessageToChat({
if (existingIndex < 0) { if (existingIndex < 0) {
list.push(row); list.push(row);
} }
sortChatMessagesInPlace(chatId); sortChatMessagesInPlace(normalizedChatId);
persistMessageRecord(chatId, row); persistMessageRecord(normalizedChatId, row);
return true; return true;
} }
export function markChatRead(chatId) { export function markChatRead(chatId) {
const list = getChatMessages(chatId); const normalizedChatId = normalizeDmChatId(chatId);
const list = getChatMessages(normalizedChatId);
list.forEach((row) => { list.forEach((row) => {
if (row?.from === 'in') { if (row?.from === 'in') {
row.unread = false; row.unread = false;
persistMessageRecord(chatId, row); persistMessageRecord(normalizedChatId, row);
} }
}); });
} }
+408 -82
View File
@@ -1,3 +1,9 @@
/* Глобально отключаем синюю tap-подсветку мобильных браузеров/WebView на ВСЕХ элементах
(Android/Chromium): синего квадрата при нажатии нигде быть не должно. */
* {
-webkit-tap-highlight-color: transparent;
}
.page-header { .page-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -401,6 +407,11 @@
text-align: center; text-align: center;
} }
.auth-screen--lower {
align-content: start;
padding-top: clamp(80px, 18vh, 180px);
}
.auth-logo { .auth-logo {
width: 126px; width: 126px;
height: 126px; height: 126px;
@@ -428,6 +439,22 @@
width: 100%; width: 100%;
} }
.login-panel {
width: min(100%, 360px);
gap: 14px;
}
.login-panel--wide {
width: min(100%, 420px);
}
.login-panel-title {
font-size: 28px;
font-weight: 700;
color: var(--text);
text-align: center;
}
.auth-footer-actions { .auth-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -1162,6 +1189,35 @@
gap: 8px; gap: 8px;
} }
.contact-search-form-card,
.contact-search-results-card {
margin: 0 6px;
padding: 14px;
border-radius: 24px;
background: rgba(7, 10, 18, 0.88);
border: 1px solid rgba(140, 99, 255, 0.24);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.34);
}
.contact-search-input {
min-height: 48px;
border-radius: 16px;
padding: 0 14px;
}
.contact-search-results-title {
font-size: 14px;
font-weight: 700;
color: rgba(252, 234, 192, 0.92);
text-align: center;
}
.contact-search-result-main {
min-width: 0;
display: flex;
align-items: center;
}
.input { .input {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
@@ -3562,13 +3618,24 @@ textarea.input {
} }
.dm-dialog-card { .dm-dialog-card {
background: rgba(20, 25, 35, 0.4); position: relative;
backdrop-filter: blur(25px); display: grid;
-webkit-backdrop-filter: blur(25px); grid-template-columns: 60px minmax(0, 1fr) auto;
border: 1px solid rgba(212, 175, 55, 0.4); gap: 12px;
border-radius: 20px; align-items: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37); min-height: 74px;
padding: 10px 12px 10px 10px;
border-radius: 26px;
background: rgba(7, 10, 18, 0.88);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(140, 99, 255, 0.32); /* оконтовка = цвет линии связи; default = violet (контакт) */
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.42);
cursor: pointer;
} }
.dm-dialog-card:focus-visible { outline: 2px solid var(--rel-link); outline-offset: 2px; }
.dm-card--family { border-color: rgba(240, 184, 46, 0.42); } /* линия связи: gold (семья) */
.dm-card--shining { border-color: rgba(104, 216, 255, 0.45); } /* линия связи: cyan (сияющий) */
.dm-screen .list-item .avatar { .dm-screen .list-item .avatar {
width: 48px; width: 48px;
@@ -3591,66 +3658,193 @@ textarea.input {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
.dm-status-line { /* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */
color: rgba(255, 255, 255, 0.5); /* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (network-graph.css :root). */
.dm-screen {
--dm-tone-default: var(--rel-contact);
--dm-tone-family: var(--rel-family);
--dm-tone-shining: var(--rel-shining);
} }
.dm-screen .unread { /* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */
min-width: 26px; .dm-head {
height: 26px; position: sticky; top: 0; z-index: 12;
padding: 0 8px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px;
border-radius: 999px; padding: 14px 14px 0;
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
}
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
.dm-head-hex {
width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center;
font-weight: 700; font-size: 15px; color: #1a1205;
background: linear-gradient(150deg, #F0B82E, #D49F22);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
box-shadow: 0 0 14px rgba(240, 184, 46, 0.35);
}
.dm-head-id { min-width: 0; display: grid; }
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-head-sub { font-size: 11px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-head-title { font-size: 18px; font-weight: 700; color: var(--text); white-space: nowrap; text-align: center; padding: 0 6px; }
.dm-list-screen .dm-head-title { color: #FCEAC0; text-shadow: 0 0 6px rgba(240, 184, 46, 0.32), 0 0 14px rgba(240, 184, 46, 0.12); }
/* Центр шапки — светящийся бренд «Shine» */
.dm-head-shine {
font-size: 21px; letter-spacing: 0.6px; color: #FCEAC0;
text-shadow: 0 0 6px rgba(240, 184, 46, 0.55), 0 0 16px rgba(240, 184, 46, 0.38), 0 0 30px rgba(240, 184, 46, 0.20);
animation: dm-shine-pulse 3.4s ease-in-out infinite;
}
@keyframes dm-shine-pulse {
0%, 100% { text-shadow: 0 0 5px rgba(240, 184, 46, 0.42), 0 0 12px rgba(240, 184, 46, 0.26), 0 0 22px rgba(240, 184, 46, 0.12); }
50% { text-shadow: 0 0 9px rgba(240, 184, 46, 0.68), 0 0 20px rgba(240, 184, 46, 0.46), 0 0 34px rgba(240, 184, 46, 0.26); }
}
@media (prefers-reduced-motion: reduce) { .dm-head-shine { animation: none; } }
.dm-head-plus {
justify-self: end; width: 48px; height: 48px; border-radius: 50%;
display: grid; place-items: center; font-size: 24px; line-height: 1; font-weight: 300;
color: #FFD98A; border: 1.5px solid rgba(240, 184, 46, 0.6);
background: rgba(12, 12, 16, 0.66);
box-shadow: 0 0 20px rgba(240, 184, 46, 0.32), 0 0 6px rgba(240, 184, 46, 0.28), inset 0 0 12px rgba(240, 184, 46, 0.12);
cursor: pointer;
}
.dm-divider { position: relative; height: 18px; margin: 6px 14px 8px; }
.dm-divider::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: linear-gradient(90deg, transparent, rgba(240, 184, 46, 0.5), transparent); }
.dm-divider::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); }
/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */
.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 6px; padding-bottom: calc(86px + 16px); }
/* текст карточки */
.dm-row-main { min-width: 0; }
.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; }
.dm-row-titlewrap { flex-wrap: wrap; row-gap: 6px; }
.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-contact-note {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; min-height: 22px;
border: 1px solid rgba(212, 175, 55, 0.5); padding: 0 8px;
background: rgba(212, 175, 55, 0.22); border-radius: 999px;
color: rgba(255, 200, 50, 0.95); border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); background: rgba(255, 255, 255, 0.05);
color: rgba(244, 246, 255, 0.62);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */
.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); }
.dm-name-check svg { width: 16px; height: 16px; }
/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */
.dm-av { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center; position: relative; }
.dm-av .avatar { width: 50px; height: 50px; min-width: 50px; min-height: 50px; border: none; box-shadow: none; }
/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */
.dm-av--default { box-shadow: none; }
.dm-av--family { box-shadow: none; }
/* Сияющий аватар = АДАПТАЦИЯ сияющего узла экрана «Связи»: та же небесная палитра, тот же небесный rim,
тот же двойной «дышащий» пульс. Переиспользуем ОБЩИЕ keyframes графа (fg-shine-glow пульс box-shadow,
fg-shine-halo дыхание радиального ореола; объявлены в network-graph.css, грузится глобально), а не рисуем
второй похожий эффект. Радиальный ореол повторяет стопы узла графа; SVG-фильтр #fg-shine-glow есть только на
стр. «Связи», поэтому здесь мягкий CSS-blur. Мини-сфера компактная не размывает текст/соседей. */
.dm-av--shining {
border: 1px solid rgba(150, 240, 255, 0.62);
animation: fg-shine-glow 3.6s ease-in-out infinite;
}
.dm-av--shining::before {
content: ''; position: absolute; inset: -12px; border-radius: 50%; z-index: -1; pointer-events: none;
background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%);
filter: blur(3.4px); /* = stdDeviation 3.4 SVG-фильтра #fg-shine-glow графа; геометрия inset −12px тоже как у узла (58px↔56px, scale≈1) */
animation: fg-shine-halo 3.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.dm-av--shining { animation: none; }
.dm-av--shining::before { animation: none; }
} }
.dm-row-meta-col { .dm-row-meta-col {
display: grid; display: inline-flex;
justify-items: end;
align-content: end;
gap: 6px;
min-width: 64px;
align-self: stretch;
}
.dm-row-main {
min-width: 0; min-width: 0;
display: grid; align-self: stretch;
grid-template-rows: auto auto; flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 4px; gap: 4px;
} }
.dm-row-meta-line {
.dm-row-title-wrap { display: inline-flex;
display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
min-width: 0; min-height: 24px;
} }
.dm-row-meta-spacer {
.dm-row-title { width: 24px;
overflow: hidden; height: 24px;
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 { .dm-row-time {
font-size: 11px; font-size: 12px;
line-height: 1.2; font-weight: 600;
white-space: nowrap; color: rgba(244, 246, 255, 0.44);
} }
.dm-row-time--empty {
width: 0;
min-width: 0;
overflow: hidden;
}
/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */
.dm-unread-badge {
min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: var(--text);
background: rgba(140, 99, 255, 0.16); border: 1px solid rgba(140, 99, 255, 0.55);
}
.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); }
.dm-chevron svg { width: 16px; height: 16px; }
@media (max-width: 380px) {
.dm-dialog-card {
grid-template-columns: 60px minmax(0, 1fr);
row-gap: 10px;
}
.dm-row-meta-col {
grid-column: 2;
justify-self: end;
}
}
/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */
.dm-via {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer;
color: var(--rel-link); border: 1px solid rgba(25, 229, 138, 0.5); background: rgba(25, 229, 138, 0.08);
}
.dm-via-icon { display: inline-flex; }
.dm-via-icon svg { width: 14px; height: 14px; }
/* попап пути связи: Ты → …посредники… → он; узлы = аватар+имя, кликабельные → профиль */
.dm-via-path {
display: none; position: absolute; left: 14px; right: 14px; top: 46px; z-index: 6;
flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 11px; border-radius: 12px;
background: rgba(8, 12, 20, 0.97); border: 1px solid rgba(25, 229, 138, 0.35);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
}
.dm-via-path.is-open { display: flex; }
.dm-via-node {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px 3px 4px; border-radius: 11px;
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text); font-size: 12px; cursor: default;
}
button.dm-via-node { cursor: pointer; }
button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
.dm-via-node-ava { width: 20px; height: 20px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; }
.dm-via-node-ava .avatar { width: 20px; height: 20px; min-width: 20px; min-height: 20px; border: none; box-shadow: none; }
.dm-via-node-ava .avatar-fallback { font-size: 9px; font-weight: 700; }
.dm-via-me { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(150deg, #F0B82E, #D49F22); color: #1a1205; font-size: 10px; font-weight: 700; }
.dm-via-node-name { white-space: nowrap; }
.dm-via-arrow { font-size: 12px; color: rgba(25, 229, 138, 0.8); }
/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
даёт лишний скролл. Фон НЕ меняем клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
html, body { overflow-x: hidden; }
.dm-chat-wrap { .dm-chat-wrap {
gap: 12px; gap: 12px;
@@ -3801,9 +3995,14 @@ textarea.input {
} }
.dm-message-actions-menu { .dm-message-actions-menu {
width: min(52vw, 240px); width: min(72vw, 220px);
padding: 8px; padding: 10px;
gap: 6px; gap: 8px;
border-radius: 16px;
border: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(10, 12, 18, 0.96);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
} }
.dm-floating-menu-layer { .dm-floating-menu-layer {
@@ -3832,7 +4031,10 @@ textarea.input {
.dm-message-action-btn { .dm-message-action-btn {
width: 100%; width: 100%;
min-height: 42px;
justify-content: flex-start; justify-content: flex-start;
padding: 0 14px;
border-radius: 12px;
} }
.dm-message-action-btn--danger { .dm-message-action-btn--danger {
@@ -3851,34 +4053,6 @@ textarea.input {
width: 100%; width: 100%;
} }
/* DM messages-list status + empty block as full glass buttons */
.dm-screen .dm-status-line {
display: block;
width: calc(100% - 40px);
margin: 2px 20px 10px;
padding: 12px 16px;
border-radius: 14px;
background: rgba(18, 24, 38, 0.42);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border: 1px solid rgba(212, 175, 55, 0.32);
color: rgba(255, 227, 154, 0.92);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
/* Hide "Нет диалогов." line on DM list per UI request */
.dm-screen .dm-status-line {
display: none !important;
}
.dm-screen .dm-status-line.is-available {
color: rgba(255, 227, 154, 0.92);
}
.dm-screen .dm-status-line.is-unavailable {
color: rgba(255, 161, 176, 0.95);
}
.dm-screen .dm-list > .card.meta-muted { .dm-screen .dm-list > .card.meta-muted {
width: calc(100% - 40px); width: calc(100% - 40px);
margin: 0 20px; margin: 0 20px;
@@ -4008,6 +4182,151 @@ textarea.input {
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
} }
.registration-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(132, 162, 228, 0.22);
background: rgba(20, 31, 52, 0.72);
color: #eef3ff;
line-height: 1.4;
}
.registration-toggle input {
width: 18px;
height: 18px;
accent-color: #d4af37;
}
.registration-words-block[hidden] {
display: none;
}
.registration-words-block {
display: grid;
gap: 10px;
}
.registration-words-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.registration-word-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.registration-word-number {
font-size: 12px;
color: #b2c2e6;
min-width: 18px;
text-align: right;
}
.registration-word-input {
min-height: 44px;
padding-left: 10px;
}
.registration-faq-card {
gap: 12px;
}
.registration-faq-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.registration-faq-grid .ghost-btn,
.registration-faq-grid .secondary-btn {
min-height: 44px;
text-align: left;
}
@media (max-width: 740px) {
.registration-words-grid,
.registration-faq-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 460px) {
.registration-words-grid,
.registration-faq-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.registration-faq-hero {
gap: 12px;
}
.registration-faq-topic {
gap: 12px;
}
.registration-faq-title {
margin: 0;
font-size: 22px;
line-height: 1.2;
color: #f6deb0;
}
.registration-progress {
width: 100%;
height: 10px;
border: 1px solid rgba(180, 180, 180, 0.5);
border-radius: 6px;
overflow: hidden;
}
.registration-progress-bar {
height: 100%;
width: 0%;
background: rgba(80, 160, 255, 0.9);
transition: width 180ms linear;
}
/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */
.toolbar-icon-img {
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */
width: var(--tab-icon-size);
height: var(--tab-icon-size);
object-fit: contain;
display: block;
transition: transform .12s ease, filter .15s ease;
}
/* Активная вкладка — лёгкое доп. свечение (подпись подсвечивается правилом .active span:last-child выше). */
.toolbar-btn.active .toolbar-icon-img {
filter: drop-shadow(0 0 5px var(--tab-glow)) brightness(1.08);
}
/* Нажатие — вдавливание + краткая вспышка свечения; на отпускании возврат. */
.toolbar-btn:active .toolbar-icon-img {
transform: scale(0.9);
filter: drop-shadow(0 0 9px var(--tab-glow)) brightness(1.2);
}
/* «Связи» — герой: крупнее и всегда чуть светится сильнее остальных; press-feedback ярче. */
.toolbar-btn-hero .toolbar-icon-img {
/* Крупнее ВИЗУАЛЬНО через transform (origin center) раскладочный размер как у остальных (27px),
поэтому иконка остаётся на одной линии с другими, а не задирается вверх. */
transform: scale(1.63); /* ≈44px при базовых 27px */
filter: brightness(1.05); /* CSS-ореол убран — светится только сама PNG (логотип не тронут) */
}
.toolbar-btn-hero.active .toolbar-icon-img {
filter: brightness(1.12);
}
.toolbar-btn-hero:active .toolbar-icon-img {
transform: scale(1.47); /* 1.63 × 0.9 (нажатие) */
filter: brightness(1.25); /* нажатие — только подсветление, без ореола */
}
.toolbar-channels-hold-overlay { .toolbar-channels-hold-overlay {
position: fixed; position: fixed;
z-index: 1200; z-index: 1200;
@@ -4424,10 +4743,17 @@ textarea.input {
.toolbar-btn-network { .toolbar-btn-network {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
-webkit-tap-highlight-color: transparent; /* нет синей вспышки-квадрата при тапе (Android WebView/Chromium) */
}
/* нет рамки/подсветки фокуса ВОКРУГ кнопки — светится только сама иконка (её drop-shadow) */
.toolbar-btn-network:focus,
.toolbar-btn-network:focus-visible {
outline: none;
} }
.toolbar-btn-network::before { .toolbar-btn-network::before {
content: ""; content: "";
display: none; /* подсветка-подложка вокруг иконки «Связи» убрана по запросу (иконка и её drop-shadow-ореол не тронуты) */
position: absolute; position: absolute;
inset: 6px; inset: 6px;
border-radius: 10px; border-radius: 10px;
+78 -29
View File
@@ -4,6 +4,20 @@
Отдельный модуль, чтобы не раздувать components.css. Отдельный модуль, чтобы не раздувать components.css.
========================================================================== */ ========================================================================== */
/* Канонические токены ЯЗЫКА СВЯЗЕЙ (единый источник цвета отношений для всего продукта).
Экран «Личные сообщения» наследует их через --dm-* (не дублирует hex).
(force-graph пока использует свои JS-цвета RELATION_COLORS миграция на токены = будущая задача.) */
:root {
--rel-contact: #8C63FF; /* violet — обычная связь / контакт */
--rel-family: #F0B82E; /* gold — семья / близкий круг / важность / подтверждение */
--rel-shining: #68D8FF; /* celestial — сияющий / сильная активная связь */
--rel-link: #19E58A; /* emerald — статус «Связь» (активный канал) */
--rel-contact-glow: rgba(140, 99, 255, 0.24);
--rel-family-glow: rgba(240, 184, 46, 0.30);
--rel-shining-glow: rgba(104, 216, 255, 0.35);
--rel-link-glow: rgba(25, 229, 138, 0.24);
}
.fg-stage { .fg-stage {
touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */ touch-action: none; /* перехватываем жесты сами (pan), без скролла страницы */
user-select: none; user-select: none;
@@ -120,14 +134,73 @@
} }
/* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */ /* круглая аватарка узла (переиспользуем существующий .node-dot из renderUserAvatar) */
/* 58px → радиус 29 = ORB_R в force-graph.js (контакт линий берётся от этого радиуса). */
.fg-node .node-dot { .fg-node .node-dot {
width: 52px; width: 58px;
height: 52px; height: 58px;
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
} }
/* SVG-«стеклянный орб» масштабируем так, чтобы сфера (r42 = 84% SVG) диаметр узла линии-связи
прилипают к краю орба, как раньше. Хост .node-dot держит размер/состояния/синхронизацию позиций. */
.fg-node .node-dot.fg-orb-host {
position: relative;
background: none;
overflow: visible; /* не срезать внешнее свечение орба */
box-shadow: none;
}
/* A/B PNG-оверлей орба (ветка glass-png-overlay): фото снизу + запечённый стеклянный PNG сверху.
Бокс = 119% от .node-dot (как .fg-orb-svg сфера кромке node-dot, контакт линий от ORB_R сохраняется). */
/* Специфичность `.fg-orb-host ` бьёт глобальное `.node-dot img` (иначе оно гасит opacity0
и форсит размер 100%). Поэтому opacity/размеры/радиус задаём здесь явно. */
/* Кромку даёт сам glass_overlay.png убираем остаточный border .node-dot (синее кольцо
старого векторного орба). Только у PNG-хоста; вектор и свечение/box-shadow не трогаем. */
/* (0,4,0) выше специфичности правил категории `.fg-node.is-family .node-dot` (0,3,0),
иначе их background/border перебивают. */
.fg-node .node-dot.fg-orb-host:has(.fg-pngorb) {
border: none;
background: none; /* фон-градиент категории не торчит из-под прозрачного стекла (та самая «обводка») */
}
.fg-orb-host .fg-pngorb {
position: absolute;
left: 50%; top: 50%;
width: 119%; height: 119%;
transform: translate(-50%, -50%);
}
.fg-orb-host .fg-pngorb-glass {
position: absolute;
left: 50%; top: 50%;
width: 100%; height: 100%;
transform: translate(-50%, -50%);
opacity: 1;
border-radius: 0;
object-fit: contain;
display: block;
pointer-events: none;
}
.fg-orb-host .fg-pngorb-photo {
position: absolute;
left: 50%; top: 50%;
width: 78%; height: 78%; /* ~78% от бокса оверлея — сидит внутри стеклянной кромки */
transform: translate(-50%, -50%);
opacity: 1;
border-radius: 50%; /* фолбэк, если mask не поддержан */
object-fit: cover;
display: block;
/* Мягкий край: фото непрозрачно до --feather-full, плавно гаснет к 0 у --feather-edge
сливается со стеклом без жёсткого ободка. Силу растушёвки крутим этими двумя параметрами. */
--feather-full: 62%;
--feather-edge: 78%;
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 var(--feather-full), transparent var(--feather-edge));
mask-image: radial-gradient(circle at 50% 50%, #000 var(--feather-full), transparent var(--feather-edge));
}
.fg-orb-host .fg-pngorb-init {
display: flex; align-items: center; justify-content: center;
background: #26344a; color: #cfe0ff; font-weight: 600; font-size: 20px;
}
.fg-node.is-family .node-dot { .fg-node.is-family .node-dot {
background: linear-gradient(165deg, #785038, #5f3e2c); background: linear-gradient(165deg, #785038, #5f3e2c);
border-color: rgba(255, 194, 143, 0.6); border-color: rgba(255, 194, 143, 0.6);
@@ -334,14 +407,6 @@
.fg-dot.is-tier3 { animation: none; } .fg-dot.is-tier3 { animation: none; }
} }
/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */
.fg-deep-chip.is-active {
background: rgba(150, 130, 255, 0.18);
border-color: rgba(190, 170, 255, 0.6);
box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3);
color: #efeaff;
}
/* «Призрак» старой карты при Z-переходе (эффект погружения) */ /* «Призрак» старой карты при Z-переходе (эффект погружения) */
.fg-ghost-layer { .fg-ghost-layer {
position: absolute; position: absolute;
@@ -567,12 +632,8 @@
} }
.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } .fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); }
/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */ /* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу
.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); } box-shadow не навязываем у них свой эффект свечения (is-shine/is-focus выше). */
.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); }
.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); }
.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); }
/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */
.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } .fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; }
/* Строка поиска (оверлей вверху, под панелью фильтров) */ /* Строка поиска (оверлей вверху, под панелью фильтров) */
@@ -659,20 +720,8 @@
border: 0; border: 0;
} }
/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок + ★ под аватаркой */ /* «Общая связь» (этот человек — и твой друг тоже): золотой ободок. Значок ★ убран по запросу. */
.fg-node.is-common .node-dot { .fg-node.is-common .node-dot {
border-color: rgba(255, 214, 120, 0.95); border-color: rgba(255, 214, 120, 0.95);
box-shadow: 0 0 14px rgba(255, 200, 90, 0.4); box-shadow: 0 0 14px rgba(255, 200, 90, 0.4);
} }
.fg-node.is-common .node-dot::after {
content: '★';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
line-height: 1;
color: #ffd678;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
pointer-events: none;
}