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

458 lines
15 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 { handleIncomingCallInvite, handleIncomingCallSignal } from './services/call-service.js';
import {
authService,
addAppLogEntry,
authorizeSession,
hydrateMessagesFromStore,
isSessionInvalidError,
refreshSessions,
setSessionAuthorizedHandler,
setSessionResetHandler,
state,
terminateCurrentSession,
addSignedMessageToChat,
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 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 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,
'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,
'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');
let currentCleanup = null;
let pingIntervalId = null;
let sessionRuntimeStarted = false;
setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling();
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 = '';
}
function renderApp() {
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'profile-view' : '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('profile-view');
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));
}
} 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),
});
if (pingIntervalId) {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
pingIntervalId = window.setInterval(async () => {
if (!state.session.isAuthorized) return;
try {
await authService.ws.request('Ping', { ts: Date.now() });
} catch {
// silent keep-alive
}
}, 60_000);
}
async function init() {
addAppLogEntry({
level: 'info',
source: 'app',
message: 'Инициализация UI запущена',
});
setSessionResetHandler(() => {
sessionRuntimeStarted = false;
if (pingIntervalId) {
window.clearInterval(pingIntervalId);
pingIntervalId = 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 || {};
addAppLogEntry({
level: 'info',
source: 'web-push',
message: 'Получено push-событие в service worker',
details: payload,
});
});
}
authService.onEvent('SessionRevoked', async () => {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
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 myLogin = String(state.session.login || '').trim().toLowerCase();
const fromLogin = parsed.fromLogin || '';
const toLogin = parsed.toLogin || '';
const chatId = String(fromLogin || '').toLowerCase() === myLogin ? toLogin : fromLogin;
const messageType = Number(parsed.messageType || 0);
const text = (messageType === 1 || messageType === 2)
? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0))
: '';
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 && Notification.permission === 'granted' && !payload.backlog) {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
} catch {}
}
} else if (messageType === 3 || messageType === 4) {
const refBaseKey = String(payload.receiptRefBaseKey || '').trim();
if (refBaseKey) {
markOutgoingReadByBaseKey(refBaseKey);
} else {
try {
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
const fallbackRefBase = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
markOutgoingReadByBaseKey(fallbackRefBase);
} catch {}
}
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') {
renderApp();
}
});
authService.onEvent('IncomingCallInvite', async (evt) => {
try { await handleIncomingCallInvite(evt); } catch {}
});
authService.onEvent('IncomingCallSignal', async (evt) => {
try { await handleIncomingCallSignal(evt); } catch {}
});
await tryAutoLogin();
await hydrateMessagesFromStore();
await ensureSessionRuntimeStarted();
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
} else {
renderApp();
}
window.addEventListener('hashchange', renderApp);
}
init();