diff --git a/AGENTS.md b/AGENTS.md index 417aa37..574794c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,11 @@ - Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`. - Документ по личным сообщениям обязан поддерживаться в актуальном состоянии. +## Известная проблема (временная пометка) +- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно. +- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI. +- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя. + ## Версионирование - Единый файл версий проекта: `VERSION.properties` (в корне репозитория). - Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`: diff --git a/Dev_Docs/Pending_Features/2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-13_0201_голосовые-инструменты-openai-tts-и-stt.md diff --git a/Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-логин-и-регистрация.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-13_0222_argon2id-логин-и-регистрация.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-13_0222_argon2id-логин-и-регистрация.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-13_0222_argon2id-логин-и-регистрация.md diff --git a/Dev_Docs/Pending_Features/2026-05-16_2123_solana-генерация-кошелька-base58.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-16_2123_solana-генерация-кошелька-base58.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-16_2123_solana-генерация-кошелька-base58.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-16_2123_solana-генерация-кошелька-base58.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_0046_навигация-по-тредам-и-история-сообщения.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_0046_навигация-по-тредам-и-история-сообщения.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_0046_навигация-по-тредам-и-история-сообщения.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_0046_навигация-по-тредам-и-история-сообщения.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_0112_короткая-ссылка-на-сообщение-m-блокчейн-номер.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_0112_короткая-ссылка-на-сообщение-m-блокчейн-номер.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_0112_короткая-ссылка-на-сообщение-m-блокчейн-номер.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_0112_короткая-ссылка-на-сообщение-m-блокчейн-номер.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1012_history-router-без-hash.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1012_history-router-без-hash.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1012_history-router-без-hash.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1012_history-router-без-hash.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1359_карточка-автора-в-канале-и-назад-по-истории.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1359_карточка-автора-в-канале-и-назад-по-истории.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1359_карточка-автора-в-канале-и-назад-по-истории.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1359_карточка-автора-в-канале-и-назад-по-истории.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1428_шапка-канала-и-унификация-тред-карточек.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1428_шапка-канала-и-унификация-тред-карточек.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1428_шапка-канала-и-унификация-тред-карточек.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1428_шапка-канала-и-унификация-тред-карточек.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1506_поднятие-верхней-фиксированной-шапки-канал-и-тред.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1506_поднятие-верхней-фиксированной-шапки-канал-и-тред.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1506_поднятие-верхней-фиксированной-шапки-канал-и-тред.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1506_поднятие-верхней-фиксированной-шапки-канал-и-тред.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1548_профиль-упрощение-и-чат-ux-меню-речи.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1548_профиль-упрощение-и-чат-ux-меню-речи.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1548_профиль-упрощение-и-чат-ux-меню-речи.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1548_профиль-упрощение-и-чат-ux-меню-речи.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1613_dm-ctrl-enter-автоскролл-и-время-в-списке.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1613_dm-ctrl-enter-автоскролл-и-время-в-списке.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1613_dm-ctrl-enter-автоскролл-и-время-в-списке.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1613_dm-ctrl-enter-автоскролл-и-время-в-списке.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1618_dm-список-и-поведение-enter-в-чате.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1618_dm-список-и-поведение-enter-в-чате.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1618_dm-список-и-поведение-enter-в-чате.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1618_dm-список-и-поведение-enter-в-чате.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_1620_деплой-на-93.170.12.154-caddy-systemd.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1620_деплой-на-93.170.12.154-caddy-systemd.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_1620_деплой-на-93.170.12.154-caddy-systemd.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_1620_деплой-на-93.170.12.154-caddy-systemd.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2032_редактирование-сообщений-история-и-delete-empty.md diff --git a/Dev_Docs/Pending_Features/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2047_каналы-убрать-тред-две-вкладки-и-dm-автоскролл.md diff --git a/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2113_аватары-в-каналах-тредах-и-лс.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2113_аватары-в-каналах-тредах-и-лс.md new file mode 100644 index 0000000..c839c8a --- /dev/null +++ b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-19_2113_аватары-в-каналах-тредах-и-лс.md @@ -0,0 +1,15 @@ +## Краткое описание +Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений. + +## Что проверять +1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан). +2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан). +3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан). +4. Если аватар не задан или недоступен, корректно остаётся fallback (буква). +5. Форма и размер остаются круглыми и визуально не ломают карточки. + +## Ожидаемый результат +Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI. + +## Статус +`pending` diff --git a/Dev_Docs/Pending_Features/2026-05-20_1134_connection-shine-known-codes.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-20_1134_connection-shine-known-codes.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-20_1134_connection-shine-known-codes.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-20_1134_connection-shine-known-codes.md diff --git a/Dev_Docs/Pending_Features/2026-05-20_1221_profile-opinion-ui-and-order.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-20_1221_profile-opinion-ui-and-order.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-20_1221_profile-opinion-ui-and-order.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-20_1221_profile-opinion-ui-and-order.md diff --git a/Dev_Docs/Personal_Messages/TODO_доработка_персональных_сообщений_для_агентов.md b/Dev_Docs/Personal_Messages/TODO_доработка_персональных_сообщений_для_агентов.md new file mode 100644 index 0000000..20b27cf --- /dev/null +++ b/Dev_Docs/Personal_Messages/TODO_доработка_персональных_сообщений_для_агентов.md @@ -0,0 +1,42 @@ +# TODO: доработка персональных сообщений для агентов + +Статус: отложено. + +## Что хотели сделать + +Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы: + +- отличать сообщения человеку от сообщений агенту; +- отличать сообщения от человека и от агента; +- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`); +- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина. + +## Базовая идея формата (черновик) + +Пример префикса: + +```text +@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"} +Текст сообщения... +``` + +Пример ответа агента: + +```text +@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"} +Ответ агента... +``` + +## Почему отложено + +- нужно отдельно согласовать финальный формат инструкции; +- нужно определить строгие правила UI-фильтрации и fallback; +- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий. + +## Что сделать при возвращении к задаче + +1. Зафиксировать окончательный формат префикса и JSON-полей. +2. Описать правила парсинга/валидации (включая битые/неполные префиксы). +3. Добавить UI-логику показа/скрытия agent-сообщений. +4. Добавить маркировку «ответ агента» в диалоге. +5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина. diff --git a/Dev_Docs/deploy/README.md b/Dev_Docs/deploy/README.md index 4f2a425..24982da 100644 --- a/Dev_Docs/deploy/README.md +++ b/Dev_Docs/deploy/README.md @@ -4,9 +4,9 @@ ## Базовый сервер -- SSH: `player@93.170.12.154` +- SSH: `player@45.136.124.227` - Домен: `shineup.me` -- Базовый путь: `/home/player/SHiNE` +- Базовый путь: `/home/player` ## Локальные команды diff --git a/Dev_Docs/deploy/servers/45.136.124.227_legacy_unavailable.md b/Dev_Docs/deploy/servers/45.136.124.227_legacy_unavailable.md index e884146..4d23f1c 100644 --- a/Dev_Docs/deploy/servers/45.136.124.227_legacy_unavailable.md +++ b/Dev_Docs/deploy/servers/45.136.124.227_legacy_unavailable.md @@ -1,7 +1,23 @@ -# Сервер `45.136.124.227` (legacy) +# Сервер `45.136.124.227` (`shineup.me`) — основной -- Исторический сервер SHiNE: `player@45.136.124.227`. -- Ранее на нём выполнялся рабочий деплой. -- Текущий статус (по данным пользователя): сервер недоступен, проблема на стороне провайдера, ответа нет второй день. +- Пользователь: `player` +- Базовый путь: `/home/player` +- Каталог SHiNE: `/home/player/SHiNE` +- UI публикация: `/home/player/SHiNE/shine-ui` +- Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` +- Данные: `/home/player/SHiNE/shine-server/data/` +- Логи сервера: `/home/player/SHiNE/shine-server/logs/app.log` -Использовать для текущих деплоев новый сервер: `player@93.170.12.154` (`shineup.me`). +## Сервисы + +- `shine-server.service` (systemd) +- `caddy.service` (systemd) + +## Caddy + +- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile` +- Для UI: + - `root * /home/player/SHiNE/shine-ui` + - `try_files {path} /index.html` (SPA fallback) + - no-cache заголовки + - `reverse_proxy /ws* -> 127.0.0.1:7070` diff --git a/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md b/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md index 5c65e68..2f44128 100644 --- a/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md +++ b/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md @@ -1,4 +1,4 @@ -# Сервер `93.170.12.154` (`shineup.me`) +# Сервер `93.170.12.154` — резервный - Пользователь: `player` - Каталог SHiNE: `/home/player/SHiNE` @@ -15,6 +15,11 @@ - `shine-server.service` (systemd) - `caddy.service` (systemd) +## Статус + +- Резервный сервер для SHiNE. +- Основной прод-сервер: `45.136.124.227` (`shineup.me`). + ## Caddy - Конфиг: `/etc/caddy/Caddyfile` diff --git a/VERSION.properties b/VERSION.properties index e40f564..99ffef1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.79 -server.version=1.2.73 +client.version=1.2.80 +server.version=1.2.74 diff --git a/build.gradle b/build.gradle index be51899..9afece9 100644 --- a/build.gradle +++ b/build.gradle @@ -182,9 +182,9 @@ tasks.register('deployServer', JavaExec) { // можно переопределить при запуске: // ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=... dependsOn shadowJar - systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247") - systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user") - systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server") + systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227") + systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player") + systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server") systemProperty "it.service", System.getProperty("it.service", "shine-server") systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar") @@ -258,10 +258,11 @@ tasks.register('startLocal', Exec) { echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL" fi + SPA_SERVER_SCRIPT="${file('scripts/local_spa_server.py').absolutePath}" if command -v python3 >/dev/null 2>&1; then - (cd "\$UI_DIR" && python3 -m http.server "\$WEB_PORT") + SHINE_UI_PORT="\$WEB_PORT" python3 "\$SPA_SERVER_SCRIPT" else - (cd "\$UI_DIR" && python -m http.server "\$WEB_PORT") + SHINE_UI_PORT="\$WEB_PORT" python "\$SPA_SERVER_SCRIPT" fi """ } diff --git a/deploy_shine-PWA.sh b/deploy_shine-PWA.sh index 1eeb83b..968b7f5 100755 --- a/deploy_shine-PWA.sh +++ b/deploy_shine-PWA.sh @@ -2,7 +2,7 @@ set -euo pipefail SRC_DIR="shine-UI" -REMOTE_HOST="${REMOTE_HOST:-player@93.170.12.154}" +REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}" REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}" ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}" @@ -54,9 +54,15 @@ echo "==> Checking SSH connectivity to $REMOTE_HOST" ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null echo "==> Validating Caddy UI root" -CADDY_ROOT_LINE="$(ssh "$REMOTE_HOST" "sudo grep -n 'root \\* ' /etc/caddy/Caddyfile | head -n 1 || true")" +CADDY_CONFIG_PATH="$(ssh "$REMOTE_HOST" "set -e; \ + exec_line=\$(systemctl show -p ExecStart caddy --value 2>/dev/null || true); \ + cfg=\$(printf '%s' \"\$exec_line\" | sed -n 's/.*--config \\([^ ]*\\).*/\\1/p' | head -n 1); \ + if [ -z \"\$cfg\" ]; then cfg=/etc/caddy/Caddyfile; fi; \ + printf '%s' \"\$cfg\"")" +CADDY_ROOT_LINE="$(ssh "$REMOTE_HOST" "sudo grep -n 'root \\* ' '$CADDY_CONFIG_PATH' | head -n 1 || true")" if [[ -n "$CADDY_ROOT_LINE" && "$CADDY_ROOT_LINE" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then echo "ERROR: Caddy root mismatch. Found: $CADDY_ROOT_LINE" >&2 + echo "Caddy config: $CADDY_CONFIG_PATH" >&2 echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2 if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2 diff --git a/scripts/local_spa_server.py b/scripts/local_spa_server.py new file mode 100644 index 0000000..0199eea --- /dev/null +++ b/scripts/local_spa_server.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import os + + +ROOT = Path(__file__).resolve().parents[1] / "shine-UI" +PORT = int(os.environ.get("SHINE_UI_PORT", "8088")) + + +class SpaHandler(SimpleHTTPRequestHandler): + def translate_path(self, path): + translated = super().translate_path(path) + rel = Path(translated).relative_to(Path.cwd()) + return str(ROOT / rel) + + def do_GET(self): + file_path = Path(self.translate_path(self.path.split("?", 1)[0])) + if file_path.exists() and file_path.is_file(): + return super().do_GET() + self.path = "/index.html" + return super().do_GET() + + +def main(): + os.chdir(ROOT) + server = ThreadingHTTPServer(("0.0.0.0", PORT), SpaHandler) + print(f"SHiNE SPA server: http://localhost:{PORT}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 1d523a3..1f48cba 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -136,6 +136,16 @@ let pwaUpdateCheckAttempted = false; let uiVersionCheckInFlight = false; let uiVersionPeriodicIntervalId = null; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; +const GUEST_ALLOWED_PAGES = new Set([ + 'start-view', + 'entry-settings-view', + 'network-view', + 'channels-list', + 'channel-view', + 'channel-thread-view', + 'user', + 'contact-search-view', +]); setClientErrorTransport((payload) => authService.reportClientUiError(payload)); setClientErrorSentNotifier((payload) => { @@ -671,7 +681,7 @@ function renderApp() { const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); - if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) { + if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) { navigate('start-view'); return; } @@ -1025,18 +1035,24 @@ async function init() { } }); - await tryAutoLogin(); - await hydrateMessagesFromStore(); - startConnectionMonitor(); - startPeriodicUiVersionCheck(); - await ensureSessionRuntimeStarted(); - + // Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"), + // а сетевые/авторизационные шаги выполняем фоном. if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') { navigate(state.session.isAuthorized ? 'messages-list' : 'start-view'); - renderApp(); - } else { - renderApp(); } + renderApp(); + + void (async () => { + try { + await tryAutoLogin(); + await hydrateMessagesFromStore(); + startConnectionMonitor(); + startPeriodicUiVersionCheck(); + await ensureSessionRuntimeStarted(); + } finally { + renderApp(); + } + })(); window.addEventListener('popstate', renderApp); document.addEventListener('visibilitychange', () => { diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 8a6efaf..593589f 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -1,5 +1,6 @@ -import { resolveToolbarActive } from '../router.js'; +import { resolveToolbarActive } from '../router.js'; import { state } from '../state.js'; +import { openAuthRequiredModal } from '../services/auth-required-modal.js'; const ITEMS = [ { pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' }, @@ -27,6 +28,35 @@ function getTotalUnreadMessages() { return total; } +function navigateWithGuestRules(pageId, navigate) { + if (state.session.isAuthorized) { + navigate(pageId); + return; + } + if (pageId === 'messages-list') { + openAuthRequiredModal({ + title: 'Личные сообщения недоступны', + text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.', + }); + return; + } + if (pageId === 'profile-view') { + openAuthRequiredModal({ + title: 'Профиль недоступен', + text: 'Вы не авторизованы. Для профиля сначала войдите в систему.', + }); + return; + } + if (pageId === 'notifications-view') { + openAuthRequiredModal({ + title: 'Уведомления недоступны', + text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.', + }); + return; + } + navigate(pageId); +} + export function renderToolbar(currentPageId, navigate) { const root = document.createElement('nav'); root.className = 'toolbar'; @@ -63,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) { if (item.pageId === 'channels-list') { installChannelsHoldSwitcher(btn, navigate); } else { - btn.addEventListener('click', () => navigate(item.pageId)); + btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate)); } root.append(btn); }); @@ -156,3 +186,4 @@ function installChannelsHoldSwitcher(button, navigate) { event.preventDefault(); }); } + diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 3dbe9b1..17001de 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -12,11 +12,66 @@ import { } from '../services/channels-ux.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { navigateBack } from '../router.js'; +import { renderUserAvatar } from '../components/avatar-image.js'; +import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; const pendingReactionActions = new Set(); const pendingThreadScroll = new Map(); +const threadAvatarSnapshotCache = new Map(); +const threadAvatarPendingByLogin = new Map(); + +async function loadThreadAvatarSnapshot(login) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) return null; + const key = cleanLogin.toLowerCase(); + if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key); + if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key); + const pending = loadProfileSnapshot(cleanLogin) + .then((snapshot) => { + threadAvatarSnapshotCache.set(key, snapshot || null); + threadAvatarPendingByLogin.delete(key); + return snapshot || null; + }) + .catch(() => { + threadAvatarSnapshotCache.set(key, null); + threadAvatarPendingByLogin.delete(key); + return null; + }); + threadAvatarPendingByLogin.set(key, pending); + return pending; +} + +function createThreadAvatar(login) { + const cleanLogin = String(login || '').trim(); + const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; + const avatarEl = renderUserAvatar({ + login: cleanLogin || 'unknown', + size: 'small', + className: 'channel-message-avatar', + title, + }); + if (!cleanLogin) return avatarEl; + void loadThreadAvatarSnapshot(cleanLogin).then((snapshot) => { + if (!avatarEl.isConnected) return; + const upgraded = renderUserAvatar({ + login: cleanLogin, + avatar: snapshot?.avatar?.txId + ? { + ar: String(snapshot.avatar.txId || '').trim(), + sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), + } + : null, + size: 'small', + className: 'channel-message-avatar', + title, + }); + avatarEl.replaceWith(upgraded); + }); + return avatarEl; +} function logThreadRuntimeError(stage, error, context = {}) { const message = String(error?.message || error || 'thread runtime error'); @@ -55,13 +110,6 @@ function looksLikeBlockchainName(value) { return /^[^-]+-\d+$/.test(raw); } -function extractLoginFromBlockchainName(value) { - const raw = String(value || '').trim(); - const match = raw.match(/^(.+)-\d+$/); - if (!match) return ''; - return String(match[1] || '').trim(); -} - function makeReactionActionKey(messageRef) { const login = String(state.session.login || '').trim().toLowerCase(); const blockchainName = String(messageRef?.blockchainName || '').trim(); @@ -200,32 +248,31 @@ async function resolveChannelDisplayNameFromServer(channelSelector) { function buildThreadRouteFromTarget(target, selector) { if (!target) return ''; - return [ - 'm', - encodeRoutePart(target.blockchainName), - target.blockNumber, - ].join('/'); + const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim(); + return makeShineMessageRoute({ + ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName), + messageBlockchainName: target.blockchainName, + messageBlockNumber: target.blockNumber, + }); } function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') { + const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim(); if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) { - return [ - 'channel', - encodeRoutePart(selector.short.ownerBlockchainName), - encodeRoutePart(selector.short.channelName), - ].join('/'); + return makeShineChannelRoute({ + ownerLogin: extractLoginFromBlockchainName(ownerBch), + ownerBlockchainName: ownerBch, + channelName: selector.short.channelName, + }); } - - const ownerBch = String(selector?.channel?.ownerBlockchainName || '').trim(); const label = String(resolvedChannelLabel || '').trim(); const slashIndex = label.indexOf('/'); const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : ''; - if (!ownerBch || !channelName) return ''; - return [ - 'channel', - encodeRoutePart(ownerBch), - encodeRoutePart(channelName), - ].join('/'); + return makeShineChannelRoute({ + ownerLogin: extractLoginFromBlockchainName(ownerBch), + ownerBlockchainName: ownerBch, + channelName, + }); } function buildTargetFromNode(node) { @@ -443,9 +490,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { authorTile.type = 'button'; authorTile.className = 'channel-message-author-tile'; - const avatar = document.createElement('div'); - avatar.className = 'channel-message-avatar'; - avatar.textContent = String(author || 'A').trim().charAt(0).toUpperCase() || 'A'; + const avatar = createThreadAvatar(author); const authorBlock = document.createElement('div'); authorBlock.className = 'channel-message-author'; @@ -591,7 +636,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { event.stopPropagation(); const login = String(node?.authorLogin || '').trim(); if (!login) return; - handlers.navigate(`user/${encodeRoutePart(login)}`); + handlers.navigate(makeProfileRoute(login)); }); card.addEventListener('click', () => { handlers.onOpenThread(target); diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 95f35f7..32a3d09 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -18,12 +18,71 @@ import { } from '../services/channels-ux.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { navigateBack } from '../router.js'; +import { renderUserAvatar } from '../components/avatar-image.js'; +import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { + extractLoginFromBlockchainName, + makeProfileRoute, + makeShineMessageRoute, +} from '../services/shine-routes.js'; export const pageMeta = { id: 'channel-view', title: 'Канал' }; const CHANNEL_TYPE_PERSONAL = 100; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); +const messageAvatarSnapshotCache = new Map(); +const messageAvatarPendingByLogin = new Map(); + +async function loadMessageAvatarSnapshot(login) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) return null; + const key = cleanLogin.toLowerCase(); + if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key); + if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key); + const pending = loadProfileSnapshot(cleanLogin) + .then((snapshot) => { + messageAvatarSnapshotCache.set(key, snapshot || null); + messageAvatarPendingByLogin.delete(key); + return snapshot || null; + }) + .catch(() => { + messageAvatarSnapshotCache.set(key, null); + messageAvatarPendingByLogin.delete(key); + return null; + }); + messageAvatarPendingByLogin.set(key, pending); + return pending; +} + +function createMessageAvatar(login) { + const cleanLogin = String(login || '').trim(); + const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; + const avatarEl = renderUserAvatar({ + login: cleanLogin || 'unknown', + size: 'small', + className: 'channel-message-avatar', + title, + }); + if (!cleanLogin) return avatarEl; + void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => { + if (!avatarEl.isConnected) return; + const upgraded = renderUserAvatar({ + login: cleanLogin, + avatar: snapshot?.avatar?.txId + ? { + ar: String(snapshot.avatar.txId || '').trim(), + sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), + } + : null, + size: 'small', + className: 'channel-message-avatar', + title, + }); + avatarEl.replaceWith(upgraded); + }); + return avatarEl; +} function isChannelsDemoMode() { try { @@ -61,13 +120,6 @@ function looksLikeBlockchainName(value) { return /^[^-]+-\d+$/.test(raw); } -function extractLoginFromBlockchainName(value) { - const raw = String(value || '').trim(); - const match = raw.match(/^(.+)-\d+$/); - if (!match) return ''; - return String(match[1] || '').trim(); -} - function makeReactionActionKey(messageRef) { const login = String(state.session.login || '').trim().toLowerCase(); const blockchainName = String(messageRef?.blockchainName || '').trim(); @@ -148,11 +200,12 @@ function buildSelectorFromRoute(route, channelId) { function buildThreadRoute(messageRef, selector) { if (!messageRef || !selector) return ''; - return [ - 'm', - encodeRoutePart(messageRef.blockchainName), - messageRef.blockNumber, - ].join('/'); + const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName); + return makeShineMessageRoute({ + ownerLogin, + messageBlockchainName: messageRef.blockchainName, + messageBlockNumber: messageRef.blockNumber, + }); } function firstNonEmptyText(...candidates) { @@ -497,10 +550,16 @@ function mapApiMessageToPost(message, selector, localNumber) { } async function loadFromApi(route, channelId) { + const currentSessionLogin = String(state.session.login || '').trim(); + const isAuthorized = !!currentSessionLogin; let cachedFeed = null; const ensureFeed = async () => { if (cachedFeed) return cachedFeed; - cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000); + if (!isAuthorized) { + cachedFeed = {}; + return cachedFeed; + } + cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000); return cachedFeed; }; const getAllRows = async () => { @@ -517,34 +576,39 @@ async function loadFromApi(route, channelId) { const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw); - const allRows = await getAllRows(); - let channel = allRows.find((item) => ( - String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized - && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() - )); - if (!channel) { + let channel = null; + if (isAuthorized) { + const allRows = await getAllRows(); channel = allRows.find((item) => ( - String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized + String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() )); - } - if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) { - try { - const ownerUser = await authService.getUser(routeOwnerRaw); - const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase(); - if (ownerBch) { - channel = allRows.find((item) => ( - String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch - && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() - )); + if (!channel) { + channel = allRows.find((item) => ( + String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized + && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + )); + } + if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) { + try { + const ownerUser = await authService.getUser(routeOwnerRaw); + const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase(); + if (ownerBch) { + channel = allRows.find((item) => ( + String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch + && String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase() + )); + } + } catch { + // ignore fallback lookup failures } - } catch { - // ignore fallback lookup failures } } - if (!channel && routeOwnerLoginFromBch) { + if (!channel) { + const ownerLoginForLookup = routeOwnerLoginFromBch || (!looksLikeBlockchainName(routeOwnerRaw) ? routeOwnerRaw : ''); + if (ownerLoginForLookup) { try { - const ownerFeed = await authService.listSubscriptionsFeed(routeOwnerLoginFromBch, 500); + const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500); const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; channel = ownerRows.find((item) => ( String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized @@ -554,6 +618,7 @@ async function loadFromApi(route, channelId) { // ignore owner feed lookup failures } } + } if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) { throw new Error('Канал не найден.'); } @@ -569,12 +634,12 @@ async function loadFromApi(route, channelId) { throw new Error('Не удалось определить канал из адреса страницы.'); } - const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login); + const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin); const messages = Array.isArray(payload.messages) ? payload.messages : []; let reverseChannelMissingWarning = ''; let mergedMessages = [...messages]; - const currentLogin = String(state.session.login || '').trim(); + const currentLogin = currentSessionLogin; const ownerLogin = String(payload.channel?.ownerLogin || '').trim(); const channelName = String(payload.channel?.channelName || '').trim(); const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1); @@ -600,7 +665,7 @@ async function loadFromApi(route, channelId) { channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber), channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash), }; - const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login); + const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin); const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : []; mergedMessages = mergedMessages.concat(reverseMessages); } else { @@ -618,9 +683,9 @@ async function loadFromApi(route, channelId) { return aNum - bNum; }) .map((post, index) => ({ ...post, localNumber: index + 1 })); - const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(); + const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase(); const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; - const isSubscribed = followedRows.some((row) => ( + const isSubscribed = isAuthorized && followedRows.some((row) => ( String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '') && Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber) && normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash) @@ -682,17 +747,31 @@ function renderDemoFallback(screen, navigate, error) { screen.append(back); } -function applyPendingScroll(screen, routeKey) { +function scrollChannelToBottom(screen, smooth = true) { + const feed = screen.querySelector('.channel-feed'); + if (feed) { + feed.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' }); + } + const appScreen = document.getElementById('app-screen'); + if (appScreen) { + appScreen.scrollTo({ top: appScreen.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); + return; + } + window.scrollTo({ top: document.body.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); +} + +function applyPendingScroll(screen, routeKey, forceBottom = false) { const target = pendingScrollByRoute.get(routeKey); - if (!target) return; + if (!target && !forceBottom) return; const doScroll = () => { + if (!target && forceBottom) { + scrollChannelToBottom(screen, false); + return; + } + if (target === '__LAST__') { - const cards = screen.querySelectorAll('[data-message-key]'); - const last = cards[cards.length - 1]; - if (last) { - last.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } + scrollChannelToBottom(screen, true); pendingScrollByRoute.delete(routeKey); return; } @@ -724,9 +803,7 @@ function renderPostCard(post, { authorTile.type = 'button'; authorTile.className = 'channel-message-author-tile'; - const avatar = document.createElement('div'); - avatar.className = 'channel-message-avatar'; - avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A'; + const avatar = createMessageAvatar(post.authorLogin); const authorBlock = document.createElement('div'); authorBlock.className = 'channel-message-author'; @@ -768,7 +845,7 @@ function renderPostCard(post, { event.stopPropagation(); const cleanLogin = String(post.authorLogin || '').trim(); if (!cleanLogin) return; - navigate(`user/${encodeRoutePart(cleanLogin)}`); + navigate(makeProfileRoute(cleanLogin)); }); const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено'; @@ -888,10 +965,8 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { } const actionButton = document.createElement('button'); - actionButton.className = channelData.isOwnChannel - ? 'primary-btn channel-main-action' - : 'destructive-btn channel-main-action'; - actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал'; + actionButton.className = 'destructive-btn channel-main-action'; + actionButton.textContent = 'Подписаться на канал'; const feed = document.createElement('div'); feed.className = 'stack channel-feed'; @@ -921,16 +996,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { } - if (channelData.isOwnChannel) { - actionButton.addEventListener('click', (event) => { - animatePress(event.currentTarget); - openAddMessageModal({ - channelName: channelData.channel.name, - navigate, - onSubmit: async (bodyText) => handlers.onAddPost(bodyText), - }); - }); - } else if (!channelData.isSubscribed) { + if (!channelData.isSubscribed) { actionButton.addEventListener('click', handlers.onSubscribeChannel); } @@ -939,13 +1005,15 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { backButton.textContent = 'Назад к каналам'; backButton.addEventListener('click', () => navigate('channels-list')); - if (channelData.isOwnChannel || !channelData.isSubscribed) { + if (channelData.isOwnChannel) { + screen.append(feed); + } else if (!channelData.isSubscribed) { screen.append(actionButton, feed, backButton); } else { screen.append(feed, backButton); } - applyPendingScroll(screen, routeKey); + applyPendingScroll(screen, routeKey, channelData.isOwnChannel); return () => { // noop }; @@ -1121,14 +1189,40 @@ export function render({ navigate, route }) { const apiData = await loadFromApi(route, channelId); activeSelector = apiData?.selector || null; const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`; + const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`; if (channelHeaderButton) { - channelHeaderButton.textContent = channelRouteLabel; + channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel; channelHeaderButton.disabled = false; channelHeaderButton.onclick = (event) => { animatePress(event.currentTarget); openAboutChannelModal(apiData.channel); }; } + if (apiData?.isOwnChannel) { + const headerActions = header.querySelector('.header-actions'); + if (headerActions) { + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'icon-btn channel-header-add-btn'; + addBtn.textContent = 'Добавить сообщение'; + addBtn.addEventListener('click', (event) => { + animatePress(event.currentTarget); + openAddMessageModal({ + channelName: apiData?.channel?.name || '', + navigate, + onSubmit: async (bodyText) => { + try { + await onAddPost(bodyText); + showStatus(''); + } catch (error) { + throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.')); + } + }, + }); + }); + headerActions.append(addBtn); + } + } skeleton.remove(); cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, { onToggleLike: async (messageRef, action) => { diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 7f60638..ed12df1 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -10,6 +10,7 @@ import { softHaptic, writeChannelNotificationsState, } from '../services/channels-ux.js'; +import { makeShineChannelRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'channels-list', title: 'Каналы' }; @@ -43,12 +44,14 @@ function normalizeLoginInput(value) { } function buildChannelRouteFromSummary(summary, fallbackId) { - const ownerBch = summary?.channel?.ownerBlockchainName; + const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim(); + const ownerLogin = String(summary?.channel?.ownerLogin || '').trim(); const channelName = String(summary?.channel?.channelName || '').trim(); - if (ownerBch && channelName) { - return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`; - } - return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`; + return makeShineChannelRoute({ + ownerLogin, + ownerBlockchainName: ownerBch, + channelName: channelName || fallbackId, + }); } function avatarLetterFromName(name = '') { @@ -408,7 +411,7 @@ function openChannelFinderModal({ navigate }) { `; - row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`)); + row.addEventListener('click', () => { + const route = channel.route || makeShineChannelRoute({ + ownerLogin: String(channel.ownerName || 'channel'), + ownerBlockchainName: String(channel.ownerName || ''), + channelName: String(channel.channelName || channel.id), + }); + if (route) navigate(route); + }); list.append(row); }); @@ -996,35 +1017,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed const main = renderChannelMain(channel, activeTab); + const isGuest = !state.session.isAuthorized; const controls = document.createElement('div'); controls.className = 'channel-row-controls'; - const menuButton = document.createElement('button'); - menuButton.type = 'button'; - menuButton.className = 'channel-menu-trigger'; - menuButton.textContent = '…'; - menuButton.addEventListener('click', (event) => { - event.stopPropagation(); - animatePress(menuButton); - listState.revealedCounters.add(channel.id); - - if (listState.openMenuId === channel.id) { - closeChannelMenu(listState); - rerenderList(); - return; - } - - listState.openMenuId = channel.id; - openChannelMenu({ - listState, - channel, - anchorEl: menuButton, - refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }), - rerenderList, - }); - rerenderList(); - }); - const time = document.createElement('span'); time.className = 'channel-row-time'; time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—'; @@ -1035,10 +1031,45 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed count.textContent = unreadCount > 0 ? String(unreadCount) : ''; count.classList.toggle('is-empty', unreadCount <= 0); - controls.append(menuButton, time, count); + if (!isGuest) { + const menuButton = document.createElement('button'); + menuButton.type = 'button'; + menuButton.className = 'channel-menu-trigger'; + menuButton.textContent = '…'; + menuButton.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(menuButton); + listState.revealedCounters.add(channel.id); + + if (listState.openMenuId === channel.id) { + closeChannelMenu(listState); + rerenderList(); + return; + } + + listState.openMenuId = channel.id; + openChannelMenu({ + listState, + channel, + anchorEl: menuButton, + refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }), + rerenderList, + }); + rerenderList(); + }); + controls.append(menuButton); + } + controls.append(time, count); row.append(avatar, main, controls); - row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`)); + row.addEventListener('click', () => { + const route = channel.route || makeShineChannelRoute({ + ownerLogin: String(channel.ownerName || 'channel'), + ownerBlockchainName: String(channel.ownerName || ''), + channelName: String(channel.channelName || channel.id), + }); + if (route) navigate(route); + }); list.append(row); }); @@ -1057,9 +1088,9 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) { } if (tab === 'my') { - button.textContent = 'Создать канал'; + button.textContent = 'Найти канал'; button.className = baseClass; - button.onclick = () => navigate('add-channel-view'); + button.onclick = () => openChannelFinderModal({ navigate }); return; } @@ -1072,8 +1103,20 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { closeChannelMenu(listState); renderSkeletonList(contentEl, 5); + if (!state.session.isAuthorized) { + setChannelsFeed(null, {}); + listState.channels = []; + renderListContent({ + screen, + container: contentEl, + listState, + navigate, + refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }), + }); + return; + } + try { - if (!state.session.login) throw new Error('not_authorized'); const feed = await authService.listSubscriptionsFeed(state.session.login, 200); const groups = mapApiFeed(feed, listState.notificationsState); @@ -1109,6 +1152,7 @@ export function render({ navigate, route }) { const createSuccessFlash = pullCreateSuccessFlash(); const notificationsState = readChannelNotificationsState(); + const isGuest = !state.session.isAuthorized; const listState = { activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim()) ? String(route?.params?.mode).trim() @@ -1119,10 +1163,16 @@ export function render({ navigate, route }) { channels: [], menuCleanup: null, }; + if (isGuest && listState.activeTab === 'my') { + listState.activeTab = 'feed'; + } const contentEl = document.createElement('div'); contentEl.className = 'channels-list-content'; + const topBarEl = document.createElement('div'); + topBarEl.className = 'channels-top-bar'; + const tabsEl = document.createElement('div'); tabsEl.className = 'channels-tabs'; const tabLabels = { @@ -1130,6 +1180,7 @@ export function render({ navigate, route }) { my: 'Мои каналы', }; TAB_ORDER.forEach((tabKey) => { + if (isGuest && tabKey === 'my') return; const tabBtn = document.createElement('button'); tabBtn.type = 'button'; tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`; @@ -1142,6 +1193,15 @@ export function render({ navigate, route }) { tabsEl.append(tabBtn); }); + const topActionBtn = document.createElement('button'); + topActionBtn.type = 'button'; + topActionBtn.className = 'secondary-btn channels-top-action-btn'; + topActionBtn.textContent = 'Создать канал'; + topActionBtn.addEventListener('click', () => navigate('add-channel-view')); + if (isGuest) topActionBtn.style.display = 'none'; + + topBarEl.append(tabsEl, topActionBtn); + const bottomCta = document.createElement('button'); bottomCta.type = 'button'; @@ -1169,6 +1229,9 @@ export function render({ navigate, route }) { refreshFeed: reloadFeed, }); + const showCreate = !isGuest && listState.activeTab === 'my'; + topActionBtn.style.display = showCreate ? '' : 'none'; + updateBottomCta({ button: bottomCta, listState, @@ -1202,7 +1265,7 @@ export function render({ navigate, route }) { rerenderList(); }, { passive: true }); - screen.append(tabsEl, contentEl, bottomCta); + screen.append(topBarEl, contentEl, bottomCta); if (createSuccessFlash) { showToast(createSuccessFlash); diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js index 77cb8c8..2c504c9 100644 --- a/shine-UI/js/pages/contact-search-view.js +++ b/shine-UI/js/pages/contact-search-view.js @@ -1,7 +1,62 @@ import { renderHeader } from '../components/header.js'; import { authService } from '../state.js'; +import { renderUserAvatar } from '../components/avatar-image.js'; +import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { makeProfileRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' }; +const searchAvatarSnapshotCache = new Map(); +const searchAvatarPendingByLogin = new Map(); + +async function loadSearchAvatarSnapshot(login) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) return null; + const key = cleanLogin.toLowerCase(); + if (searchAvatarSnapshotCache.has(key)) return searchAvatarSnapshotCache.get(key); + if (searchAvatarPendingByLogin.has(key)) return searchAvatarPendingByLogin.get(key); + const pending = loadProfileSnapshot(cleanLogin) + .then((snapshot) => { + searchAvatarSnapshotCache.set(key, snapshot || null); + searchAvatarPendingByLogin.delete(key); + return snapshot || null; + }) + .catch(() => { + searchAvatarSnapshotCache.set(key, null); + searchAvatarPendingByLogin.delete(key); + return null; + }); + searchAvatarPendingByLogin.set(key, pending); + return pending; +} + +function createSearchAvatar(login) { + const cleanLogin = String(login || '').trim(); + const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; + const avatarEl = renderUserAvatar({ + login: cleanLogin || 'unknown', + size: 'small', + className: 'avatar', + title, + }); + if (!cleanLogin) return avatarEl; + void loadSearchAvatarSnapshot(cleanLogin).then((snapshot) => { + if (!avatarEl.isConnected) return; + const upgraded = renderUserAvatar({ + login: cleanLogin, + avatar: snapshot?.avatar?.txId + ? { + ar: String(snapshot.avatar.txId || '').trim(), + sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), + } + : null, + size: 'small', + className: 'avatar', + title, + }); + avatarEl.replaceWith(upgraded); + }); + return avatarEl; +} export function render({ navigate }) { const screen = document.createElement('section'); @@ -44,16 +99,17 @@ export function render({ navigate }) { matches.forEach((login) => { const row = document.createElement('article'); row.className = 'list-item dm-dialog-card'; + const avatarEl = createSearchAvatar(login); row.innerHTML = ` -
${(login[0] || '?').toUpperCase()}
${login}

