SHiNE-server/shine-UI/js/app.js

904 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js';
import {
handleIncomingCallInvite,
handleIncomingCallSignal,
setCallDebugReporter,
startDebugConnectionAsInitiator,
startDebugConnectionAsResponder,
} from './services/call-service.js';
import {
authService,
addAppLogEntry,
authorizeSession,
hydrateMessagesFromStore,
isSessionInvalidError,
refreshSessions,
setSessionAuthorizedHandler,
setSessionResetHandler,
state,
terminateCurrentSession,
addSignedMessageToChat,
markIncomingReadByBaseKey,
markOutgoingReadByBaseKey,
setContacts,
} from './state.js';
import * as startView from './pages/start-view.js';
import * as entrySettingsView from './pages/entry-settings-view.js';
import * as registerView from './pages/register-view.js';
import * as registrationPaymentView from './pages/registration-payment-view.js';
import * as registrationKeysView from './pages/registration-keys-view.js';
import * as topupView from './pages/topup-view.js';
import * as loginView from './pages/login-view.js';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginPasswordView from './pages/login-password-view.js';
import * as keyStorageView from './pages/key-storage-view.js';
import * as profileView from './pages/profile-view.js';
import * as profileEditView from './pages/profile-edit-view.js';
import * as walletView from './pages/wallet-view.js';
import * as settingsView from './pages/settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js';
import * as deviceView from './pages/device-view.js';
import * as connectDeviceView from './pages/connect-device-view.js';
import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js';
import * as deviceSessionView from './pages/device-session-view.js';
import * as languageView from './pages/language-view.js';
import * as appLogView from './pages/app-log-view.js';
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js';
import * as channelThreadView from './pages/channel-thread-view.js';
import * as addChannelView from './pages/add-channel-view.js';
import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
const routes = {
'start-view': startView,
'entry-settings-view': entrySettingsView,
'register-view': registerView,
'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView,
'topup-view': topupView,
'login-view': loginView,
'login-camera-view': loginCameraView,
'login-password-view': loginPasswordView,
'key-storage-view': keyStorageView,
'profile-view': profileView,
'profile-edit-view': profileEditView,
'wallet-view': walletView,
'settings-view': settingsView,
'server-settings-view': serverSettingsView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
'device-qr-view': deviceQrView,
'device-camera-view': deviceCameraView,
'show-keys-view': showKeysView,
'device-session-view': deviceSessionView,
'language-view': languageView,
'app-log-view': appLogView,
'pwa-diagnostics-view': pwaDiagnosticsView,
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
'user-profile-view': userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'channel-thread-view': channelThreadView,
'add-channel-view': addChannelView,
'network-view': networkView,
'notifications-view': notificationsView,
};
const screenEl = document.getElementById('app-screen');
const toolbarEl = document.getElementById('toolbar-slot');
const appShellEl = document.querySelector('.app-shell');
const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000;
const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim();
const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/;
let currentCleanup = null;
let pingIntervalId = null;
let reconnectIntervalId = null;
let sessionRuntimeStarted = false;
let connectionState = '';
let connectionStatusText = '';
let connectionRetryBannerEl = null;
let connectionStatusCountdownId = null;
let connectionNextRetryAtMs = 0;
let connectionCheckInFlight = false;
let wsSessionRestoreInFlight = null;
let uiUpdateReloadScheduled = false;
let pwaUpdateCheckAttempted = false;
let uiVersionCheckInFlight = false;
setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
function ensureConnectionIndicatorEl() {
return document.getElementById('toolbar-connection-indicator');
}
function ensureConnectionRetryBannerEl() {
if (connectionRetryBannerEl) return connectionRetryBannerEl;
if (!appShellEl) return null;
const el = document.createElement('div');
el.id = 'connection-retry-banner';
el.className = 'connection-retry-banner is-connecting';
el.textContent = 'Соединяюсь…';
el.addEventListener('click', () => {
void triggerImmediateConnectionRetry();
});
appShellEl.append(el);
connectionRetryBannerEl = el;
return el;
}
function stopConnectionCountdown() {
if (connectionStatusCountdownId) {
window.clearInterval(connectionStatusCountdownId);
connectionStatusCountdownId = null;
}
}
function getConnectionRetrySecondsLeft() {
const leftMs = Math.max(0, Number(connectionNextRetryAtMs || 0) - Date.now());
return Math.ceil(leftMs / 1000);
}
function refreshConnectionUi() {
const state = String(connectionState || 'connecting').trim();
const indicatorEl = ensureConnectionIndicatorEl();
if (indicatorEl) {
indicatorEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating', 'is-unknown');
indicatorEl.classList.add(`is-${state || 'unknown'}`);
}
const bannerEl = ensureConnectionRetryBannerEl();
if (!bannerEl) return;
if (state === 'connected' || state === 'updating') {
bannerEl.hidden = true;
stopConnectionCountdown();
return;
}
bannerEl.hidden = false;
bannerEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating');
bannerEl.classList.add(`is-${state}`);
if (connectionStatusText) {
bannerEl.textContent = connectionStatusText;
return;
}
if (state === 'connecting') {
bannerEl.textContent = 'Соединяюсь… Нажмите, чтобы попробовать сразу';
return;
}
if (state === 'disconnected') {
const secs = getConnectionRetrySecondsLeft();
bannerEl.textContent = `Нет соединения. Повтор через ${secs}с. Нажмите для попытки сейчас`;
return;
}
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
}
function startConnectionCountdown() {
if (connectionStatusCountdownId) return;
connectionStatusCountdownId = window.setInterval(() => {
if (connectionState !== 'disconnected') return;
const secs = getConnectionRetrySecondsLeft();
if (secs <= 0 && !connectionCheckInFlight) {
connectionStatusText = '';
setConnectionStatus('connecting', 'Соединяюсь…');
void checkConnectionHealth();
return;
}
refreshConnectionUi();
}, 1000);
}
function setConnectionStatus(nextState, text = '') {
const state = String(nextState || '').trim();
if (!state) return;
connectionState = state;
connectionStatusText = String(text || '').trim();
if (state === 'disconnected') {
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
}
startConnectionCountdown();
} else {
stopConnectionCountdown();
}
refreshConnectionUi();
}
async function triggerImmediateConnectionRetry() {
if (connectionCheckInFlight) return;
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
setConnectionStatus('connecting', 'Соединяюсь…');
await checkConnectionHealth();
}
function extractBuildHashFromHtml(htmlText) {
const html = String(htmlText || '');
if (!html) return '';
const match = html.match(UI_BUILD_HASH_PATTERN);
return String(match?.[1] || '').trim();
}
async function fetchCurrentHostUiBuildHash() {
try {
const url = `./index.html?build_probe=${Date.now()}`;
const response = await fetch(url, { cache: 'no-store' });
if (!response?.ok) return '';
const html = await response.text();
return extractBuildHashFromHtml(html);
} catch {
return '';
}
}
async function checkAndReloadIfUiUpdated(remoteHashRaw) {
const remoteHash = String(remoteHashRaw || '').trim();
if (!remoteHash || !CURRENT_BUILD_HASH || remoteHash === CURRENT_BUILD_HASH) return;
if (uiVersionCheckInFlight) return;
uiVersionCheckInFlight = true;
try {
const latestHostHash = await fetchCurrentHostUiBuildHash();
if (!latestHostHash || latestHostHash === CURRENT_BUILD_HASH) {
addAppLogEntry({
level: 'info',
source: 'version-check',
message: `Ping сообщил другую версию UI (${remoteHash}), но текущий хост всё ещё отдаёт ${CURRENT_BUILD_HASH}. Reload пропущен, чтобы избежать цикла.`,
});
return;
}
scheduleUiReload({
source: 'version-check',
message: `Обнаружена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash} (ping: ${remoteHash})`,
delayMs: 600,
activateWaitingWorker: true,
});
} finally {
uiVersionCheckInFlight = false;
}
}
async function refreshServiceWorkers({ activateWaitingWorker = false } = {}) {
if (!('serviceWorker' in navigator)) return;
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(async (registration) => {
try {
await registration.update();
} catch {}
if (activateWaitingWorker && registration.waiting) {
try {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
} catch {}
}
}));
} catch {
// ignore service worker update failures
}
}
function scheduleUiReload({
source = 'ui-update',
message = 'Запрошено обновление интерфейса',
delayMs = 700,
activateWaitingWorker = true,
} = {}) {
if (uiUpdateReloadScheduled) return;
uiUpdateReloadScheduled = true;
addAppLogEntry({
level: 'info',
source,
message,
});
setConnectionStatus('updating');
void refreshServiceWorkers({ activateWaitingWorker });
const ms = Math.min(15_000, Math.max(200, Number(delayMs || 700)));
window.setTimeout(() => {
window.location.reload();
}, ms);
}
async function tryUpdatePwaOnFirstConnectedPing() {
if (pwaUpdateCheckAttempted) return;
pwaUpdateCheckAttempted = true;
await refreshServiceWorkers({ activateWaitingWorker: false });
}
async function checkConnectionHealth() {
if (connectionCheckInFlight) return;
connectionCheckInFlight = true;
if (connectionState !== 'connected') {
setConnectionStatus('connecting');
}
try {
const wasOpen = wsIsOpen();
if (!wasOpen) {
await authService.ws.open();
const restored = await ensureSessionAfterWsReconnect();
if (!restored) {
connectionStatusText = '';
setConnectionStatus('disconnected');
return;
}
}
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
const remoteUiBuildHash = pingResp?.payload?.uiBuildHash || pingResp?.uiBuildHash || '';
void checkAndReloadIfUiUpdated(remoteUiBuildHash);
await tryUpdatePwaOnFirstConnectedPing();
setConnectionStatus('connected');
} catch {
connectionStatusText = '';
setConnectionStatus('disconnected');
} finally {
connectionCheckInFlight = false;
}
}
function startConnectionMonitor() {
if (pingIntervalId) {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
void checkConnectionHealth();
pingIntervalId = window.setInterval(() => {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
if (connectionState === 'disconnected') {
refreshConnectionUi();
}
void checkConnectionHealth();
}, CONNECTION_CHECK_INTERVAL_MS);
}
function isReconnectAllowedNow() {
const pageId = getRoute().pageId || '';
return !PRE_AUTH_PAGES.includes(pageId);
}
function wsIsOpen() {
const ws = authService?.ws?.ws;
return !!(ws && ws.readyState === WebSocket.OPEN);
}
async function ensureSessionAfterWsReconnect() {
if (!state.session.isAuthorized) return true;
if (wsSessionRestoreInFlight) return wsSessionRestoreInFlight;
wsSessionRestoreInFlight = (async () => {
try {
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
authorizeSession({
login: resumed.login || state.session.login,
sessionId: resumed.sessionId || state.session.sessionId,
storagePwd: resumed.storagePwd || state.session.storagePwdInMemory,
});
addAppLogEntry({
level: 'info',
source: 'ws-reconnect',
message: 'Сессия восстановлена после переподключения WS',
});
return true;
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Ваша сессия устарела. Авторизируйтесь заново.',
});
return false;
}
throw error;
}
})().finally(() => {
wsSessionRestoreInFlight = null;
});
return wsSessionRestoreInFlight;
}
function showGlobalErrorAlert(title, details = {}) {
const lines = [title];
if (details.message) lines.push(`Сообщение: ${details.message}`);
if (details.pageId) lines.push(`Экран: ${details.pageId}`);
if (details.sourceUrl) lines.push(`Источник: ${details.sourceUrl}`);
if (Number.isFinite(details.lineNumber)) lines.push(`Строка: ${details.lineNumber}`);
if (Number.isFinite(details.columnNumber)) lines.push(`Колонка: ${details.columnNumber}`);
if (details.reasonType) lines.push(`Тип: ${details.reasonType}`);
if (details.stack) lines.push(`Stack:\n${details.stack}`);
window.alert(lines.join('\n'));
}
window.addEventListener('error', (event) => {
const pageId = getRoute().pageId || '';
addAppLogEntry({
level: 'error',
source: 'global_error',
message: event.message || 'Global JS error',
details: {
pageId,
sourceUrl: event.filename || '',
line: event.lineno,
column: event.colno,
stack: event.error?.stack || '',
},
});
captureClientError({
kind: 'global_error',
message: event.message || 'Global JS error',
stack: event.error?.stack || '',
sourceUrl: event.filename || '',
lineNumber: event.lineno,
columnNumber: event.colno,
context: {
pageId,
},
});
showGlobalErrorAlert('Поймана глобальная ошибка UI', {
message: event.message || 'Global JS error',
stack: event.error?.stack || '',
sourceUrl: event.filename || '',
lineNumber: event.lineno,
columnNumber: event.colno,
pageId,
});
});
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
const pageId = getRoute().pageId || '';
addAppLogEntry({
level: 'error',
source: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
details: {
pageId,
reasonType: reason?.constructor?.name || typeof reason,
stack: reason?.stack || '',
},
});
captureClientError({
kind: 'unhandled_rejection',
message: reason?.message || String(reason || 'Unhandled promise rejection'),
stack: reason?.stack || '',
context: {
pageId,
reasonType: reason?.constructor?.name || typeof reason,
},
});
showGlobalErrorAlert('Пойман необработанный Promise reject', {
message: reason?.message || String(reason || 'Unhandled promise rejection'),
stack: reason?.stack || '',
pageId,
reasonType: reason?.constructor?.name || typeof reason,
});
});
function renderPageFailureFallback(pageId, error) {
captureClientError({
kind: 'page_render_failure',
message: error?.message || 'Page render failed',
stack: error?.stack || '',
context: {
pageId,
routeHash: window.location.hash || '',
},
});
screenEl.innerHTML = '';
const wrap = document.createElement('section');
wrap.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack channels-status';
const title = document.createElement('strong');
title.textContent = 'Не удалось отрисовать экран';
const details = document.createElement('p');
details.className = 'meta-muted';
details.textContent = `Экран: ${pageId || 'неизвестно'}. Попробуйте повторить.`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';
retry.textContent = 'Повторить';
retry.addEventListener('click', () => renderApp());
card.append(title, details, retry);
wrap.append(card);
screenEl.append(wrap);
screenEl.classList.toggle('no-app-chrome', false);
toolbarEl.innerHTML = '';
refreshConnectionUi();
}
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)) {
navigate('start-view');
return;
}
if (state.session.isAuthorized && PRE_AUTH_PAGES.includes(pageId)) {
navigate('messages-list');
return;
}
const page = routes[pageId] || routes['start-view'];
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
try {
screenEl.innerHTML = '';
const screen = page.render({ route, navigate });
if (!(screen instanceof Node)) {
throw new Error('Page render returned invalid node');
}
screenEl.append(screen);
currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null;
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
refreshConnectionUi();
} catch (error) {
console.error('[renderApp] controlled fallback', error);
renderPageFailureFallback(pageId, error);
}
}
async function tryAutoLogin() {
if (!state.session.login || !state.session.sessionId) return;
try {
await authService.reconnect(state.entrySettings.shineServer);
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
authorizeSession(resumed);
await refreshSessions();
try {
const contacts = await authService.listContacts();
setContacts(contacts.contacts || []);
} catch {}
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
}
}
}
async function ensureSessionRuntimeStarted() {
if (!state.session.isAuthorized || sessionRuntimeStarted) return;
sessionRuntimeStarted = true;
await initPwaPush({
authService,
onLog: (entry) => addAppLogEntry(entry),
});
startConnectionMonitor();
if (reconnectIntervalId) {
window.clearInterval(reconnectIntervalId);
reconnectIntervalId = null;
}
reconnectIntervalId = window.setInterval(async () => {
if (!state.session.isAuthorized) return;
if (!isReconnectAllowedNow()) return;
if (wsIsOpen()) return;
try {
await authService.ws.open();
const restored = await ensureSessionAfterWsReconnect();
if (!restored) return;
addAppLogEntry({
level: 'info',
source: 'ws-reconnect',
message: 'WS переподключен автоматически',
});
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'ws-reconnect',
message: 'Попытка автопереподключения не удалась',
details: { error: error?.message || 'unknown' },
});
}
}, 15_000);
}
async function init() {
addAppLogEntry({
level: 'info',
source: 'app',
message: 'Инициализация UI запущена',
});
setSessionResetHandler(() => {
sessionRuntimeStarted = false;
startConnectionMonitor();
if (reconnectIntervalId) {
window.clearInterval(reconnectIntervalId);
reconnectIntervalId = null;
}
navigate('start-view');
});
setSessionAuthorizedHandler(() => {
void ensureSessionRuntimeStarted();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
const data = event?.data || {};
if (data.type !== 'SHINE_WEB_PUSH_EVENT') return;
const payload = data.payload || {};
const kind = String(payload.kind || '').trim();
const now = Date.now();
try {
localStorage.setItem('shine-ui-last-push-at-v1', String(now));
localStorage.setItem('shine-ui-last-push-payload-v1', JSON.stringify(payload || {}));
if (kind === 'test_push') {
localStorage.setItem('shine-ui-last-test-push-status-v1', 'доставлен');
localStorage.setItem('shine-ui-last-test-push-at-v1', String(now));
}
} catch {
// ignore localStorage errors
}
addAppLogEntry({
level: 'info',
source: 'web-push',
message: 'Получено push-событие в service worker',
details: payload,
});
window.dispatchEvent(new CustomEvent('shine-push-diagnostics-update', { detail: payload }));
});
}
authService.onEvent('SessionRevoked', async () => {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
authService.onEvent('ForceUiReload', async (evt) => {
const payload = evt?.payload || {};
const reason = String(payload.reason || 'server_debug_api').trim() || 'server_debug_api';
const reloadAfterMs = Number(payload.reloadAfterMs || 700);
scheduleUiReload({
source: 'server-ui-reload',
message: `Сервер запросил обновление UI (${reason})`,
delayMs: reloadAfterMs,
activateWaitingWorker: true,
});
});
authService.onEvent('SignedMessageArrived', async (evt) => {
const payload = evt?.payload || {};
const messageKey = String(payload.messageKey || '').trim();
const blobB64 = String(payload.blobB64 || '').trim();
if (!messageKey || !blobB64) return;
let parsed;
try {
parsed = authService.parseSignedMessageBlob(blobB64);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Не удалось распарсить входящий signed message',
details: { messageKey, error: error?.message || 'unknown' },
});
return;
}
const fromLogin = parsed.fromLogin || '';
const toLogin = parsed.toLogin || '';
const messageType = Number(parsed.messageType || 0);
const chatId = messageType === 2 ? toLogin : fromLogin;
const text = (messageType === 1 || messageType === 2)
? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0))
: '';
let shouldRefreshToolbarUnread = false;
if (messageType === 1 || messageType === 2) {
const isIncomingForCurrent = messageType === 1;
const added = addSignedMessageToChat({
chatId,
messageKey,
baseKey: parsed.baseKey,
from: isIncomingForCurrent ? 'in' : 'out',
text,
messageType,
unread: isIncomingForCurrent,
rawBlobB64: blobB64,
});
if (added) {
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: isIncomingForCurrent
? `Новое входящее сообщение от ${fromLogin}`
: `Синхронизирована исходящая копия в чате ${chatId}`,
details: { messageKey, baseKey: parsed.baseKey, messageType },
});
}
if (added && isIncomingForCurrent) {
shouldRefreshToolbarUnread = true;
}
if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
}
} else if (messageType === 3 || messageType === 4) {
let refBaseKey = String(payload.receiptRefBaseKey || '').trim();
if (!refBaseKey) {
try {
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
} catch {}
}
if (refBaseKey) {
if (messageType === 3) {
markOutgoingReadByBaseKey(refBaseKey);
} else {
markIncomingReadByBaseKey(refBaseKey);
}
}
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: 'Получено подтверждение прочтения',
details: { messageKey, baseKey: parsed.baseKey, messageType },
});
}
try {
await authService.ackSessionDelivery(messageKey);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Не удалось отправить ACK доставки по сессии',
details: { messageKey, error: error?.message || 'unknown' },
});
}
const pageId = getRoute().pageId || '';
if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) {
renderApp();
}
});
authService.onEvent('IncomingCallInvite', async (evt) => {
try { await handleIncomingCallInvite(evt); } catch {}
});
authService.onEvent('IncomingCallSignal', async (evt) => {
try { await handleIncomingCallSignal(evt); } catch {}
});
authService.onEvent('DebugConnectPrepareResponder', async (evt) => {
try {
const p = evt?.payload || {};
await startDebugConnectionAsResponder({
runId: p.runId,
callId: p.callId,
peerLogin: p.peerLogin,
peerSessionId: p.peerSessionId,
});
addAppLogEntry({
level: 'info',
source: 'debug-connect',
message: 'Получена команда debug responder',
details: p,
});
} catch (error) {
addAppLogEntry({
level: 'error',
source: 'debug-connect',
message: 'Ошибка запуска debug responder',
details: { error: error?.message || 'unknown' },
});
await authService.reportClientDebug({
runId: evt?.payload?.runId || '',
level: 'error',
message: 'debug_responder_failed',
details: error?.message || 'unknown',
});
}
});
authService.onEvent('DebugConnectStartInitiator', async (evt) => {
try {
const p = evt?.payload || {};
await startDebugConnectionAsInitiator({
runId: p.runId,
callId: p.callId,
peerLogin: p.peerLogin,
peerSessionId: p.peerSessionId,
});
addAppLogEntry({
level: 'info',
source: 'debug-connect',
message: 'Получена команда debug initiator',
details: p,
});
} catch (error) {
addAppLogEntry({
level: 'error',
source: 'debug-connect',
message: 'Ошибка запуска debug initiator',
details: { error: error?.message || 'unknown' },
});
await authService.reportClientDebug({
runId: evt?.payload?.runId || '',
level: 'error',
message: 'debug_initiator_failed',
details: error?.message || 'unknown',
});
}
});
await tryAutoLogin();
await hydrateMessagesFromStore();
startConnectionMonitor();
await ensureSessionRuntimeStarted();
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'messages-list' : 'start-view');
} else {
renderApp();
}
window.addEventListener('hashchange', renderApp);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
void checkConnectionHealth();
});
}
init();