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 }) {
Поиск каналов
Введите логин (или начало логина), затем выберите пользователя и канал.
-
+
@@ -463,8 +466,12 @@ function openChannelFinderModal({ navigate }) {
openBtn.textContent = 'Просмотреть';
openBtn.addEventListener('click', () => {
close();
- const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim();
- navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`);
+ const route = makeShineChannelRoute({
+ ownerLogin: String(item.ownerLogin || '').trim(),
+ ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
+ channelName: String(item.channelName || '').trim(),
+ });
+ if (route) navigate(route);
});
row.style.display = 'flex';
@@ -582,7 +589,11 @@ function openChannelFinderModal({ navigate }) {
function mapMockGroups() {
const mapRow = (channel) => ({
...channel,
- route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`,
+ route: makeShineChannelRoute({
+ ownerLogin: String(channel.ownerName || 'channel'),
+ ownerBlockchainName: String(channel.ownerName || ''),
+ channelName: String(channel.channelName || channel.title || channel.id),
+ }),
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal'
? 'my'
: 'feed',
@@ -683,6 +694,9 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
+ if (!state.session.isAuthorized) {
+ return wrap;
+ }
const text = document.createElement('p');
text.className = 'meta-muted';
if (activeTab === 'feed') {
@@ -763,7 +777,14 @@ function renderDemoFallback(container, navigate, error, onRetry) {
—
`;
- 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 секунды.
-
- Показать профиль
- Показать связи
-
- OK
`;
@@ -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 = `
+
+
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
Закрыть
+
+
+ `;
+ 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 = `
+ Кошелёк
+ Показать\nсвязи
+ `;
+ 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 = `
+
+
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
Закрыть
+
+
+ `;
+ 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'}
+ Официальный: ${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 ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}
- ${hasOpinion ? 'Изменить связи' : 'Добавить связь'}
+ ${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}
`;
@@ -191,7 +226,7 @@ function openOpinionMenuModal({ flags, onApply }) {
];
const rowsHtml = items
.filter((item) => item.kind !== activeKind)
- .map((item) => `
Добавить: ${item.title} `)
+ .map((item) => `
Высказать: ${item.title} `)
.join('');
const removeHtml = activeKind
? `
Убрать мнение `
@@ -200,7 +235,7 @@ function openOpinionMenuModal({ flags, onApply }) {
root.innerHTML = `
-
${activeKind ? 'Изменить связи' : 'Добавить связь'}
+
${activeKind ? 'Изменить мнение' : 'Добавить мнение'}
${rowsHtml}${removeHtml}
Закрыть
@@ -262,11 +297,13 @@ export function render({ navigate, route }) {
renderHeader({
title: 'Профиль пользователя',
leftAction: { label: '←', onClick: () => navigateBack() },
- rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
+ rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
}),
status,
body,
);
+ const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
+ linksHeaderBtn?.classList.add('profile-links-header-btn');
let currentCard = null;
let currentFlags = null;
@@ -285,7 +322,7 @@ export function render({ navigate, route }) {
contactBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
followBtn.disabled = Boolean(isSelf);
- opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить связи' : 'Добавить связь';
+ opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
opinionBtn.disabled = Boolean(isSelf);
}
@@ -321,6 +358,10 @@ export function render({ navigate, route }) {
body.prepend(identityCard);
syncActionButtons();
+ if (String(route?.params?.section || '').toLowerCase() === 'links') {
+ const rel = body.querySelector('[data-profile-relations="true"]');
+ rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
status.className = 'status-line is-available';
status.textContent = 'Профиль обновлён.';
} catch (error) {
@@ -418,6 +459,17 @@ export function render({ navigate, route }) {
});
}
await refresh();
+ if (mode === 'set') {
+ const opinionVisible = Boolean(
+ currentFlags?.outKnownPerson
+ || currentFlags?.outShineConfirmed
+ || currentFlags?.outShineSeen,
+ );
+ if (!opinionVisible) {
+ await new Promise((resolve) => window.setTimeout(resolve, 350));
+ await refresh();
+ }
+ }
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
@@ -429,6 +481,22 @@ export function render({ navigate, route }) {
body.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
+ const infoBtn = target.closest('[data-profile-info]');
+ const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
+ if (infoKind === 'official') {
+ openProfileInfoModal({
+ title: 'Официальный канал',
+ text: officialInfoText(),
+ });
+ return;
+ }
+ if (infoKind === 'shine') {
+ openProfileInfoModal({
+ title: 'Справка о сияющих',
+ text: shineInfoText(),
+ });
+ return;
+ }
const actionBtn = target.closest('[data-relation-action]');
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
if (!kind) return;
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index f653ffd..b9f5d61 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -1,3 +1,5 @@
+import { parseShineRootSegment } from './services/shine-routes.js';
+
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
export const PRE_AUTH_PAGES = [
@@ -20,9 +22,7 @@ export function getRoute() {
.replace(/^index\.html$/i, '')
.replace(/^index\.html\//i, '')
.replace(/\/+$/, '');
- if (!raw) {
- return { pageId: '', params: {} };
- }
+ if (!raw) return { pageId: '', params: {} };
const segments = raw.split('/').filter(Boolean);
const pageId = segments[0] || '';
@@ -36,6 +36,73 @@ export function getRoute() {
}
};
+ const shineLogin = parseShineRootSegment(pageId);
+ if (shineLogin) {
+ const section = decodePart(segments[1] || '').toLowerCase();
+ if (!section) {
+ return { pageId: 'user', params: { login: shineLogin, fromPage: 'messages-list', section: 'profile' } };
+ }
+ if (section === 'links') {
+ return { pageId: 'network-view', params: { mode: 'keep-history', login: shineLogin } };
+ }
+ if (section === 'channels') {
+ const sub = decodePart(segments[2] || '').toLowerCase();
+ if (sub === 'owned') {
+ return {
+ pageId: 'channels-list',
+ params: { mode: 'my', login: shineLogin, scope: 'owned' },
+ };
+ }
+ if (sub === 'following') {
+ return {
+ pageId: 'channels-list',
+ params: { mode: 'feed', login: shineLogin, scope: 'following' },
+ };
+ }
+ return {
+ pageId: 'channels-list',
+ params: { mode: 'feed', login: shineLogin, scope: 'all' },
+ };
+ }
+ if (section === 'msg') {
+ return {
+ pageId: 'channel-thread-view',
+ params: {
+ messageBlockchainName: decodePart(segments[2]),
+ messageBlockNumber: segments[3] || '',
+ messageBlockHash: '',
+ channelOwnerBlockchainName: '',
+ channelRootBlockNumber: '',
+ channelRootBlockHash: '',
+ },
+ };
+ }
+ if (section === 'channel') {
+ const ownerBlockchainName = decodePart(segments[2] || '');
+ const channelName = decodePart(segments[3] || '');
+ const messageBlockNumber = segments[4] || '';
+ if (ownerBlockchainName && channelName && messageBlockNumber) {
+ return {
+ pageId: 'channel-thread-view',
+ params: {
+ ownerBlockchainName,
+ channelName,
+ messageBlockNumber,
+ messageBlockHash: '',
+ messageBlockchainName: '',
+ channelOwnerBlockchainName: ownerBlockchainName,
+ channelRootBlockNumber: '',
+ channelRootBlockHash: '',
+ },
+ };
+ }
+ return {
+ pageId: 'channel-view',
+ params: { ownerBlockchainName, channelName, channelId: '' },
+ };
+ }
+ }
+
if (pageId === 'chat-view') {
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
@@ -55,41 +122,6 @@ export function getRoute() {
return { pageId, params: { channelId: dynamicId || '' } };
}
- if (pageId === 'channel') {
- // Короткий формат:
- // /channel/{ownerBlockchainName}/{channelName}
- // /channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber}
- const ownerBlockchainName = decodePart(segments[1] || '');
- const channelName = decodePart(segments[2] || '');
- const messageBlockNumber = segments[3] || '';
-
- if (ownerBlockchainName && channelName && messageBlockNumber) {
- return {
- pageId: 'channel-thread-view',
- params: {
- ownerBlockchainName,
- channelName,
- messageBlockNumber,
- messageBlockHash: '',
- // поддержка старого контракта страницы треда
- messageBlockchainName: '',
- channelOwnerBlockchainName: ownerBlockchainName,
- channelRootBlockNumber: '',
- channelRootBlockHash: '',
- },
- };
- }
-
- return {
- pageId: 'channel-view',
- params: {
- ownerBlockchainName,
- channelName,
- channelId: '',
- },
- };
- }
-
if (pageId === 'channel-thread-view') {
return {
pageId,
@@ -104,50 +136,16 @@ export function getRoute() {
};
}
- if (pageId === 'm') {
- return {
- pageId: 'channel-thread-view',
- params: {
- messageBlockchainName: decodePart(segments[1]),
- messageBlockNumber: segments[2] || '',
- messageBlockHash: '',
- channelOwnerBlockchainName: '',
- channelRootBlockNumber: '',
- channelRootBlockHash: '',
- },
- };
- }
-
if (pageId === 'device-session-view') {
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
- if (pageId === 'user') {
- return {
- pageId,
- params: {
- login: dynamicId ? decodeURIComponent(dynamicId) : '',
- fromPage: segments[2] ? decodeURIComponent(segments[2]) : 'messages-list',
- },
- };
- }
-
if (pageId === 'network-view') {
- return {
- pageId,
- params: {
- mode: segments[1] ? decodePart(segments[1]) : '',
- },
- };
+ return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
}
if (pageId === 'channels-list') {
- return {
- pageId,
- params: {
- mode: segments[1] ? decodePart(segments[1]) : '',
- },
- };
+ return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
}
return { pageId, params: {} };
@@ -185,9 +183,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'language-view' ||
pageId === 'app-log-view' ||
pageId === 'pwa-diagnostics-view'
- ) {
- return 'profile-view';
- }
+ ) return 'profile-view';
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
if (pageId === 'user') return 'messages-list';
diff --git a/shine-UI/js/services/auth-required-modal.js b/shine-UI/js/services/auth-required-modal.js
new file mode 100644
index 0000000..4f2e8af
--- /dev/null
+++ b/shine-UI/js/services/auth-required-modal.js
@@ -0,0 +1,45 @@
+import { navigate } from '../router.js';
+
+function escapeHtml(text) {
+ return String(text || '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+export function openAuthRequiredModal({
+ title = 'Нужен вход',
+ text = 'Эта часть доступна после входа в систему.',
+ startRoute = 'start-view',
+} = {}) {
+ const root = document.getElementById('modal-root');
+ if (!(root instanceof HTMLElement)) {
+ window.alert(`${title}\n\n${text}`);
+ return;
+ }
+ root.innerHTML = `
+
+
+
${escapeHtml(title)}
+
${escapeHtml(text)}
+
+ Закрыть
+ На старт
+
+
+
+ `;
+
+ const close = () => { root.innerHTML = ''; };
+ root.querySelector('#auth-required-close')?.addEventListener('click', close);
+ root.querySelector('#auth-required-start')?.addEventListener('click', () => {
+ close();
+ navigate(startRoute);
+ });
+ root.querySelector('#auth-required-modal')?.addEventListener('click', (event) => {
+ if (event.target?.id === 'auth-required-modal') close();
+ });
+}
+
diff --git a/shine-UI/js/services/shine-routes.js b/shine-UI/js/services/shine-routes.js
new file mode 100644
index 0000000..5b50929
--- /dev/null
+++ b/shine-UI/js/services/shine-routes.js
@@ -0,0 +1,65 @@
+function encodeRoutePart(value = '') {
+ return encodeURIComponent(String(value || '').trim());
+}
+
+function decodeRoutePart(value = '') {
+ try {
+ return decodeURIComponent(String(value || ''));
+ } catch {
+ return String(value || '');
+ }
+}
+
+export function normalizeLogin(value = '') {
+ return String(value || '').trim().replace(/^@+/, '');
+}
+
+export function extractLoginFromBlockchainName(value = '') {
+ const raw = String(value || '').trim();
+ const match = raw.match(/^(.+)-\d+$/);
+ if (!match) return normalizeLogin(raw);
+ return normalizeLogin(String(match[1] || ''));
+}
+
+export function makeProfileRoute(login = '') {
+ const clean = normalizeLogin(login);
+ return clean ? `shine.${encodeRoutePart(clean)}` : 'profile-view';
+}
+
+export function makeProfileLinksRoute(login = '') {
+ const clean = normalizeLogin(login);
+ return clean ? `shine.${encodeRoutePart(clean)}/links` : 'network-view/keep-history';
+}
+
+export function makeProfileChannelsRoute(login = '', scope = 'all') {
+ const clean = normalizeLogin(login);
+ if (!clean) return 'channels-list/feed';
+ const normalizedScope = String(scope || '').trim().toLowerCase();
+ if (normalizedScope === 'owned') return `shine.${encodeRoutePart(clean)}/channels/owned`;
+ if (normalizedScope === 'following') return `shine.${encodeRoutePart(clean)}/channels/following`;
+ return `shine.${encodeRoutePart(clean)}/channels`;
+}
+
+export function makeShineChannelRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '', messageBlockNumber = '' }) {
+ const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName);
+ const ownerBch = String(ownerBlockchainName || '').trim();
+ const chName = String(channelName || '').trim();
+ const msgNo = String(messageBlockNumber || '').trim();
+ if (!cleanOwnerLogin || !ownerBch || !chName) return '';
+ if (msgNo) return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}/${encodeRoutePart(msgNo)}`;
+ return `shine.${encodeRoutePart(cleanOwnerLogin)}/channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
+}
+
+export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
+ const cleanOwnerLogin = normalizeLogin(ownerLogin);
+ const msgBch = String(messageBlockchainName || '').trim();
+ const msgNo = String(messageBlockNumber || '').trim();
+ if (!cleanOwnerLogin || !msgBch || !msgNo) return '';
+ return `shine.${encodeRoutePart(cleanOwnerLogin)}/msg/${encodeRoutePart(msgBch)}/${encodeRoutePart(msgNo)}`;
+}
+
+export function parseShineRootSegment(segment = '') {
+ const raw = String(segment || '').trim();
+ if (!raw.toLowerCase().startsWith('shine.')) return '';
+ return normalizeLogin(decodeRoutePart(raw.slice('shine.'.length)));
+}
diff --git a/shine-UI/js/services/user-connections.js b/shine-UI/js/services/user-connections.js
index 658b76b..67bbf07 100644
--- a/shine-UI/js/services/user-connections.js
+++ b/shine-UI/js/services/user-connections.js
@@ -47,7 +47,23 @@ function toToggleMap(snapshot) {
}
function readArray(payload, key) {
- const value = payload?.[key];
+ const aliases = {
+ outKnownPersons: ['outKnownPersons', 'outKnownPerson', 'out_known_persons'],
+ inKnownPersons: ['inKnownPersons', 'inKnownPerson', 'in_known_persons'],
+ outShineConfirmed: ['outShineConfirmed', 'outShineConfident', 'out_shine_confirmed'],
+ inShineConfirmed: ['inShineConfirmed', 'inShineConfident', 'in_shine_confirmed'],
+ outShineSeen: ['outShineSeen', 'out_shine_seen'],
+ inShineSeen: ['inShineSeen', 'in_shine_seen'],
+ };
+ const keys = aliases[key] || [key];
+ let value = null;
+ for (const oneKey of keys) {
+ const candidate = payload?.[oneKey];
+ if (Array.isArray(candidate)) {
+ value = candidate;
+ break;
+ }
+ }
return Array.isArray(value) ? uniqueLogins(value) : null;
}
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 4d5f957..7ee1d26 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -2727,7 +2727,7 @@ textarea.input {
.channels-tabs {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 6px;
border-radius: 14px;
@@ -2735,6 +2735,19 @@ textarea.input {
background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
}
+.channels-top-bar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+}
+
+.channels-top-action-btn {
+ min-height: 38px;
+ padding: 8px 12px;
+ white-space: nowrap;
+}
+
.channels-tab-btn {
min-height: 38px;
border-radius: 10px;
@@ -4104,7 +4117,13 @@ textarea.input {
.profile-top-actions {
display: grid;
- grid-template-columns: 1.6fr 1fr 1fr;
+ grid-template-columns: 1fr 1fr;
+ gap: 5px;
+}
+
+.profile-bottom-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
gap: 5px;
}
@@ -4113,13 +4132,18 @@ textarea.input {
min-height: 32px;
padding: 0 10px;
font-size: 12px;
- line-height: 1;
+ line-height: 1.15;
text-align: center;
- white-space: nowrap;
+ white-space: pre-line;
overflow: hidden;
text-overflow: ellipsis;
}
+.profile-links-header-btn {
+ white-space: pre-line;
+ line-height: 1.1;
+}
+
.profile-main-card {
margin-top: 0;
padding: 2px 8px 8px;
diff --git a/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java b/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java
index 1327905..cb430a3 100644
--- a/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java
+++ b/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java
@@ -228,6 +228,12 @@ public final class DatabaseTriggersInstaller {
NEW.msg_sub_type,
COALESCE(
NEW.to_login,
+ (
+ SELECT su.login
+ FROM solana_users su
+ WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
+ LIMIT 1
+ ),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@@ -242,6 +248,12 @@ public final class DatabaseTriggersInstaller {
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
AND COALESCE(
NEW.to_login,
+ (
+ SELECT su.login
+ FROM solana_users su
+ WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
+ LIMIT 1
+ ),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@@ -262,6 +274,12 @@ public final class DatabaseTriggersInstaller {
AND rel_type = NEW.msg_sub_type
AND to_login = COALESCE(
NEW.to_login,
+ (
+ SELECT su.login
+ FROM solana_users su
+ WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
+ LIMIT 1
+ ),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@@ -273,6 +291,12 @@ public final class DatabaseTriggersInstaller {
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
AND COALESCE(
NEW.to_login,
+ (
+ SELECT su.login
+ FROM solana_users su
+ WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
+ LIMIT 1
+ ),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@@ -289,6 +313,12 @@ public final class DatabaseTriggersInstaller {
WHERE login = NEW.login
AND to_login = COALESCE(
NEW.to_login,
+ (
+ SELECT su.login
+ FROM solana_users su
+ WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
+ LIMIT 1
+ ),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
@@ -312,6 +342,12 @@ public final class DatabaseTriggersInstaller {
END
AND COALESCE(
NEW.to_login,
+ (
+ SELECT su.login
+ FROM solana_users su
+ WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
+ LIMIT 1
+ ),
CASE
WHEN NEW.to_bch_name IS NOT NULL
AND length(NEW.to_bch_name) > 4
diff --git a/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java b/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java
index 1e910bf..3de5aa9 100644
--- a/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java
+++ b/shine-server-db/src/main/java/shine/db/dao/ConnectionsStateDAO.java
@@ -40,13 +40,15 @@ public final class ConnectionsStateDAO {
*/
public List
listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
String sql = """
- SELECT u.login AS friend_login
+ SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
FROM connections_state cs
- JOIN solana_users u
- ON u.login = cs.to_login COLLATE NOCASE
+ LEFT JOIN solana_users u_login
+ ON u_login.login = cs.to_login COLLATE NOCASE
+ LEFT JOIN solana_users u_bch
+ ON u_bch.blockchain_name = cs.to_bch_name COLLATE NOCASE
WHERE cs.login = ? COLLATE NOCASE
AND cs.rel_type = ?
- ORDER BY u.login
+ ORDER BY friend_login
""";
List out = new ArrayList<>();
@@ -68,19 +70,25 @@ public final class ConnectionsStateDAO {
*/
public List listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
String sql = """
- SELECT u.login AS friend_login
+ SELECT COALESCE(u_actor.login, cs.login) AS friend_login
FROM connections_state cs
- JOIN solana_users u
- ON u.login = cs.login COLLATE NOCASE
- WHERE cs.to_login = ? COLLATE NOCASE
+ LEFT JOIN solana_users u_actor
+ ON u_actor.login = cs.login COLLATE NOCASE
+ LEFT JOIN solana_users u_target
+ ON u_target.login = ? COLLATE NOCASE
+ WHERE (
+ cs.to_login = ? COLLATE NOCASE
+ OR (u_target.blockchain_name IS NOT NULL AND cs.to_bch_name = u_target.blockchain_name COLLATE NOCASE)
+ )
AND cs.rel_type = ?
- ORDER BY u.login
+ ORDER BY friend_login
""";
List out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
- ps.setInt(2, relType);
+ ps.setString(2, loginAnyCase);
+ ps.setInt(3, relType);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String v = rs.getString("friend_login");
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
index 397895e..36bca44 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
@@ -30,10 +30,14 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
- if (ctx == null || !ctx.isAuthenticatedUser()) {
- return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
+ String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? "" : req.getLogin().trim();
+ if (requestedLogin.isEmpty()) {
+ if (ctx != null && ctx.isAuthenticatedUser()) {
+ requestedLogin = ctx.getLogin();
+ } else {
+ return NetExceptionResponseFactory.error(req, 422, "LOGIN_REQUIRED", "Нужно передать login пользователя");
+ }
}
- String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim();
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
String canonicalLogin = findCanonicalLogin(c, requestedLogin);