Добавил гостевой режим, единые shine-ссылки и пометку о нестабильности мнений
This commit is contained in:
parent
aa35d87885
commit
21413268f3
@ -24,6 +24,11 @@
|
|||||||
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
|
- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`.
|
||||||
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
|
- Документ по личным сообщениям обязан поддерживаться в актуальном состоянии.
|
||||||
|
|
||||||
|
## Известная проблема (временная пометка)
|
||||||
|
- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно.
|
||||||
|
- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI.
|
||||||
|
- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя.
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
|
||||||
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
|
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
## Краткое описание
|
||||||
|
Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений.
|
||||||
|
|
||||||
|
## Что проверять
|
||||||
|
1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
||||||
|
2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан).
|
||||||
|
3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан).
|
||||||
|
4. Если аватар не задан или недоступен, корректно остаётся fallback (буква).
|
||||||
|
5. Форма и размер остаются круглыми и визуально не ломают карточки.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI.
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
`pending`
|
||||||
@ -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 (между своими клиентами/агентом) в рамках одного логина.
|
||||||
@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
## Базовый сервер
|
## Базовый сервер
|
||||||
|
|
||||||
- SSH: `player@93.170.12.154`
|
- SSH: `player@45.136.124.227`
|
||||||
- Домен: `shineup.me`
|
- Домен: `shineup.me`
|
||||||
- Базовый путь: `/home/player/SHiNE`
|
- Базовый путь: `/home/player`
|
||||||
|
|
||||||
## Локальные команды
|
## Локальные команды
|
||||||
|
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Сервер `93.170.12.154` (`shineup.me`)
|
# Сервер `93.170.12.154` — резервный
|
||||||
|
|
||||||
- Пользователь: `player`
|
- Пользователь: `player`
|
||||||
- Каталог SHiNE: `/home/player/SHiNE`
|
- Каталог SHiNE: `/home/player/SHiNE`
|
||||||
@ -15,6 +15,11 @@
|
|||||||
- `shine-server.service` (systemd)
|
- `shine-server.service` (systemd)
|
||||||
- `caddy.service` (systemd)
|
- `caddy.service` (systemd)
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- Резервный сервер для SHiNE.
|
||||||
|
- Основной прод-сервер: `45.136.124.227` (`shineup.me`).
|
||||||
|
|
||||||
## Caddy
|
## Caddy
|
||||||
|
|
||||||
- Конфиг: `/etc/caddy/Caddyfile`
|
- Конфиг: `/etc/caddy/Caddyfile`
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.79
|
client.version=1.2.80
|
||||||
server.version=1.2.73
|
server.version=1.2.74
|
||||||
|
|||||||
11
build.gradle
11
build.gradle
@ -182,9 +182,9 @@ tasks.register('deployServer', JavaExec) {
|
|||||||
// можно переопределить при запуске:
|
// можно переопределить при запуске:
|
||||||
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
// ./gradlew deployServer -Dit.remoteHost=... -Dit.wsUri=...
|
||||||
dependsOn shadowJar
|
dependsOn shadowJar
|
||||||
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "194.87.0.247")
|
systemProperty "it.remoteHost", System.getProperty("it.remoteHost", "45.136.124.227")
|
||||||
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "user")
|
systemProperty "it.remoteUser", System.getProperty("it.remoteUser", "player")
|
||||||
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/user/docker/shine-server")
|
systemProperty "it.remoteDir", System.getProperty("it.remoteDir", "/home/player/SHiNE/shine-server")
|
||||||
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
systemProperty "it.service", System.getProperty("it.service", "shine-server")
|
||||||
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
systemProperty "it.localJar", System.getProperty("it.localJar", "build/libs/shine-server.jar")
|
||||||
|
|
||||||
@ -258,10 +258,11 @@ tasks.register('startLocal', Exec) {
|
|||||||
echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL"
|
echo "Browser auto-open is not available on this host. Open manually: \$CLIENT_URL"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SPA_SERVER_SCRIPT="${file('scripts/local_spa_server.py').absolutePath}"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
(cd "\$UI_DIR" && python3 -m http.server "\$WEB_PORT")
|
SHINE_UI_PORT="\$WEB_PORT" python3 "\$SPA_SERVER_SCRIPT"
|
||||||
else
|
else
|
||||||
(cd "\$UI_DIR" && python -m http.server "\$WEB_PORT")
|
SHINE_UI_PORT="\$WEB_PORT" python "\$SPA_SERVER_SCRIPT"
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SRC_DIR="shine-UI"
|
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}"
|
REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}"
|
||||||
EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/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}"
|
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
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "$REMOTE_HOST" "echo SSH OK" >/dev/null
|
||||||
|
|
||||||
echo "==> Validating Caddy UI root"
|
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
|
if [[ -n "$CADDY_ROOT_LINE" && "$CADDY_ROOT_LINE" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then
|
||||||
echo "ERROR: Caddy root mismatch. Found: $CADDY_ROOT_LINE" >&2
|
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
|
echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2
|
||||||
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then
|
if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then
|
||||||
echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2
|
echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2
|
||||||
|
|||||||
33
scripts/local_spa_server.py
Normal file
33
scripts/local_spa_server.py
Normal file
@ -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()
|
||||||
@ -136,6 +136,16 @@ let pwaUpdateCheckAttempted = false;
|
|||||||
let uiVersionCheckInFlight = false;
|
let uiVersionCheckInFlight = false;
|
||||||
let uiVersionPeriodicIntervalId = null;
|
let uiVersionPeriodicIntervalId = null;
|
||||||
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1';
|
||||||
|
const GUEST_ALLOWED_PAGES = new Set([
|
||||||
|
'start-view',
|
||||||
|
'entry-settings-view',
|
||||||
|
'network-view',
|
||||||
|
'channels-list',
|
||||||
|
'channel-view',
|
||||||
|
'channel-thread-view',
|
||||||
|
'user',
|
||||||
|
'contact-search-view',
|
||||||
|
]);
|
||||||
|
|
||||||
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
||||||
setClientErrorSentNotifier((payload) => {
|
setClientErrorSentNotifier((payload) => {
|
||||||
@ -671,7 +681,7 @@ function renderApp() {
|
|||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
|
|
||||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId)) {
|
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) {
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1025,18 +1035,24 @@ async function init() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await tryAutoLogin();
|
// Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"),
|
||||||
await hydrateMessagesFromStore();
|
// а сетевые/авторизационные шаги выполняем фоном.
|
||||||
startConnectionMonitor();
|
|
||||||
startPeriodicUiVersionCheck();
|
|
||||||
await ensureSessionRuntimeStarted();
|
|
||||||
|
|
||||||
if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') {
|
||||||
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
|
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);
|
window.addEventListener('popstate', renderApp);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
|
||||||
@ -27,6 +28,35 @@ function getTotalUnreadMessages() {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateWithGuestRules(pageId, navigate) {
|
||||||
|
if (state.session.isAuthorized) {
|
||||||
|
navigate(pageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageId === 'messages-list') {
|
||||||
|
openAuthRequiredModal({
|
||||||
|
title: 'Личные сообщения недоступны',
|
||||||
|
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageId === 'profile-view') {
|
||||||
|
openAuthRequiredModal({
|
||||||
|
title: 'Профиль недоступен',
|
||||||
|
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageId === 'notifications-view') {
|
||||||
|
openAuthRequiredModal({
|
||||||
|
title: 'Уведомления недоступны',
|
||||||
|
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderToolbar(currentPageId, navigate) {
|
export function renderToolbar(currentPageId, navigate) {
|
||||||
const root = document.createElement('nav');
|
const root = document.createElement('nav');
|
||||||
root.className = 'toolbar';
|
root.className = 'toolbar';
|
||||||
@ -63,7 +93,7 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
if (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
installChannelsHoldSwitcher(btn, navigate);
|
installChannelsHoldSwitcher(btn, navigate);
|
||||||
} else {
|
} else {
|
||||||
btn.addEventListener('click', () => navigate(item.pageId));
|
btn.addEventListener('click', () => navigateWithGuestRules(item.pageId, navigate));
|
||||||
}
|
}
|
||||||
root.append(btn);
|
root.append(btn);
|
||||||
});
|
});
|
||||||
@ -156,3 +186,4 @@ function installChannelsHoldSwitcher(button, navigate) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,66 @@ import {
|
|||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { navigateBack } from '../router.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingThreadScroll = new Map();
|
const pendingThreadScroll = new Map();
|
||||||
|
const threadAvatarSnapshotCache = new Map();
|
||||||
|
const threadAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
|
async function loadThreadAvatarSnapshot(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return null;
|
||||||
|
const key = cleanLogin.toLowerCase();
|
||||||
|
if (threadAvatarSnapshotCache.has(key)) return threadAvatarSnapshotCache.get(key);
|
||||||
|
if (threadAvatarPendingByLogin.has(key)) return threadAvatarPendingByLogin.get(key);
|
||||||
|
const pending = loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
threadAvatarSnapshotCache.set(key, snapshot || null);
|
||||||
|
threadAvatarPendingByLogin.delete(key);
|
||||||
|
return snapshot || null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
threadAvatarSnapshotCache.set(key, null);
|
||||||
|
threadAvatarPendingByLogin.delete(key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
threadAvatarPendingByLogin.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThreadAvatar(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
|
const avatarEl = renderUserAvatar({
|
||||||
|
login: cleanLogin || 'unknown',
|
||||||
|
size: 'small',
|
||||||
|
className: 'channel-message-avatar',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
if (!cleanLogin) return avatarEl;
|
||||||
|
void loadThreadAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
|
if (!avatarEl.isConnected) return;
|
||||||
|
const upgraded = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
avatar: snapshot?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(snapshot.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'small',
|
||||||
|
className: 'channel-message-avatar',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
avatarEl.replaceWith(upgraded);
|
||||||
|
});
|
||||||
|
return avatarEl;
|
||||||
|
}
|
||||||
|
|
||||||
function logThreadRuntimeError(stage, error, context = {}) {
|
function logThreadRuntimeError(stage, error, context = {}) {
|
||||||
const message = String(error?.message || error || 'thread runtime error');
|
const message = String(error?.message || error || 'thread runtime error');
|
||||||
@ -55,13 +110,6 @@ function looksLikeBlockchainName(value) {
|
|||||||
return /^[^-]+-\d+$/.test(raw);
|
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) {
|
function makeReactionActionKey(messageRef) {
|
||||||
const login = String(state.session.login || '').trim().toLowerCase();
|
const login = String(state.session.login || '').trim().toLowerCase();
|
||||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||||
@ -200,32 +248,31 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
|
|||||||
|
|
||||||
function buildThreadRouteFromTarget(target, selector) {
|
function buildThreadRouteFromTarget(target, selector) {
|
||||||
if (!target) return '';
|
if (!target) return '';
|
||||||
return [
|
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
||||||
'm',
|
return makeShineMessageRoute({
|
||||||
encodeRoutePart(target.blockchainName),
|
ownerLogin: extractLoginFromBlockchainName(ownerBch) || extractLoginFromBlockchainName(target.blockchainName),
|
||||||
target.blockNumber,
|
messageBlockchainName: target.blockchainName,
|
||||||
].join('/');
|
messageBlockNumber: target.blockNumber,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
function buildChannelRouteFromThread(selector, resolvedChannelLabel = '') {
|
||||||
|
const ownerBch = String(selector?.short?.ownerBlockchainName || selector?.channel?.ownerBlockchainName || '').trim();
|
||||||
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
if (selector?.short?.ownerBlockchainName && selector?.short?.channelName) {
|
||||||
return [
|
return makeShineChannelRoute({
|
||||||
'channel',
|
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
||||||
encodeRoutePart(selector.short.ownerBlockchainName),
|
ownerBlockchainName: ownerBch,
|
||||||
encodeRoutePart(selector.short.channelName),
|
channelName: selector.short.channelName,
|
||||||
].join('/');
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerBch = String(selector?.channel?.ownerBlockchainName || '').trim();
|
|
||||||
const label = String(resolvedChannelLabel || '').trim();
|
const label = String(resolvedChannelLabel || '').trim();
|
||||||
const slashIndex = label.indexOf('/');
|
const slashIndex = label.indexOf('/');
|
||||||
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
|
const channelName = slashIndex >= 0 ? label.slice(slashIndex + 1).trim() : '';
|
||||||
if (!ownerBch || !channelName) return '';
|
return makeShineChannelRoute({
|
||||||
return [
|
ownerLogin: extractLoginFromBlockchainName(ownerBch),
|
||||||
'channel',
|
ownerBlockchainName: ownerBch,
|
||||||
encodeRoutePart(ownerBch),
|
channelName,
|
||||||
encodeRoutePart(channelName),
|
});
|
||||||
].join('/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTargetFromNode(node) {
|
function buildTargetFromNode(node) {
|
||||||
@ -443,9 +490,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
authorTile.type = 'button';
|
authorTile.type = 'button';
|
||||||
authorTile.className = 'channel-message-author-tile';
|
authorTile.className = 'channel-message-author-tile';
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = createThreadAvatar(author);
|
||||||
avatar.className = 'channel-message-avatar';
|
|
||||||
avatar.textContent = String(author || 'A').trim().charAt(0).toUpperCase() || 'A';
|
|
||||||
|
|
||||||
const authorBlock = document.createElement('div');
|
const authorBlock = document.createElement('div');
|
||||||
authorBlock.className = 'channel-message-author';
|
authorBlock.className = 'channel-message-author';
|
||||||
@ -591,7 +636,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const login = String(node?.authorLogin || '').trim();
|
const login = String(node?.authorLogin || '').trim();
|
||||||
if (!login) return;
|
if (!login) return;
|
||||||
handlers.navigate(`user/${encodeRoutePart(login)}`);
|
handlers.navigate(makeProfileRoute(login));
|
||||||
});
|
});
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
handlers.onOpenThread(target);
|
handlers.onOpenThread(target);
|
||||||
|
|||||||
@ -18,12 +18,71 @@ import {
|
|||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||||||
import { navigateBack } from '../router.js';
|
import { navigateBack } from '../router.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
import {
|
||||||
|
extractLoginFromBlockchainName,
|
||||||
|
makeProfileRoute,
|
||||||
|
makeShineMessageRoute,
|
||||||
|
} from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
|
const messageAvatarSnapshotCache = new Map();
|
||||||
|
const messageAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
|
async function loadMessageAvatarSnapshot(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return null;
|
||||||
|
const key = cleanLogin.toLowerCase();
|
||||||
|
if (messageAvatarSnapshotCache.has(key)) return messageAvatarSnapshotCache.get(key);
|
||||||
|
if (messageAvatarPendingByLogin.has(key)) return messageAvatarPendingByLogin.get(key);
|
||||||
|
const pending = loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
messageAvatarSnapshotCache.set(key, snapshot || null);
|
||||||
|
messageAvatarPendingByLogin.delete(key);
|
||||||
|
return snapshot || null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
messageAvatarSnapshotCache.set(key, null);
|
||||||
|
messageAvatarPendingByLogin.delete(key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
messageAvatarPendingByLogin.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageAvatar(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
|
const avatarEl = renderUserAvatar({
|
||||||
|
login: cleanLogin || 'unknown',
|
||||||
|
size: 'small',
|
||||||
|
className: 'channel-message-avatar',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
if (!cleanLogin) return avatarEl;
|
||||||
|
void loadMessageAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
|
if (!avatarEl.isConnected) return;
|
||||||
|
const upgraded = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
avatar: snapshot?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(snapshot.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'small',
|
||||||
|
className: 'channel-message-avatar',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
avatarEl.replaceWith(upgraded);
|
||||||
|
});
|
||||||
|
return avatarEl;
|
||||||
|
}
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -61,13 +120,6 @@ function looksLikeBlockchainName(value) {
|
|||||||
return /^[^-]+-\d+$/.test(raw);
|
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) {
|
function makeReactionActionKey(messageRef) {
|
||||||
const login = String(state.session.login || '').trim().toLowerCase();
|
const login = String(state.session.login || '').trim().toLowerCase();
|
||||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||||
@ -148,11 +200,12 @@ function buildSelectorFromRoute(route, channelId) {
|
|||||||
|
|
||||||
function buildThreadRoute(messageRef, selector) {
|
function buildThreadRoute(messageRef, selector) {
|
||||||
if (!messageRef || !selector) return '';
|
if (!messageRef || !selector) return '';
|
||||||
return [
|
const ownerLogin = extractLoginFromBlockchainName(selector.ownerBlockchainName);
|
||||||
'm',
|
return makeShineMessageRoute({
|
||||||
encodeRoutePart(messageRef.blockchainName),
|
ownerLogin,
|
||||||
messageRef.blockNumber,
|
messageBlockchainName: messageRef.blockchainName,
|
||||||
].join('/');
|
messageBlockNumber: messageRef.blockNumber,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstNonEmptyText(...candidates) {
|
function firstNonEmptyText(...candidates) {
|
||||||
@ -497,10 +550,16 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromApi(route, channelId) {
|
async function loadFromApi(route, channelId) {
|
||||||
|
const currentSessionLogin = String(state.session.login || '').trim();
|
||||||
|
const isAuthorized = !!currentSessionLogin;
|
||||||
let cachedFeed = null;
|
let cachedFeed = null;
|
||||||
const ensureFeed = async () => {
|
const ensureFeed = async () => {
|
||||||
if (cachedFeed) return cachedFeed;
|
if (cachedFeed) return cachedFeed;
|
||||||
cachedFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
|
if (!isAuthorized) {
|
||||||
|
cachedFeed = {};
|
||||||
|
return cachedFeed;
|
||||||
|
}
|
||||||
|
cachedFeed = await authService.listSubscriptionsFeed(currentSessionLogin, 1000);
|
||||||
return cachedFeed;
|
return cachedFeed;
|
||||||
};
|
};
|
||||||
const getAllRows = async () => {
|
const getAllRows = async () => {
|
||||||
@ -517,34 +576,39 @@ async function loadFromApi(route, channelId) {
|
|||||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
||||||
const allRows = await getAllRows();
|
let channel = null;
|
||||||
let channel = allRows.find((item) => (
|
if (isAuthorized) {
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
const allRows = await getAllRows();
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
|
||||||
));
|
|
||||||
if (!channel) {
|
|
||||||
channel = allRows.find((item) => (
|
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()
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||||
));
|
));
|
||||||
}
|
if (!channel) {
|
||||||
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
|
channel = allRows.find((item) => (
|
||||||
try {
|
String(item?.channel?.ownerLogin || '').trim().toLowerCase() === routeOwnerNormalized
|
||||||
const ownerUser = await authService.getUser(routeOwnerRaw);
|
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||||
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
|
));
|
||||||
if (ownerBch) {
|
}
|
||||||
channel = allRows.find((item) => (
|
if (!channel && !looksLikeBlockchainName(routeOwnerRaw)) {
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
|
try {
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
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 {
|
try {
|
||||||
const ownerFeed = await authService.listSubscriptionsFeed(routeOwnerLoginFromBch, 500);
|
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
|
||||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||||||
channel = ownerRows.find((item) => (
|
channel = ownerRows.find((item) => (
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||||
@ -554,6 +618,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
// ignore owner feed lookup failures
|
// ignore owner feed lookup failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
|
||||||
throw new Error('Канал не найден.');
|
throw new Error('Канал не найден.');
|
||||||
}
|
}
|
||||||
@ -569,12 +634,12 @@ async function loadFromApi(route, channelId) {
|
|||||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
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 : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
let reverseChannelMissingWarning = '';
|
let reverseChannelMissingWarning = '';
|
||||||
let mergedMessages = [...messages];
|
let mergedMessages = [...messages];
|
||||||
|
|
||||||
const currentLogin = String(state.session.login || '').trim();
|
const currentLogin = currentSessionLogin;
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
const channelName = String(payload.channel?.channelName || '').trim();
|
const channelName = String(payload.channel?.channelName || '').trim();
|
||||||
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
|
const channelTypeCode = Number(payload.channel?.channelTypeCode ?? 1);
|
||||||
@ -600,7 +665,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
||||||
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
||||||
};
|
};
|
||||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', state.session.login);
|
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
|
||||||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||||||
mergedMessages = mergedMessages.concat(reverseMessages);
|
mergedMessages = mergedMessages.concat(reverseMessages);
|
||||||
} else {
|
} else {
|
||||||
@ -618,9 +683,9 @@ async function loadFromApi(route, channelId) {
|
|||||||
return aNum - bNum;
|
return aNum - bNum;
|
||||||
})
|
})
|
||||||
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
.map((post, index) => ({ ...post, localNumber: index + 1 }));
|
||||||
const isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase();
|
const isOwnChannel = ownerLogin.toLowerCase() === currentSessionLogin.toLowerCase();
|
||||||
const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : [];
|
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 || '')
|
String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '')
|
||||||
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
|
&& Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber)
|
||||||
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
|
&& normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash)
|
||||||
@ -682,17 +747,31 @@ function renderDemoFallback(screen, navigate, error) {
|
|||||||
screen.append(back);
|
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);
|
const target = pendingScrollByRoute.get(routeKey);
|
||||||
if (!target) return;
|
if (!target && !forceBottom) return;
|
||||||
|
|
||||||
const doScroll = () => {
|
const doScroll = () => {
|
||||||
|
if (!target && forceBottom) {
|
||||||
|
scrollChannelToBottom(screen, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (target === '__LAST__') {
|
if (target === '__LAST__') {
|
||||||
const cards = screen.querySelectorAll('[data-message-key]');
|
scrollChannelToBottom(screen, true);
|
||||||
const last = cards[cards.length - 1];
|
|
||||||
if (last) {
|
|
||||||
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
pendingScrollByRoute.delete(routeKey);
|
pendingScrollByRoute.delete(routeKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -724,9 +803,7 @@ function renderPostCard(post, {
|
|||||||
authorTile.type = 'button';
|
authorTile.type = 'button';
|
||||||
authorTile.className = 'channel-message-author-tile';
|
authorTile.className = 'channel-message-author-tile';
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
const avatar = createMessageAvatar(post.authorLogin);
|
||||||
avatar.className = 'channel-message-avatar';
|
|
||||||
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
|
|
||||||
|
|
||||||
const authorBlock = document.createElement('div');
|
const authorBlock = document.createElement('div');
|
||||||
authorBlock.className = 'channel-message-author';
|
authorBlock.className = 'channel-message-author';
|
||||||
@ -768,7 +845,7 @@ function renderPostCard(post, {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const cleanLogin = String(post.authorLogin || '').trim();
|
const cleanLogin = String(post.authorLogin || '').trim();
|
||||||
if (!cleanLogin) return;
|
if (!cleanLogin) return;
|
||||||
navigate(`user/${encodeRoutePart(cleanLogin)}`);
|
navigate(makeProfileRoute(cleanLogin));
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||||
@ -888,10 +965,8 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionButton = document.createElement('button');
|
const actionButton = document.createElement('button');
|
||||||
actionButton.className = channelData.isOwnChannel
|
actionButton.className = 'destructive-btn channel-main-action';
|
||||||
? 'primary-btn channel-main-action'
|
actionButton.textContent = 'Подписаться на канал';
|
||||||
: 'destructive-btn channel-main-action';
|
|
||||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
|
|
||||||
|
|
||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack channel-feed';
|
feed.className = 'stack channel-feed';
|
||||||
@ -921,16 +996,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
if (!channelData.isSubscribed) {
|
||||||
actionButton.addEventListener('click', (event) => {
|
|
||||||
animatePress(event.currentTarget);
|
|
||||||
openAddMessageModal({
|
|
||||||
channelName: channelData.channel.name,
|
|
||||||
navigate,
|
|
||||||
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else if (!channelData.isSubscribed) {
|
|
||||||
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -939,13 +1005,15 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
backButton.textContent = 'Назад к каналам';
|
backButton.textContent = 'Назад к каналам';
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
if (channelData.isOwnChannel) {
|
||||||
|
screen.append(feed);
|
||||||
|
} else if (!channelData.isSubscribed) {
|
||||||
screen.append(actionButton, feed, backButton);
|
screen.append(actionButton, feed, backButton);
|
||||||
} else {
|
} else {
|
||||||
screen.append(feed, backButton);
|
screen.append(feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey, channelData.isOwnChannel);
|
||||||
return () => {
|
return () => {
|
||||||
// noop
|
// noop
|
||||||
};
|
};
|
||||||
@ -1121,14 +1189,40 @@ export function render({ navigate, route }) {
|
|||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
activeSelector = apiData?.selector || null;
|
||||||
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
|
const channelRouteLabel = `Канал: ${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
|
||||||
|
const ownChannelLabel = `Ваш канал: ${apiData?.channel?.name || 'channel'}`;
|
||||||
if (channelHeaderButton) {
|
if (channelHeaderButton) {
|
||||||
channelHeaderButton.textContent = channelRouteLabel;
|
channelHeaderButton.textContent = apiData?.isOwnChannel ? ownChannelLabel : channelRouteLabel;
|
||||||
channelHeaderButton.disabled = false;
|
channelHeaderButton.disabled = false;
|
||||||
channelHeaderButton.onclick = (event) => {
|
channelHeaderButton.onclick = (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
openAboutChannelModal(apiData.channel);
|
openAboutChannelModal(apiData.channel);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (apiData?.isOwnChannel) {
|
||||||
|
const headerActions = header.querySelector('.header-actions');
|
||||||
|
if (headerActions) {
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.className = 'icon-btn channel-header-add-btn';
|
||||||
|
addBtn.textContent = 'Добавить сообщение';
|
||||||
|
addBtn.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openAddMessageModal({
|
||||||
|
channelName: apiData?.channel?.name || '',
|
||||||
|
navigate,
|
||||||
|
onSubmit: async (bodyText) => {
|
||||||
|
try {
|
||||||
|
await onAddPost(bodyText);
|
||||||
|
showStatus('');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
headerActions.append(addBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||||
onToggleLike: async (messageRef, action) => {
|
onToggleLike: async (messageRef, action) => {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
softHaptic,
|
softHaptic,
|
||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
|
||||||
@ -43,12 +44,14 @@ function normalizeLoginInput(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
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();
|
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||||
if (ownerBch && channelName) {
|
return makeShineChannelRoute({
|
||||||
return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`;
|
ownerLogin,
|
||||||
}
|
ownerBlockchainName: ownerBch,
|
||||||
return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`;
|
channelName: channelName || fallbackId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function avatarLetterFromName(name = '') {
|
function avatarLetterFromName(name = '') {
|
||||||
@ -408,7 +411,7 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
||||||
<h3 class="modal-title">Поиск каналов</h3>
|
<h3 class="modal-title">Поиск каналов</h3>
|
||||||
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
|
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
|
||||||
<input id="channels-find-input" class="input" placeholder="Например: aid" autocomplete="off" />
|
<input id="channels-find-input" class="input" placeholder="Например: aidar" autocomplete="off" />
|
||||||
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
||||||
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
||||||
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
||||||
@ -463,8 +466,12 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
openBtn.textContent = 'Просмотреть';
|
openBtn.textContent = 'Просмотреть';
|
||||||
openBtn.addEventListener('click', () => {
|
openBtn.addEventListener('click', () => {
|
||||||
close();
|
close();
|
||||||
const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim();
|
const route = makeShineChannelRoute({
|
||||||
navigate(`channel/${encodeRoutePart(ownerPart)}/${encodeRoutePart(item.channelName)}`);
|
ownerLogin: String(item.ownerLogin || '').trim(),
|
||||||
|
ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
|
||||||
|
channelName: String(item.channelName || '').trim(),
|
||||||
|
});
|
||||||
|
if (route) navigate(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
row.style.display = 'flex';
|
row.style.display = 'flex';
|
||||||
@ -582,7 +589,11 @@ function openChannelFinderModal({ navigate }) {
|
|||||||
function mapMockGroups() {
|
function mapMockGroups() {
|
||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...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'
|
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal'
|
||||||
? 'my'
|
? 'my'
|
||||||
: 'feed',
|
: 'feed',
|
||||||
@ -683,6 +694,9 @@ function toListModel(groups) {
|
|||||||
function renderEmptyState(activeTab, navigate) {
|
function renderEmptyState(activeTab, navigate) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||||
|
if (!state.session.isAuthorized) {
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
const text = document.createElement('p');
|
const text = document.createElement('p');
|
||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
if (activeTab === 'feed') {
|
if (activeTab === 'feed') {
|
||||||
@ -763,7 +777,14 @@ function renderDemoFallback(container, navigate, error, onRetry) {
|
|||||||
<span class="channel-row-time">—</span>
|
<span class="channel-row-time">—</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
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);
|
list.append(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -996,35 +1017,10 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
|||||||
|
|
||||||
const main = renderChannelMain(channel, activeTab);
|
const main = renderChannelMain(channel, activeTab);
|
||||||
|
|
||||||
|
const isGuest = !state.session.isAuthorized;
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'channel-row-controls';
|
controls.className = 'channel-row-controls';
|
||||||
|
|
||||||
const 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');
|
const time = document.createElement('span');
|
||||||
time.className = 'channel-row-time';
|
time.className = 'channel-row-time';
|
||||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
|
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.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||||||
count.classList.toggle('is-empty', unreadCount <= 0);
|
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.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);
|
list.append(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1057,9 +1088,9 @@ function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tab === 'my') {
|
if (tab === 'my') {
|
||||||
button.textContent = 'Создать канал';
|
button.textContent = 'Найти канал';
|
||||||
button.className = baseClass;
|
button.className = baseClass;
|
||||||
button.onclick = () => navigate('add-channel-view');
|
button.onclick = () => openChannelFinderModal({ navigate });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1072,8 +1103,20 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
|||||||
closeChannelMenu(listState);
|
closeChannelMenu(listState);
|
||||||
renderSkeletonList(contentEl, 5);
|
renderSkeletonList(contentEl, 5);
|
||||||
|
|
||||||
|
if (!state.session.isAuthorized) {
|
||||||
|
setChannelsFeed(null, {});
|
||||||
|
listState.channels = [];
|
||||||
|
renderListContent({
|
||||||
|
screen,
|
||||||
|
container: contentEl,
|
||||||
|
listState,
|
||||||
|
navigate,
|
||||||
|
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!state.session.login) throw new Error('not_authorized');
|
|
||||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||||
const groups = mapApiFeed(feed, listState.notificationsState);
|
const groups = mapApiFeed(feed, listState.notificationsState);
|
||||||
|
|
||||||
@ -1109,6 +1152,7 @@ export function render({ navigate, route }) {
|
|||||||
const createSuccessFlash = pullCreateSuccessFlash();
|
const createSuccessFlash = pullCreateSuccessFlash();
|
||||||
const notificationsState = readChannelNotificationsState();
|
const notificationsState = readChannelNotificationsState();
|
||||||
|
|
||||||
|
const isGuest = !state.session.isAuthorized;
|
||||||
const listState = {
|
const listState = {
|
||||||
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
|
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
|
||||||
? String(route?.params?.mode).trim()
|
? String(route?.params?.mode).trim()
|
||||||
@ -1119,10 +1163,16 @@ export function render({ navigate, route }) {
|
|||||||
channels: [],
|
channels: [],
|
||||||
menuCleanup: null,
|
menuCleanup: null,
|
||||||
};
|
};
|
||||||
|
if (isGuest && listState.activeTab === 'my') {
|
||||||
|
listState.activeTab = 'feed';
|
||||||
|
}
|
||||||
|
|
||||||
const contentEl = document.createElement('div');
|
const contentEl = document.createElement('div');
|
||||||
contentEl.className = 'channels-list-content';
|
contentEl.className = 'channels-list-content';
|
||||||
|
|
||||||
|
const topBarEl = document.createElement('div');
|
||||||
|
topBarEl.className = 'channels-top-bar';
|
||||||
|
|
||||||
const tabsEl = document.createElement('div');
|
const tabsEl = document.createElement('div');
|
||||||
tabsEl.className = 'channels-tabs';
|
tabsEl.className = 'channels-tabs';
|
||||||
const tabLabels = {
|
const tabLabels = {
|
||||||
@ -1130,6 +1180,7 @@ export function render({ navigate, route }) {
|
|||||||
my: 'Мои каналы',
|
my: 'Мои каналы',
|
||||||
};
|
};
|
||||||
TAB_ORDER.forEach((tabKey) => {
|
TAB_ORDER.forEach((tabKey) => {
|
||||||
|
if (isGuest && tabKey === 'my') return;
|
||||||
const tabBtn = document.createElement('button');
|
const tabBtn = document.createElement('button');
|
||||||
tabBtn.type = 'button';
|
tabBtn.type = 'button';
|
||||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
||||||
@ -1142,6 +1193,15 @@ export function render({ navigate, route }) {
|
|||||||
tabsEl.append(tabBtn);
|
tabsEl.append(tabBtn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const topActionBtn = document.createElement('button');
|
||||||
|
topActionBtn.type = 'button';
|
||||||
|
topActionBtn.className = 'secondary-btn channels-top-action-btn';
|
||||||
|
topActionBtn.textContent = 'Создать канал';
|
||||||
|
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||||
|
if (isGuest) topActionBtn.style.display = 'none';
|
||||||
|
|
||||||
|
topBarEl.append(tabsEl, topActionBtn);
|
||||||
|
|
||||||
const bottomCta = document.createElement('button');
|
const bottomCta = document.createElement('button');
|
||||||
bottomCta.type = 'button';
|
bottomCta.type = 'button';
|
||||||
|
|
||||||
@ -1169,6 +1229,9 @@ export function render({ navigate, route }) {
|
|||||||
refreshFeed: reloadFeed,
|
refreshFeed: reloadFeed,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showCreate = !isGuest && listState.activeTab === 'my';
|
||||||
|
topActionBtn.style.display = showCreate ? '' : 'none';
|
||||||
|
|
||||||
updateBottomCta({
|
updateBottomCta({
|
||||||
button: bottomCta,
|
button: bottomCta,
|
||||||
listState,
|
listState,
|
||||||
@ -1202,7 +1265,7 @@ export function render({ navigate, route }) {
|
|||||||
rerenderList();
|
rerenderList();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
screen.append(tabsEl, contentEl, bottomCta);
|
screen.append(topBarEl, contentEl, bottomCta);
|
||||||
|
|
||||||
if (createSuccessFlash) {
|
if (createSuccessFlash) {
|
||||||
showToast(createSuccessFlash);
|
showToast(createSuccessFlash);
|
||||||
|
|||||||
@ -1,7 +1,62 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { authService } from '../state.js';
|
import { authService } from '../state.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
|
||||||
|
const searchAvatarSnapshotCache = new Map();
|
||||||
|
const searchAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
|
async function loadSearchAvatarSnapshot(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return null;
|
||||||
|
const key = cleanLogin.toLowerCase();
|
||||||
|
if (searchAvatarSnapshotCache.has(key)) return searchAvatarSnapshotCache.get(key);
|
||||||
|
if (searchAvatarPendingByLogin.has(key)) return searchAvatarPendingByLogin.get(key);
|
||||||
|
const pending = loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
searchAvatarSnapshotCache.set(key, snapshot || null);
|
||||||
|
searchAvatarPendingByLogin.delete(key);
|
||||||
|
return snapshot || null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
searchAvatarSnapshotCache.set(key, null);
|
||||||
|
searchAvatarPendingByLogin.delete(key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
searchAvatarPendingByLogin.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearchAvatar(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
|
const avatarEl = renderUserAvatar({
|
||||||
|
login: cleanLogin || 'unknown',
|
||||||
|
size: 'small',
|
||||||
|
className: 'avatar',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
if (!cleanLogin) return avatarEl;
|
||||||
|
void loadSearchAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
|
if (!avatarEl.isConnected) return;
|
||||||
|
const upgraded = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
avatar: snapshot?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(snapshot.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'small',
|
||||||
|
className: 'avatar',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
avatarEl.replaceWith(upgraded);
|
||||||
|
});
|
||||||
|
return avatarEl;
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
@ -44,16 +99,17 @@ export function render({ navigate }) {
|
|||||||
matches.forEach((login) => {
|
matches.forEach((login) => {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
|
const avatarEl = createSearchAvatar(login);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>${login}</strong>
|
<strong>${login}</strong>
|
||||||
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-muted">Профиль</div>
|
<div class="meta-muted">Профиль</div>
|
||||||
`;
|
`;
|
||||||
|
row.prepend(avatarEl);
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
navigate(`user/${encodeURIComponent(login)}/contact-search-view`);
|
navigate(makeProfileRoute(login));
|
||||||
});
|
});
|
||||||
resultsList.append(row);
|
resultsList.append(row);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,8 +8,61 @@ import {
|
|||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||||
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
|
const dmAvatarSnapshotCache = new Map();
|
||||||
|
const dmAvatarPendingByLogin = new Map();
|
||||||
|
|
||||||
|
async function loadDmAvatarSnapshot(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
if (!cleanLogin) return null;
|
||||||
|
const key = cleanLogin.toLowerCase();
|
||||||
|
if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key);
|
||||||
|
if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key);
|
||||||
|
const pending = loadProfileSnapshot(cleanLogin)
|
||||||
|
.then((snapshot) => {
|
||||||
|
dmAvatarSnapshotCache.set(key, snapshot || null);
|
||||||
|
dmAvatarPendingByLogin.delete(key);
|
||||||
|
return snapshot || null;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dmAvatarSnapshotCache.set(key, null);
|
||||||
|
dmAvatarPendingByLogin.delete(key);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
dmAvatarPendingByLogin.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDmAvatar(login) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
|
const avatarEl = renderUserAvatar({
|
||||||
|
login: cleanLogin || 'unknown',
|
||||||
|
size: 'small',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
if (!cleanLogin) return avatarEl;
|
||||||
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
|
if (!avatarEl.isConnected) return;
|
||||||
|
const upgraded = renderUserAvatar({
|
||||||
|
login: cleanLogin,
|
||||||
|
avatar: snapshot?.avatar?.txId
|
||||||
|
? {
|
||||||
|
ar: String(snapshot.avatar.txId || '').trim(),
|
||||||
|
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
size: 'small',
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
upgraded.classList.add('avatar');
|
||||||
|
avatarEl.replaceWith(upgraded);
|
||||||
|
});
|
||||||
|
return avatarEl;
|
||||||
|
}
|
||||||
|
|
||||||
function formatChatRowTime(ts) {
|
function formatChatRowTime(ts) {
|
||||||
const value = Number(ts || 0);
|
const value = Number(ts || 0);
|
||||||
@ -40,8 +93,9 @@ export function render({ navigate }) {
|
|||||||
function renderRow(item) {
|
function renderRow(item) {
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
row.className = 'list-item dm-dialog-card';
|
||||||
|
const avatarEl = createDmAvatar(item.id);
|
||||||
|
avatarEl.classList.add('avatar');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="avatar">${item.initials}</div>
|
|
||||||
<div class="dm-row-main">
|
<div class="dm-row-main">
|
||||||
<div class="dm-row-title-wrap">
|
<div class="dm-row-title-wrap">
|
||||||
<strong class="dm-row-title">${item.name}</strong>
|
<strong class="dm-row-title">${item.name}</strong>
|
||||||
@ -54,6 +108,7 @@ export function render({ navigate }) {
|
|||||||
<span class="meta-muted dm-row-time">${item.time}</span>
|
<span class="meta-muted dm-row-time">${item.time}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
row.prepend(avatarEl);
|
||||||
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@ -73,7 +128,6 @@ export function render({ navigate }) {
|
|||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||||
return {
|
return {
|
||||||
id: login,
|
id: login,
|
||||||
initials: (login[0] || '?').toUpperCase(),
|
|
||||||
name: preview?.name || login,
|
name: preview?.name || login,
|
||||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||||
time: formatChatRowTime(lastTimeMs),
|
time: formatChatRowTime(lastTimeMs),
|
||||||
@ -96,7 +150,6 @@ export function render({ navigate }) {
|
|||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
||||||
return {
|
return {
|
||||||
id: login,
|
id: login,
|
||||||
initials: (login[0] || '?').toUpperCase(),
|
|
||||||
name: login,
|
name: login,
|
||||||
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
||||||
time: formatChatRowTime(lastTimeMs),
|
time: formatChatRowTime(lastTimeMs),
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||||
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||||
|
|
||||||
@ -14,6 +16,14 @@ function normalizeLogin(value) {
|
|||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDebounced(fn, delayMs = 2000) {
|
||||||
|
let timer = 0;
|
||||||
|
return (...args) => {
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => fn(...args), delayMs);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normKey(value) {
|
function normKey(value) {
|
||||||
return normalizeLogin(value).toLowerCase();
|
return normalizeLogin(value).toLowerCase();
|
||||||
}
|
}
|
||||||
@ -507,6 +517,7 @@ let persistedCenterHistory = [];
|
|||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
|
||||||
|
const routeLogin = normalizeLogin(route?.params?.login || '');
|
||||||
if (!keepHistory) {
|
if (!keepHistory) {
|
||||||
persistedCenterLogin = '';
|
persistedCenterLogin = '';
|
||||||
persistedCenterHistory = [];
|
persistedCenterHistory = [];
|
||||||
@ -533,7 +544,7 @@ export function render({ navigate, route }) {
|
|||||||
const cleanLogin = normalizeLogin(login);
|
const cleanLogin = normalizeLogin(login);
|
||||||
if (!cleanLogin) return '';
|
if (!cleanLogin) return '';
|
||||||
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
|
||||||
return `user/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
|
return makeProfileRoute(cleanLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
function helpText() {
|
function helpText() {
|
||||||
@ -551,6 +562,15 @@ export function render({ navigate, route }) {
|
|||||||
persistedCenterHistory = [...centerHistory];
|
persistedCenterHistory = [...centerHistory];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncLinksUrl(login, { push = false } = {}) {
|
||||||
|
const clean = normalizeLogin(login);
|
||||||
|
if (!clean) return;
|
||||||
|
const nextPath = `/${makeProfileLinksRoute(clean)}`;
|
||||||
|
if (window.location.pathname === nextPath) return;
|
||||||
|
if (push) window.history.pushState({}, '', nextPath);
|
||||||
|
else window.history.replaceState({}, '', nextPath);
|
||||||
|
}
|
||||||
|
|
||||||
function setBackButtonState(backBtn) {
|
function setBackButtonState(backBtn) {
|
||||||
if (!(backBtn instanceof HTMLButtonElement)) return;
|
if (!(backBtn instanceof HTMLButtonElement)) return;
|
||||||
backBtn.disabled = centerHistory.length === 0;
|
backBtn.disabled = centerHistory.length === 0;
|
||||||
@ -568,13 +588,8 @@ export function render({ navigate, route }) {
|
|||||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-muted" id="network-search-meta">Введите логин и нажмите «Искать».</div>
|
<div class="meta-muted" id="network-search-meta">Введите логин. Поиск начнётся автоматически через 2 секунды.</div>
|
||||||
<div class="stack" id="network-search-results"></div>
|
<div class="stack" id="network-search-results"></div>
|
||||||
<div class="form-actions-grid">
|
|
||||||
<button class="ghost-btn" type="button" id="network-search-profile" disabled>Показать профиль</button>
|
|
||||||
<button class="primary-btn" type="button" id="network-search-graph" disabled>Показать связи</button>
|
|
||||||
</div>
|
|
||||||
<button class="secondary-btn" type="button" id="network-search-ok" disabled>OK</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -585,9 +600,6 @@ export function render({ navigate, route }) {
|
|||||||
const runBtn = root.querySelector('#network-search-run');
|
const runBtn = root.querySelector('#network-search-run');
|
||||||
const metaEl = root.querySelector('#network-search-meta');
|
const metaEl = root.querySelector('#network-search-meta');
|
||||||
const resultsEl = root.querySelector('#network-search-results');
|
const resultsEl = root.querySelector('#network-search-results');
|
||||||
const profileBtn = root.querySelector('#network-search-profile');
|
|
||||||
const graphBtn = root.querySelector('#network-search-graph');
|
|
||||||
const okBtn = root.querySelector('#network-search-ok');
|
|
||||||
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
|
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
@ -607,10 +619,6 @@ export function render({ navigate, route }) {
|
|||||||
if (!(row instanceof HTMLElement)) return;
|
if (!(row instanceof HTMLElement)) return;
|
||||||
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
|
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
|
||||||
});
|
});
|
||||||
const hasSelected = Boolean(selectedLogin);
|
|
||||||
if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected;
|
|
||||||
if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected;
|
|
||||||
if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCandidates = (logins) => {
|
const renderCandidates = (logins) => {
|
||||||
@ -661,6 +669,8 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
closeBtn?.addEventListener('click', close);
|
closeBtn?.addEventListener('click', close);
|
||||||
runBtn?.addEventListener('click', () => { void runSearch(); });
|
runBtn?.addEventListener('click', () => { void runSearch(); });
|
||||||
|
const debouncedSearch = createDebounced(() => { void runSearch(); }, 2000);
|
||||||
|
inputEl.addEventListener('input', debouncedSearch);
|
||||||
inputEl.addEventListener('keydown', (event) => {
|
inputEl.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -672,23 +682,11 @@ export function render({ navigate, route }) {
|
|||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
const button = target.closest('[data-candidate]');
|
const button = target.closest('[data-candidate]');
|
||||||
if (!(button instanceof HTMLElement)) return;
|
if (!(button instanceof HTMLElement)) return;
|
||||||
applySelection(String(button.dataset.candidate || ''));
|
const nextLogin = String(button.dataset.candidate || '');
|
||||||
});
|
applySelection(nextLogin);
|
||||||
profileBtn?.addEventListener('click', () => {
|
if (!nextLogin) return;
|
||||||
if (!selectedLogin) return;
|
|
||||||
const routeTo = profileInfoRoute(selectedLogin);
|
|
||||||
if (!routeTo) return;
|
|
||||||
close();
|
close();
|
||||||
navigate(routeTo);
|
void load(nextLogin, { pushHistory: true });
|
||||||
});
|
|
||||||
graphBtn?.addEventListener('click', () => {
|
|
||||||
if (!selectedLogin) return;
|
|
||||||
close();
|
|
||||||
void load(selectedLogin, { pushHistory: true });
|
|
||||||
});
|
|
||||||
okBtn?.addEventListener('click', () => {
|
|
||||||
if (!selectedLogin) return;
|
|
||||||
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.setTimeout(() => inputEl.focus(), 0);
|
window.setTimeout(() => inputEl.focus(), 0);
|
||||||
@ -765,6 +763,7 @@ export function render({ navigate, route }) {
|
|||||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||||
centerHistory.push(prevCenter);
|
centerHistory.push(prevCenter);
|
||||||
}
|
}
|
||||||
|
syncLinksUrl(targetCenter, { push: pushHistory });
|
||||||
|
|
||||||
const model = buildGraphModel(graph, targetCenter);
|
const model = buildGraphModel(graph, targetCenter);
|
||||||
const layout = layoutNodes(model);
|
const layout = layoutNodes(model);
|
||||||
@ -839,13 +838,22 @@ export function render({ navigate, route }) {
|
|||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
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 });
|
void load(centerLogin, { pushHistory: false });
|
||||||
} else {
|
} else {
|
||||||
centerLogin = normalizeLogin(state.session.login || '');
|
centerLogin = normalizeLogin(state.session.login || '');
|
||||||
centerHistory = [];
|
centerHistory = [];
|
||||||
persistHistory();
|
persistHistory();
|
||||||
void load(centerLogin, { pushHistory: false });
|
if (centerLogin) {
|
||||||
|
void load(centerLogin, { pushHistory: false });
|
||||||
|
} else {
|
||||||
|
window.setTimeout(() => openSearchModal(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setBackButtonState(backBtnEl);
|
setBackButtonState(backBtnEl);
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines } from '../services/user-connections.js';
|
import { buildIdentityLines } from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@ -29,6 +30,40 @@ function escapeHtml(text) {
|
|||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openProfileInfoModal({ title, text }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="profile-info-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||||
|
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||||
|
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const close = () => { root.innerHTML = ''; };
|
||||||
|
root.querySelector('#profile-info-close')?.addEventListener('click', close);
|
||||||
|
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
||||||
|
if (event.target?.id === 'profile-info-modal') close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function officialInfoText() {
|
||||||
|
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
||||||
|
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shineInfoText() {
|
||||||
|
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
||||||
|
+ 'Пять принципов сияющих:\n'
|
||||||
|
+ '1) сияющие не обманывают;\n'
|
||||||
|
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
||||||
|
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
||||||
|
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
||||||
|
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const login = state.session.login || profile.login;
|
const login = state.session.login || profile.login;
|
||||||
|
|
||||||
@ -39,14 +74,22 @@ export function render({ navigate }) {
|
|||||||
topActions.className = 'profile-top-actions';
|
topActions.className = 'profile-top-actions';
|
||||||
topActions.innerHTML = `
|
topActions.innerHTML = `
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button>
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="wallet">Кошелёк</button>
|
|
||||||
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
|
||||||
`;
|
`;
|
||||||
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
|
||||||
topActions.querySelector('[data-top-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
|
||||||
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||||
screen.append(topActions);
|
screen.append(topActions);
|
||||||
|
|
||||||
|
const bottomActions = document.createElement('div');
|
||||||
|
bottomActions.className = 'profile-bottom-actions';
|
||||||
|
bottomActions.innerHTML = `
|
||||||
|
<button class="ghost-btn profile-top-action-btn" type="button" data-bottom-action="wallet">Кошелёк</button>
|
||||||
|
<button class="ghost-btn profile-top-action-btn profile-links-two-line" type="button" data-bottom-action="links">Показать\nсвязи</button>
|
||||||
|
`;
|
||||||
|
bottomActions.querySelector('[data-bottom-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
|
||||||
|
bottomActions.querySelector('[data-bottom-action="links"]')?.addEventListener('click', () => navigate(makeProfileLinksRoute(login)));
|
||||||
|
screen.append(bottomActions);
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack profile-main-card';
|
card.className = 'card stack profile-main-card';
|
||||||
|
|
||||||
@ -126,6 +169,21 @@ export function render({ navigate }) {
|
|||||||
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
updateToggleButton(shineBtn, 'Сияющий', shine.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
officialBtn?.classList.add('profile-badge-trigger');
|
||||||
|
shineBtn?.classList.add('profile-badge-trigger');
|
||||||
|
officialBtn?.addEventListener('click', () => {
|
||||||
|
openProfileInfoModal({
|
||||||
|
title: 'Официальный канал',
|
||||||
|
text: officialInfoText(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
shineBtn?.addEventListener('click', () => {
|
||||||
|
openProfileInfoModal({
|
||||||
|
title: 'Справка о сияющих',
|
||||||
|
text: shineInfoText(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function renderFields(fields) {
|
function renderFields(fields) {
|
||||||
listWrap.innerHTML = '';
|
listWrap.innerHTML = '';
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
|
|||||||
@ -32,13 +32,19 @@ export function render({ navigate }) {
|
|||||||
registerButton.textContent = 'Зарегистрироваться';
|
registerButton.textContent = 'Зарегистрироваться';
|
||||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||||
|
|
||||||
|
const guestViewButton = document.createElement('button');
|
||||||
|
guestViewButton.className = 'ghost-btn';
|
||||||
|
guestViewButton.type = 'button';
|
||||||
|
guestViewButton.textContent = 'Только просмотр';
|
||||||
|
guestViewButton.addEventListener('click', () => navigate('network-view'));
|
||||||
|
|
||||||
const settingsButton = document.createElement('button');
|
const settingsButton = document.createElement('button');
|
||||||
settingsButton.className = 'ghost-btn';
|
settingsButton.className = 'ghost-btn';
|
||||||
settingsButton.type = 'button';
|
settingsButton.type = 'button';
|
||||||
settingsButton.textContent = 'Настройки';
|
settingsButton.textContent = 'Настройки';
|
||||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||||
|
|
||||||
actions.append(loginButton, registerButton, settingsButton);
|
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
|
||||||
screen.append(logo, title, actions);
|
screen.append(logo, title, actions);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
loadUserProfileCard,
|
loadUserProfileCard,
|
||||||
} from '../services/user-connections.js';
|
} from '../services/user-connections.js';
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
import { navigateBack } from '../router.js';
|
import { navigateBack } from '../router.js';
|
||||||
|
|
||||||
@ -20,6 +21,40 @@ function escapeHtml(text) {
|
|||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openProfileInfoModal({ title, text }) {
|
||||||
|
const root = document.getElementById('modal-root');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="profile-info-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||||
|
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||||
|
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const close = () => { root.innerHTML = ''; };
|
||||||
|
root.querySelector('#profile-info-close')?.addEventListener('click', close);
|
||||||
|
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
||||||
|
if (event.target?.id === 'profile-info-modal') close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function officialInfoText() {
|
||||||
|
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
||||||
|
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shineInfoText() {
|
||||||
|
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
||||||
|
+ 'Пять принципов сияющих:\n'
|
||||||
|
+ '1) сияющие не обманывают;\n'
|
||||||
|
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
||||||
|
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
||||||
|
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
||||||
|
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||||||
|
}
|
||||||
|
|
||||||
function genderText(value) {
|
function genderText(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
if (normalized === 'male') return 'Мужской';
|
if (normalized === 'male') return 'Мужской';
|
||||||
@ -141,8 +176,8 @@ function renderIdentity(card) {
|
|||||||
function renderReadOnlyBadges(card) {
|
function renderReadOnlyBadges(card) {
|
||||||
return `
|
return `
|
||||||
<div class="row wrap-row">
|
<div class="row wrap-row">
|
||||||
<span class="badge ${card.official ? 'is-yes-official' : 'is-no'}">Официальный: ${card.official ? 'Yes' : 'No'}</span>
|
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button>
|
||||||
<span class="badge ${card.shine ? 'is-yes-shine' : 'is-no'}">Сияющий: ${card.shine ? 'Yes' : 'No'}</span>
|
<button class="badge profile-badge-trigger ${card.shine ? 'is-yes-shine' : 'is-no'}" type="button" data-profile-info="shine">Сияющий: ${card.shine ? 'Yes' : 'No'}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -157,7 +192,7 @@ function renderRelations(flags) {
|
|||||||
const hasOpinion = opinionItems.length > 0;
|
const hasOpinion = opinionItems.length > 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card stack user-relations-list">
|
<div class="card stack user-relations-list" data-profile-relations="true">
|
||||||
${rows.map((row) => `
|
${rows.map((row) => `
|
||||||
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
|
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
|
||||||
<span class="user-rel-text">${escapeHtml(row.text)}</span>
|
<span class="user-rel-text">${escapeHtml(row.text)}</span>
|
||||||
@ -174,7 +209,7 @@ function renderRelations(flags) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-rel-row">
|
<div class="user-rel-row">
|
||||||
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
|
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
|
||||||
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить связи' : 'Добавить связь'}</button>
|
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -191,7 +226,7 @@ function openOpinionMenuModal({ flags, onApply }) {
|
|||||||
];
|
];
|
||||||
const rowsHtml = items
|
const rowsHtml = items
|
||||||
.filter((item) => item.kind !== activeKind)
|
.filter((item) => item.kind !== activeKind)
|
||||||
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Добавить: ${item.title}</button>`)
|
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Высказать: ${item.title}</button>`)
|
||||||
.join('');
|
.join('');
|
||||||
const removeHtml = activeKind
|
const removeHtml = activeKind
|
||||||
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
|
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
|
||||||
@ -200,7 +235,7 @@ function openOpinionMenuModal({ flags, onApply }) {
|
|||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="user-opinion-modal">
|
<div class="modal" id="user-opinion-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">${activeKind ? 'Изменить связи' : 'Добавить связь'}</h3>
|
<h3 class="modal-title">${activeKind ? 'Изменить мнение' : 'Добавить мнение'}</h3>
|
||||||
<div class="stack">${rowsHtml}${removeHtml}</div>
|
<div class="stack">${rowsHtml}${removeHtml}</div>
|
||||||
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
|
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
@ -262,11 +297,13 @@ export function render({ navigate, route }) {
|
|||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Профиль пользователя',
|
title: 'Профиль пользователя',
|
||||||
leftAction: { label: '←', onClick: () => navigateBack() },
|
leftAction: { label: '←', onClick: () => navigateBack() },
|
||||||
rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
|
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
|
||||||
}),
|
}),
|
||||||
status,
|
status,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
|
||||||
|
linksHeaderBtn?.classList.add('profile-links-header-btn');
|
||||||
|
|
||||||
let currentCard = null;
|
let currentCard = null;
|
||||||
let currentFlags = null;
|
let currentFlags = null;
|
||||||
@ -285,7 +322,7 @@ export function render({ navigate, route }) {
|
|||||||
contactBtn.disabled = Boolean(isSelf);
|
contactBtn.disabled = Boolean(isSelf);
|
||||||
friendBtn.disabled = Boolean(isSelf);
|
friendBtn.disabled = Boolean(isSelf);
|
||||||
followBtn.disabled = Boolean(isSelf);
|
followBtn.disabled = Boolean(isSelf);
|
||||||
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить связи' : 'Добавить связь';
|
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
|
||||||
opinionBtn.disabled = Boolean(isSelf);
|
opinionBtn.disabled = Boolean(isSelf);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,6 +358,10 @@ export function render({ navigate, route }) {
|
|||||||
body.prepend(identityCard);
|
body.prepend(identityCard);
|
||||||
|
|
||||||
syncActionButtons();
|
syncActionButtons();
|
||||||
|
if (String(route?.params?.section || '').toLowerCase() === 'links') {
|
||||||
|
const rel = body.querySelector('[data-profile-relations="true"]');
|
||||||
|
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
status.className = 'status-line is-available';
|
status.className = 'status-line is-available';
|
||||||
status.textContent = 'Профиль обновлён.';
|
status.textContent = 'Профиль обновлён.';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -418,6 +459,17 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await refresh();
|
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) {
|
} catch (error) {
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
|
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
|
||||||
@ -429,6 +481,22 @@ export function render({ navigate, route }) {
|
|||||||
body.addEventListener('click', (event) => {
|
body.addEventListener('click', (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
const infoBtn = target.closest('[data-profile-info]');
|
||||||
|
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
|
||||||
|
if (infoKind === 'official') {
|
||||||
|
openProfileInfoModal({
|
||||||
|
title: 'Официальный канал',
|
||||||
|
text: officialInfoText(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (infoKind === 'shine') {
|
||||||
|
openProfileInfoModal({
|
||||||
|
title: 'Справка о сияющих',
|
||||||
|
text: shineInfoText(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const actionBtn = target.closest('[data-relation-action]');
|
const actionBtn = target.closest('[data-relation-action]');
|
||||||
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
||||||
if (!kind) return;
|
if (!kind) return;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { parseShineRootSegment } from './services/shine-routes.js';
|
||||||
|
|
||||||
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
const ROOT_PAGES = ['messages-list', 'channels-list', 'network-view', 'notifications-view', 'profile-view'];
|
||||||
|
|
||||||
export const PRE_AUTH_PAGES = [
|
export const PRE_AUTH_PAGES = [
|
||||||
@ -20,9 +22,7 @@ export function getRoute() {
|
|||||||
.replace(/^index\.html$/i, '')
|
.replace(/^index\.html$/i, '')
|
||||||
.replace(/^index\.html\//i, '')
|
.replace(/^index\.html\//i, '')
|
||||||
.replace(/\/+$/, '');
|
.replace(/\/+$/, '');
|
||||||
if (!raw) {
|
if (!raw) return { pageId: '', params: {} };
|
||||||
return { pageId: '', params: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = raw.split('/').filter(Boolean);
|
const segments = raw.split('/').filter(Boolean);
|
||||||
const pageId = segments[0] || '';
|
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') {
|
if (pageId === 'chat-view') {
|
||||||
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
||||||
}
|
}
|
||||||
@ -55,41 +122,6 @@ export function getRoute() {
|
|||||||
return { pageId, params: { channelId: dynamicId || '' } };
|
return { pageId, params: { channelId: dynamicId || '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageId === 'channel') {
|
|
||||||
// Короткий формат:
|
|
||||||
// /channel/{ownerBlockchainName}/{channelName}
|
|
||||||
// /channel/{ownerBlockchainName}/{channelName}/{messageBlockNumber}
|
|
||||||
const ownerBlockchainName = decodePart(segments[1] || '');
|
|
||||||
const channelName = decodePart(segments[2] || '');
|
|
||||||
const messageBlockNumber = segments[3] || '';
|
|
||||||
|
|
||||||
if (ownerBlockchainName && channelName && messageBlockNumber) {
|
|
||||||
return {
|
|
||||||
pageId: 'channel-thread-view',
|
|
||||||
params: {
|
|
||||||
ownerBlockchainName,
|
|
||||||
channelName,
|
|
||||||
messageBlockNumber,
|
|
||||||
messageBlockHash: '',
|
|
||||||
// поддержка старого контракта страницы треда
|
|
||||||
messageBlockchainName: '',
|
|
||||||
channelOwnerBlockchainName: ownerBlockchainName,
|
|
||||||
channelRootBlockNumber: '',
|
|
||||||
channelRootBlockHash: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageId: 'channel-view',
|
|
||||||
params: {
|
|
||||||
ownerBlockchainName,
|
|
||||||
channelName,
|
|
||||||
channelId: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageId === 'channel-thread-view') {
|
if (pageId === 'channel-thread-view') {
|
||||||
return {
|
return {
|
||||||
pageId,
|
pageId,
|
||||||
@ -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') {
|
if (pageId === 'device-session-view') {
|
||||||
return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
|
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') {
|
if (pageId === 'network-view') {
|
||||||
return {
|
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||||
pageId,
|
|
||||||
params: {
|
|
||||||
mode: segments[1] ? decodePart(segments[1]) : '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageId === 'channels-list') {
|
if (pageId === 'channels-list') {
|
||||||
return {
|
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||||
pageId,
|
|
||||||
params: {
|
|
||||||
mode: segments[1] ? decodePart(segments[1]) : '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { pageId, params: {} };
|
return { pageId, params: {} };
|
||||||
@ -185,9 +183,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'language-view' ||
|
pageId === 'language-view' ||
|
||||||
pageId === 'app-log-view' ||
|
pageId === 'app-log-view' ||
|
||||||
pageId === 'pwa-diagnostics-view'
|
pageId === 'pwa-diagnostics-view'
|
||||||
) {
|
) return 'profile-view';
|
||||||
return 'profile-view';
|
|
||||||
}
|
|
||||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||||
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
if (pageId === 'channel-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||||
if (pageId === 'user') return 'messages-list';
|
if (pageId === 'user') return 'messages-list';
|
||||||
|
|||||||
45
shine-UI/js/services/auth-required-modal.js
Normal file
45
shine-UI/js/services/auth-required-modal.js
Normal file
@ -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 = `
|
||||||
|
<div class="modal" id="auth-required-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||||
|
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" type="button" id="auth-required-close">Закрыть</button>
|
||||||
|
<button class="primary-btn" type="button" id="auth-required-start">На старт</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const close = () => { root.innerHTML = ''; };
|
||||||
|
root.querySelector('#auth-required-close')?.addEventListener('click', close);
|
||||||
|
root.querySelector('#auth-required-start')?.addEventListener('click', () => {
|
||||||
|
close();
|
||||||
|
navigate(startRoute);
|
||||||
|
});
|
||||||
|
root.querySelector('#auth-required-modal')?.addEventListener('click', (event) => {
|
||||||
|
if (event.target?.id === 'auth-required-modal') close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
65
shine-UI/js/services/shine-routes.js
Normal file
65
shine-UI/js/services/shine-routes.js
Normal file
@ -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)));
|
||||||
|
}
|
||||||
@ -47,7 +47,23 @@ function toToggleMap(snapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readArray(payload, key) {
|
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;
|
return Array.isArray(value) ? uniqueLogins(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2727,7 +2727,7 @@ textarea.input {
|
|||||||
|
|
||||||
.channels-tabs {
|
.channels-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 14px;
|
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));
|
background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-top-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-top-action-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.channels-tab-btn {
|
.channels-tab-btn {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -4104,7 +4117,13 @@ textarea.input {
|
|||||||
|
|
||||||
.profile-top-actions {
|
.profile-top-actions {
|
||||||
display: grid;
|
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;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4113,13 +4132,18 @@ textarea.input {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1.15;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: pre-line;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-links-header-btn {
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-main-card {
|
.profile-main-card {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 2px 8px 8px;
|
padding: 2px 8px 8px;
|
||||||
|
|||||||
@ -228,6 +228,12 @@ public final class DatabaseTriggersInstaller {
|
|||||||
NEW.msg_sub_type,
|
NEW.msg_sub_type,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
|
(
|
||||||
|
SELECT su.login
|
||||||
|
FROM solana_users su
|
||||||
|
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -242,6 +248,12 @@ public final class DatabaseTriggersInstaller {
|
|||||||
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
|
(
|
||||||
|
SELECT su.login
|
||||||
|
FROM solana_users su
|
||||||
|
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -262,6 +274,12 @@ public final class DatabaseTriggersInstaller {
|
|||||||
AND rel_type = NEW.msg_sub_type
|
AND rel_type = NEW.msg_sub_type
|
||||||
AND to_login = COALESCE(
|
AND to_login = COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
|
(
|
||||||
|
SELECT su.login
|
||||||
|
FROM solana_users su
|
||||||
|
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -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 NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d, %d, %d, %d, %d)
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
|
(
|
||||||
|
SELECT su.login
|
||||||
|
FROM solana_users su
|
||||||
|
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -289,6 +313,12 @@ public final class DatabaseTriggersInstaller {
|
|||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
AND to_login = COALESCE(
|
AND to_login = COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
|
(
|
||||||
|
SELECT su.login
|
||||||
|
FROM solana_users su
|
||||||
|
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
@ -312,6 +342,12 @@ public final class DatabaseTriggersInstaller {
|
|||||||
END
|
END
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
|
(
|
||||||
|
SELECT su.login
|
||||||
|
FROM solana_users su
|
||||||
|
WHERE su.blockchain_name = NEW.to_bch_name COLLATE NOCASE
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
CASE
|
CASE
|
||||||
WHEN NEW.to_bch_name IS NOT NULL
|
WHEN NEW.to_bch_name IS NOT NULL
|
||||||
AND length(NEW.to_bch_name) > 4
|
AND length(NEW.to_bch_name) > 4
|
||||||
|
|||||||
@ -40,13 +40,15 @@ public final class ConnectionsStateDAO {
|
|||||||
*/
|
*/
|
||||||
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
public List<String> listOutgoingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT u.login AS friend_login
|
SELECT COALESCE(u_login.login, u_bch.login, cs.to_login) AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
JOIN solana_users u
|
LEFT JOIN solana_users u_login
|
||||||
ON u.login = cs.to_login COLLATE NOCASE
|
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
|
WHERE cs.login = ? COLLATE NOCASE
|
||||||
AND cs.rel_type = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY u.login
|
ORDER BY friend_login
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
@ -68,19 +70,25 @@ public final class ConnectionsStateDAO {
|
|||||||
*/
|
*/
|
||||||
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
public List<String> listIncomingByRelTypeCanonical(Connection c, String loginAnyCase, int relType) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT u.login AS friend_login
|
SELECT COALESCE(u_actor.login, cs.login) AS friend_login
|
||||||
FROM connections_state cs
|
FROM connections_state cs
|
||||||
JOIN solana_users u
|
LEFT JOIN solana_users u_actor
|
||||||
ON u.login = cs.login COLLATE NOCASE
|
ON u_actor.login = cs.login COLLATE NOCASE
|
||||||
WHERE cs.to_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 = ?
|
AND cs.rel_type = ?
|
||||||
ORDER BY u.login
|
ORDER BY friend_login
|
||||||
""";
|
""";
|
||||||
|
|
||||||
List<String> out = new ArrayList<>();
|
List<String> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, loginAnyCase);
|
ps.setString(1, loginAnyCase);
|
||||||
ps.setInt(2, relType);
|
ps.setString(2, loginAnyCase);
|
||||||
|
ps.setInt(3, relType);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
String v = rs.getString("friend_login");
|
String v = rs.getString("friend_login");
|
||||||
|
|||||||
@ -30,10 +30,14 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
@Override
|
@Override
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||||
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
|
||||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
String requestedLogin = (req.getLogin() == null || req.getLogin().isBlank()) ? "" : req.getLogin().trim();
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
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()) {
|
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
|
||||||
String canonicalLogin = findCanonicalLogin(c, requestedLogin);
|
String canonicalLogin = findCanonicalLogin(c, requestedLogin);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user