Пользователь сервера

Профиль
`; + row.prepend(avatarEl); row.addEventListener('click', () => { - navigate(`user/${encodeURIComponent(login)}/contact-search-view`); + navigate(makeProfileRoute(login)); }); resultsList.append(row); }); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 3ee0e5d..36380a1 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -8,8 +8,61 @@ import { terminateCurrentSession, } from '../state.js'; import { loadCurrentRelations } from '../services/user-connections.js'; +import { renderUserAvatar } from '../components/avatar-image.js'; +import { loadProfileSnapshot } from '../services/user-profile-params.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; +const dmAvatarSnapshotCache = new Map(); +const dmAvatarPendingByLogin = new Map(); + +async function loadDmAvatarSnapshot(login) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) return null; + const key = cleanLogin.toLowerCase(); + if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key); + if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key); + const pending = loadProfileSnapshot(cleanLogin) + .then((snapshot) => { + dmAvatarSnapshotCache.set(key, snapshot || null); + dmAvatarPendingByLogin.delete(key); + return snapshot || null; + }) + .catch(() => { + dmAvatarSnapshotCache.set(key, null); + dmAvatarPendingByLogin.delete(key); + return null; + }); + dmAvatarPendingByLogin.set(key, pending); + return pending; +} + +function createDmAvatar(login) { + const cleanLogin = String(login || '').trim(); + const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; + const avatarEl = renderUserAvatar({ + login: cleanLogin || 'unknown', + size: 'small', + title, + }); + if (!cleanLogin) return avatarEl; + void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { + if (!avatarEl.isConnected) return; + const upgraded = renderUserAvatar({ + login: cleanLogin, + avatar: snapshot?.avatar?.txId + ? { + ar: String(snapshot.avatar.txId || '').trim(), + sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), + } + : null, + size: 'small', + title, + }); + upgraded.classList.add('avatar'); + avatarEl.replaceWith(upgraded); + }); + return avatarEl; +} function formatChatRowTime(ts) { const value = Number(ts || 0); @@ -40,8 +93,9 @@ export function render({ navigate }) { function renderRow(item) { const row = document.createElement('article'); row.className = 'list-item dm-dialog-card'; + const avatarEl = createDmAvatar(item.id); + avatarEl.classList.add('avatar'); row.innerHTML = ` -
${item.initials}
${item.name} @@ -54,6 +108,7 @@ export function render({ navigate }) { ${item.time}
`; + row.prepend(avatarEl); row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`)); return row; } @@ -73,7 +128,6 @@ export function render({ navigate }) { const lastTimeMs = Number(lastChat?.createdAtMs || 0); return { id: login, - initials: (login[0] || '?').toUpperCase(), name: preview?.name || login, lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.', time: formatChatRowTime(lastTimeMs), @@ -96,7 +150,6 @@ export function render({ navigate }) { const lastTimeMs = Number(lastChat?.createdAtMs || 0); return { id: login, - initials: (login[0] || '?').toUpperCase(), name: login, lastMessage: lastChat?.text || 'Диалог пока пуст.', time: formatChatRowTime(lastTimeMs), diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 833f0bb..2d82933 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -2,6 +2,8 @@ import { renderHeader } from '../components/header.js'; import { authService, state } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { loadUserProfileCard } from '../services/user-connections.js'; +import { makeProfileRoute } from '../services/shine-routes.js'; +import { makeProfileLinksRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'network-view', title: 'Связи' }; @@ -14,6 +16,14 @@ function normalizeLogin(value) { return String(value || '').trim(); } +function createDebounced(fn, delayMs = 2000) { + let timer = 0; + return (...args) => { + if (timer) window.clearTimeout(timer); + timer = window.setTimeout(() => fn(...args), delayMs); + }; +} + function normKey(value) { return normalizeLogin(value).toLowerCase(); } @@ -507,6 +517,7 @@ let persistedCenterHistory = []; export function render({ navigate, route }) { const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history'; + const routeLogin = normalizeLogin(route?.params?.login || ''); if (!keepHistory) { persistedCenterLogin = ''; persistedCenterHistory = []; @@ -533,7 +544,7 @@ export function render({ navigate, route }) { const cleanLogin = normalizeLogin(login); if (!cleanLogin) return ''; if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view'; - return `user/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`; + return makeProfileRoute(cleanLogin); } function helpText() { @@ -551,6 +562,15 @@ export function render({ navigate, route }) { persistedCenterHistory = [...centerHistory]; } + function syncLinksUrl(login, { push = false } = {}) { + const clean = normalizeLogin(login); + if (!clean) return; + const nextPath = `/${makeProfileLinksRoute(clean)}`; + if (window.location.pathname === nextPath) return; + if (push) window.history.pushState({}, '', nextPath); + else window.history.replaceState({}, '', nextPath); + } + function setBackButtonState(backBtn) { if (!(backBtn instanceof HTMLButtonElement)) return; backBtn.disabled = centerHistory.length === 0; @@ -568,13 +588,8 @@ export function render({ navigate, route }) {
-
Введите логин и нажмите «Искать».
+
Введите логин. Поиск начнётся автоматически через 2 секунды.
-
- - -
- `; @@ -585,9 +600,6 @@ export function render({ navigate, route }) { const runBtn = root.querySelector('#network-search-run'); const metaEl = root.querySelector('#network-search-meta'); const resultsEl = root.querySelector('#network-search-results'); - const profileBtn = root.querySelector('#network-search-profile'); - const graphBtn = root.querySelector('#network-search-graph'); - const okBtn = root.querySelector('#network-search-ok'); if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) { root.innerHTML = ''; return; @@ -607,10 +619,6 @@ export function render({ navigate, route }) { if (!(row instanceof HTMLElement)) return; row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin); }); - const hasSelected = Boolean(selectedLogin); - if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected; - if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected; - if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected; }; const renderCandidates = (logins) => { @@ -661,6 +669,8 @@ export function render({ navigate, route }) { }); closeBtn?.addEventListener('click', close); runBtn?.addEventListener('click', () => { void runSearch(); }); + const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000); + inputEl.addEventListener('input', debouncedSearch); inputEl.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); @@ -672,23 +682,11 @@ export function render({ navigate, route }) { if (!(target instanceof HTMLElement)) return; const button = target.closest('[data-candidate]'); if (!(button instanceof HTMLElement)) return; - applySelection(String(button.dataset.candidate || '')); - }); - profileBtn?.addEventListener('click', () => { - if (!selectedLogin) return; - const routeTo = profileInfoRoute(selectedLogin); - if (!routeTo) return; + const nextLogin = String(button.dataset.candidate || ''); + applySelection(nextLogin); + if (!nextLogin) return; close(); - navigate(routeTo); - }); - graphBtn?.addEventListener('click', () => { - if (!selectedLogin) return; - close(); - void load(selectedLogin, { pushHistory: true }); - }); - okBtn?.addEventListener('click', () => { - if (!selectedLogin) return; - metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`; + void load(nextLogin, { pushHistory: true }); }); window.setTimeout(() => inputEl.focus(), 0); @@ -765,6 +763,7 @@ export function render({ navigate, route }) { if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) { centerHistory.push(prevCenter); } + syncLinksUrl(targetCenter, { push: pushHistory }); const model = buildGraphModel(graph, targetCenter); const layout = layoutNodes(model); @@ -839,13 +838,22 @@ export function render({ navigate, route }) { appScreenEl?.classList.remove('network-scroll-lock'); }; - if (keepHistory && centerLogin) { + if (routeLogin) { + centerLogin = routeLogin; + centerHistory = []; + persistHistory(); + void load(centerLogin, { pushHistory: false }); + } else if (keepHistory && centerLogin) { void load(centerLogin, { pushHistory: false }); } else { centerLogin = normalizeLogin(state.session.login || ''); centerHistory = []; persistHistory(); - void load(centerLogin, { pushHistory: false }); + if (centerLogin) { + void load(centerLogin, { pushHistory: false }); + } else { + window.setTimeout(() => openSearchModal(), 0); + } } setBackButtonState(backBtnEl); diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index 4204d46..e71a4f1 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -7,6 +7,7 @@ import { } from '../services/user-profile-params.js'; import { buildIdentityLines } from '../services/user-connections.js'; import { renderUserAvatar } from '../components/avatar-image.js'; +import { makeProfileLinksRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'profile-view', title: 'Профиль' }; @@ -29,6 +30,40 @@ function escapeHtml(text) { .replaceAll("'", '''); } +function openProfileInfoModal({ title, text }) { + const root = document.getElementById('modal-root'); + if (!root) return; + root.innerHTML = ` + + `; + const close = () => { root.innerHTML = ''; }; + root.querySelector('#profile-info-close')?.addEventListener('click', close); + root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => { + if (event.target?.id === 'profile-info-modal') close(); + }); +} + +function officialInfoText() { + return 'Можно создавать несколько альтернативных или анонимных каналов. ' + + 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.'; +} + +function shineInfoText() { + return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n' + + 'Пять принципов сияющих:\n' + + '1) сияющие не обманывают;\n' + + '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n' + + '3) сияющие развиваются и в духовной, и в материальной плоскости;\n' + + '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n' + + '5) сияющие заботятся о мире: о людях, гармонии и общем благе.'; +} + export function render({ navigate }) { const login = state.session.login || profile.login; @@ -39,14 +74,22 @@ export function render({ navigate }) { topActions.className = 'profile-top-actions'; topActions.innerHTML = ` - `; topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view')); - topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view')); topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view')); screen.append(topActions); + const bottomActions = document.createElement('div'); + bottomActions.className = 'profile-bottom-actions'; + bottomActions.innerHTML = ` + + + `; + bottomActions.querySelector('[data-bottom-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view')); + bottomActions.querySelector('[data-bottom-action="links"]')?.addEventListener('click', () => navigate(makeProfileLinksRoute(login))); + screen.append(bottomActions); + const card = document.createElement('div'); card.className = 'card stack profile-main-card'; @@ -126,6 +169,21 @@ export function render({ navigate }) { updateToggleButton(shineBtn, 'Сияющий', shine.enabled); } + officialBtn?.classList.add('profile-badge-trigger'); + shineBtn?.classList.add('profile-badge-trigger'); + officialBtn?.addEventListener('click', () => { + openProfileInfoModal({ + title: 'Официальный канал', + text: officialInfoText(), + }); + }); + shineBtn?.addEventListener('click', () => { + openProfileInfoModal({ + title: 'Справка о сияющих', + text: shineInfoText(), + }); + }); + function renderFields(fields) { listWrap.innerHTML = ''; fields.forEach((field) => { diff --git a/shine-UI/js/pages/start-view.js b/shine-UI/js/pages/start-view.js index 1695530..56748f2 100644 --- a/shine-UI/js/pages/start-view.js +++ b/shine-UI/js/pages/start-view.js @@ -32,13 +32,19 @@ export function render({ navigate }) { registerButton.textContent = 'Зарегистрироваться'; registerButton.addEventListener('click', () => navigate('register-view')); + const guestViewButton = document.createElement('button'); + guestViewButton.className = 'ghost-btn'; + guestViewButton.type = 'button'; + guestViewButton.textContent = 'Только просмотр'; + guestViewButton.addEventListener('click', () => navigate('network-view')); + const settingsButton = document.createElement('button'); settingsButton.className = 'ghost-btn'; settingsButton.type = 'button'; settingsButton.textContent = 'Настройки'; settingsButton.addEventListener('click', () => navigate('entry-settings-view')); - actions.append(loginButton, registerButton, settingsButton); + actions.append(loginButton, registerButton, guestViewButton, settingsButton); screen.append(logo, title, actions); return screen; } diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js index 818f9dc..3236951 100644 --- a/shine-UI/js/pages/user-profile-view.js +++ b/shine-UI/js/pages/user-profile-view.js @@ -6,6 +6,7 @@ import { loadUserProfileCard, } from '../services/user-connections.js'; import { renderUserAvatar } from '../components/avatar-image.js'; +import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { navigateBack } from '../router.js'; @@ -20,6 +21,40 @@ function escapeHtml(text) { .replaceAll("'", '''); } +function openProfileInfoModal({ title, text }) { + const root = document.getElementById('modal-root'); + if (!root) return; + root.innerHTML = ` + + `; + const close = () => { root.innerHTML = ''; }; + root.querySelector('#profile-info-close')?.addEventListener('click', close); + root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => { + if (event.target?.id === 'profile-info-modal') close(); + }); +} + +function officialInfoText() { + return 'Можно создавать несколько альтернативных или анонимных каналов. ' + + 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.'; +} + +function shineInfoText() { + return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n' + + 'Пять принципов сияющих:\n' + + '1) сияющие не обманывают;\n' + + '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n' + + '3) сияющие развиваются и в духовной, и в материальной плоскости;\n' + + '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n' + + '5) сияющие заботятся о мире: о людях, гармонии и общем благе.'; +} + function genderText(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'male') return 'Мужской'; @@ -141,8 +176,8 @@ function renderIdentity(card) { function renderReadOnlyBadges(card) { return `
- Официальный: ${card.official ? 'Yes' : 'No'} - Сияющий: ${card.shine ? 'Yes' : 'No'} + +
`; } @@ -157,7 +192,7 @@ function renderRelations(flags) { const hasOpinion = opinionItems.length > 0; return ` -
+
${rows.map((row) => `
${escapeHtml(row.text)} @@ -174,7 +209,7 @@ function renderRelations(flags) {
${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'} - +
`; @@ -191,7 +226,7 @@ function openOpinionMenuModal({ flags, onApply }) { ]; const rowsHtml = items .filter((item) => item.kind !== activeKind) - .map((item) => ``) + .map((item) => ``) .join(''); const removeHtml = activeKind ? `` @@ -200,7 +235,7 @@ function openOpinionMenuModal({ flags, onApply }) { root.innerHTML = `