Добавил гостевой режим, единые shine-ссылки и пометку о нестабильности мнений

This commit is contained in:
AidarKC 2026-05-20 16:14:59 +03:00
parent aa35d87885
commit 21413268f3
46 changed files with 1125 additions and 310 deletions

View File

@ -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`:

View File

@ -0,0 +1,15 @@
## Краткое описание
Добавлена отрисовка пользовательских аватаров (из профиля, Arweave) в карточках сообщений каналов, тредов и в списке личных сообщений.
## Что проверять
1. В канале у сообщений вместо буквы показывается аватар автора (если в профиле задан).
2. В треде у сообщений вместо буквы показывается аватар автора (если в профиле задан).
3. В списке личных сообщений у диалогов показывается аватар собеседника (если в профиле задан).
4. Если аватар не задан или недоступен, корректно остаётся fallback (буква).
5. Форма и размер остаются круглыми и визуально не ломают карточки.
## Ожидаемый результат
Аватары подгружаются автоматически после открытия экрана; при отсутствии аватара отображается стандартный fallback без ошибок UI.
## Статус
`pending`

View File

@ -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 (между своими клиентами/агентом) в рамках одного логина.

View File

@ -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`
## Локальные команды ## Локальные команды

View File

@ -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`

View File

@ -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`

View File

@ -1,2 +1,2 @@
client.version=1.2.79 client.version=1.2.80
server.version=1.2.73 server.version=1.2.74

View File

@ -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
""" """
} }

View File

@ -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

View 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()

View File

@ -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', () => {

View File

@ -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();
}); });
} }

View File

@ -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);

View File

@ -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) => {

View File

@ -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);

View File

@ -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);
}); });

View File

@ -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),

View File

@ -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);

View File

@ -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("'", '&#39;'); .replaceAll("'", '&#39;');
} }
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) => {

View File

@ -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;
} }

View File

@ -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("'", '&#39;'); .replaceAll("'", '&#39;');
} }
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;

View File

@ -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';

View File

@ -0,0 +1,45 @@
import { navigate } from '../router.js';
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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();
});
}

View 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)));
}

View File

@ -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;
} }

View File

@ -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;

View File

@ -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

View File

@ -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");

View File

@ -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);