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