import { navigate, getRoute, parseRouteFromPath, PRE_AUTH_PAGES, getSwipeNavigationTarget, syncTrackedRouteHistory, rememberToolbarRoute, resetRememberedToolbarRoutes, resolveToolbarActive, } from './router.js'; import { renderToolbar } from './components/toolbar.js'; import { captureClientError, setClientErrorSentNotifier, 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 { showToast } from './services/channels-ux.js'; import { handleCallPushAction, handleIncomingCallInvite, handleIncomingCallPush, handleIncomingCallSignal, handleIncomingCallSignalViaSendSignal, handleStopCallPush, setCallDebugReporter, startDebugConnectionAsInitiator, startDebugConnectionAsResponder, } from './services/call-service.js'; import { parseDmTechBlocks } from './services/dm-tech-blocks.js'; import { authService, addAppLogEntry, authorizeSession, hydrateMessagesFromStore, isSessionInvalidError, refreshSessions, setSessionAuthorizedHandler, setSessionResetHandler, state, terminateCurrentSession, addSignedMessageToChat, deleteConversationMessagesBefore, deleteSignedMessageByBaseKey, markIncomingReadByBaseKey, markOutgoingReadByBaseKey, normalizeDmChatId, setContacts, } from './state.js'; import * as startView from './pages/start-view.js?v=202606142105'; import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240'; import * as registerView from './pages/register-view.js?v=202606201650'; import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650'; import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940'; import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginCameraView from './pages/login-camera-view.js'; import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940'; import * as loginPasswordView from './pages/login-password-view.js?v=202606201650'; import * as keyStorageView from './pages/key-storage-view.js'; import * as publicSupportQueueView from './pages/public-support-queue-view.js'; import * as profileView from './pages/profile-view.js?v=202607150910'; import * as profileEditView from './pages/profile-edit-view.js'; import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as arweaveUploadsView from './pages/arweave-uploads-view.js'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940'; import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930'; 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 clientSessionView from './pages/device-session-view.js?v=202606131435'; 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 solanaUsersInitView from './pages/solana-users-init-view.js'; import * as solanaRpcCheckView from './pages/solana-rpc-check-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?v=202607152145'; 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 addPersonalPublicChatView from './pages/add-personal-public-chat-view.js'; import * as networkView from './pages/network-view.js'; import * as notificationsView from './pages/notifications-view.js'; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается'; const DM_DECRYPT_FAILED_TEXT = 'Не удалось расшифровать сообщение'; const DM_CORRUPTED_TEXT = 'Сообщение повреждено'; function dmDecryptFallbackText(error) { const kind = authService.classifyDmDecryptFailure(error); if (kind === 'unsupported_format') return DM_UNSUPPORTED_FORMAT_TEXT; if (kind === 'corrupted') return DM_CORRUPTED_TEXT; return DM_DECRYPT_FAILED_TEXT; } const routes = { 'start-view': startView, 'entry-settings-view': entrySettingsView, 'register-view': registerView, 'registration-faq-view': registrationFaqView, 'registration-payment-view': registrationPaymentView, 'registration-keys-view': registrationKeysView, 'registration-draft-keys-view': registrationDraftKeysView, 'topup-view': topupView, 'devnet-topup-view': devnetTopupView, 'login-view': loginView, 'login-camera-view': loginCameraView, 'login-other-device-view': loginOtherDeviceView, 'login-password-view': loginPasswordView, 'key-storage-view': keyStorageView, queue: publicSupportQueueView, 'profile-view': profileView, 'profile-edit-view': profileEditView, 'wallet-view': walletView, 'settings-view': settingsView, 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, 'server-settings-view': serverSettingsView, 'arweave-uploads-view': arweaveUploadsView, 'remote-addblock-session-view': remoteAddBlockSessionView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, 'device-pairing-view': clientPairingView, 'trusted-device-login-settings-view': trustedDeviceLoginSettingsView, 'device-qr-view': deviceQrView, 'device-camera-view': deviceCameraView, 'show-keys-view': showKeysView, 'device-session-view': clientSessionView, 'language-view': languageView, 'app-log-view': appLogView, 'pwa-diagnostics-view': pwaDiagnosticsView, 'solana-users-init-view': solanaUsersInitView, 'solana-rpc-check-view': solanaRpcCheckView, 'messages-list': messagesList, 'contact-search-view': contactSearchView, 'chat-view': chatView, user: userProfileView, 'channels-list': channelsList, 'channel-view': channelView, 'channel-thread-view': channelThreadView, 'add-channel-view': addChannelView, 'add-personal-public-chat-view': addPersonalPublicChatView, 'network-view': networkView, 'notifications-view': notificationsView, }; const screenEl = document.getElementById('app-screen'); const topbarEl = document.getElementById('topbar-slot'); const composerEl = document.getElementById('composer-slot'); const toolbarEl = document.getElementById('toolbar-slot'); const appShellEl = document.querySelector('.app-shell'); const initialSplashEl = document.getElementById('initial-splash'); const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000; const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50; const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; const KEEP_ALIVE_ROOTS = new Set(['messages-list', 'channels-list']); const HORIZONTAL_SWIPE_MIN_DISTANCE_PX = 72; const HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX = 56; const HORIZONTAL_SWIPE_DOMINANCE_RATIO = 1.35; const HORIZONTAL_SWIPE_LOCK_DISTANCE_PX = 14; const HORIZONTAL_SWIPE_COMMIT_RATIO = 0.32; const HORIZONTAL_SWIPE_PREVIEW_EDGE_PX = 18; const HORIZONTAL_SWIPE_MAX_DURATION_MS = 260; 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; let uiVersionPeriodicIntervalId = null; let hiddenDmAudioContext = null; let hiddenDmAudioUnlocked = false; let initialConnectionCompleted = false; let orientationLockInFlight = false; let currentChromeCleanup = null; let currentMountState = null; let activeSwipePreview = null; const keepAliveEntries = new Map(); 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', 'queue', ]); setClientErrorTransport((payload) => authService.reportClientUiError(payload)); setClientErrorSentNotifier((payload) => { const login = String(state.session.login || 'guest').trim(); const isoTs = new Date(Number(payload?.clientTs || Date.now())).toISOString(); showToast(`Ошибка отправлена на сервер · ${login} · ${isoTs}`); }); initPwaInstallPromptHandling(); initCallUiOverlay(); setCallDebugReporter((payload) => authService.reportClientDebug(payload)); function setShellMetricVar(name, valuePx) { if (!appShellEl) return; appShellEl.style.setProperty(name, `${Math.max(0, Math.ceil(Number(valuePx || 0)))}px`); } function setKeyboardOffsetPx(valuePx = 0) { setShellMetricVar('--keyboard-offset', valuePx); } function attachSlotHeightObserver(slotEl, cssVarName) { if (!slotEl || typeof ResizeObserver !== 'function') return null; const sync = () => { const visible = !slotEl.hidden && slotEl.childElementCount > 0; setShellMetricVar(cssVarName, visible ? slotEl.offsetHeight : 0); }; const observer = new ResizeObserver(sync); observer.observe(slotEl); sync(); return { observer, sync }; } const topbarHeightObserver = attachSlotHeightObserver(topbarEl, '--topbar-height'); const composerHeightObserver = attachSlotHeightObserver(composerEl, '--composer-height'); const toolbarHeightObserver = attachSlotHeightObserver(toolbarEl, '--toolbar-height'); function clearSlot(slotEl, cssVarName) { if (!slotEl) return; slotEl.innerHTML = ''; slotEl.hidden = true; setShellMetricVar(cssVarName, 0); } function mountSlot(slotEl, cssVarName, node) { if (!slotEl) return; slotEl.innerHTML = ''; if (node instanceof Node) { slotEl.append(node); slotEl.hidden = false; } else { slotEl.hidden = true; } setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0); } function createChromeController(showAppChrome) { let topbarNode = null; let composerNode = null; let disposed = false; const apply = () => { if (disposed) return; if (!showAppChrome) { clearSlot(topbarEl, '--topbar-height'); clearSlot(composerEl, '--composer-height'); return; } mountSlot(topbarEl, '--topbar-height', topbarNode); mountSlot(composerEl, '--composer-height', composerNode); topbarHeightObserver?.sync?.(); composerHeightObserver?.sync?.(); }; return { setTopbar(node = null) { topbarNode = node instanceof Node ? node : null; apply(); }, setComposer(node = null) { composerNode = node instanceof Node ? node : null; apply(); }, clear() { topbarNode = null; composerNode = null; apply(); }, suspend() { clearSlot(topbarEl, '--topbar-height'); clearSlot(composerEl, '--composer-height'); }, resume() { apply(); }, dispose() { disposed = true; clearSlot(topbarEl, '--topbar-height'); clearSlot(composerEl, '--composer-height'); }, }; } function destroyMountState(entry) { if (!entry) return; if (entry.destroyed) return; entry.destroyed = true; try { if (typeof entry.cleanup === 'function') { entry.cleanup(); } } finally { entry.chrome?.dispose?.(); } } function clearKeepAliveEntries() { teardownSwipePreview({ cancelOnly: true }); keepAliveEntries.forEach((entry) => destroyMountState(entry)); keepAliveEntries.clear(); resetRememberedToolbarRoutes(); currentMountState = null; currentCleanup = null; currentChromeCleanup = null; } function detachMountedScreen(entry) { if (!entry) return; entry.chrome?.suspend?.(); if (entry.screen?.parentNode === screenEl) { screenEl.removeChild(entry.screen); } else { screenEl.innerHTML = ''; } } function mountExistingEntry(entry, { showAppChrome, pageId }) { teardownSwipePreview({ cancelOnly: true }); screenEl.innerHTML = ''; screenEl.append(entry.screen); entry.chrome?.resume?.(); currentMountState = entry; currentCleanup = typeof entry.cleanup === 'function' ? entry.cleanup : null; currentChromeCleanup = () => entry.chrome?.dispose?.(); screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); } function cloneScreenForSwipe(screen) { const clone = screen?.cloneNode?.(true); if (!(clone instanceof Node)) return null; return clone; } function sanitizeSwipeClone(node) { if (!(node instanceof Element)) return; node.removeAttribute('id'); node.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); } function cloneSlotChildForSwipe(slotEl) { const child = slotEl?.firstElementChild; if (!(child instanceof Node)) return null; const clone = child.cloneNode(true); if (clone instanceof Element) sanitizeSwipeClone(clone); return clone; } function createSwipeFrameSlot(className, contentNode = null) { const slot = document.createElement('div'); slot.className = className; if (contentNode instanceof Node) { slot.append(contentNode); slot.hidden = false; } else { slot.hidden = true; } return slot; } function buildSwipePane({ topbarNode = null, screenNode = null, composerNode = null, screenClassName = '', screenScrollTop = 0, }) { const pane = document.createElement('div'); pane.className = 'screen-swipe-pane'; const topbarSlot = createSwipeFrameSlot('topbar-slot screen-swipe-slot screen-swipe-slot--topbar', topbarNode); const screenSlot = document.createElement('main'); screenSlot.className = `${screenClassName || 'screen-content'} screen-swipe-slot screen-swipe-slot--content`; if (screenNode instanceof Node) { screenSlot.append(screenNode); } const composerSlot = createSwipeFrameSlot('composer-slot screen-swipe-slot screen-swipe-slot--composer', composerNode); pane.append(topbarSlot, screenSlot, composerSlot); requestAnimationFrame(() => { screenSlot.scrollTop = Math.max(0, Number(screenScrollTop || 0)); }); return pane; } function createSwipePreviewTarget(targetPath) { const route = parseRouteFromPath(`/${String(targetPath || '').replace(/^\/+/, '')}`); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); const page = routes[pageId] || routes['start-view']; const rootPageId = resolveToolbarActive(pageId); const cachedEntry = keepAliveEntries.get(rootPageId); if (cachedEntry && cachedEntry.routePath === `/${String(targetPath || '').replace(/^\/+/, '')}`) { return { screen: cloneScreenForSwipe(cachedEntry.screen), cleanup: null, }; } let previewTopbarNode = null; let previewComposerNode = null; const chrome = { setTopbar(node = null) { previewTopbarNode = node instanceof Node ? node : null; }, setComposer(node = null) { previewComposerNode = node instanceof Node ? node : null; }, clear() { previewTopbarNode = null; previewComposerNode = null; }, suspend() {}, resume() {}, dispose() {}, }; const screen = page.render({ route, navigate, chrome }); if (!(screen instanceof Node)) { chrome.dispose(); throw new Error('Swipe preview render returned invalid node'); } const cleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; return { screen, topbarNode: previewTopbarNode, composerNode: previewComposerNode, cleanup: () => { try { if (typeof cleanup === 'function') cleanup(); } finally { chrome.dispose(); } }, }; } function applySwipePreviewOffset(session, revealPx) { if (!session) return; const width = Math.max(1, session.width); const clamped = Math.max(0, Math.min(width, revealPx)); session.revealPx = clamped; const currentX = session.direction === 'left' ? -clamped : clamped; const targetX = session.direction === 'left' ? width - clamped + HORIZONTAL_SWIPE_PREVIEW_EDGE_PX : -width + clamped - HORIZONTAL_SWIPE_PREVIEW_EDGE_PX; const dividerX = session.direction === 'left' ? width - clamped : clamped; session.currentPane.style.transform = `translate3d(${currentX}px, 0, 0)`; session.targetPane.style.transform = `translate3d(${targetX}px, 0, 0)`; session.divider.style.transform = `translate3d(${dividerX}px, 0, 0)`; const overlayOpacity = Math.max(0.08, Math.min(0.24, (clamped / width) * 0.24)); session.overlay.style.setProperty('--swipe-overlay-opacity', overlayOpacity.toFixed(3)); } function teardownSwipePreview({ cancelOnly = false } = {}) { const session = activeSwipePreview; if (!session) return; activeSwipePreview = null; appShellEl?.classList.remove('app-shell--swiping'); topbarEl?.classList.remove('topbar-slot--swipe-hidden'); screenEl.classList.remove('screen-content--swipe-hidden'); composerEl?.classList.remove('composer-slot--swipe-hidden'); session.overlay.remove(); if (typeof session.targetCleanup === 'function') { session.targetCleanup(); } if (!cancelOnly) { session.onComplete?.(); } } function animateSwipePreviewTo(session, revealPx, { complete = false } = {}) { const width = Math.max(1, session.width); const currentReveal = Number(session.revealPx || 0); const remaining = Math.abs(revealPx - currentReveal); const duration = Math.max(140, Math.min(HORIZONTAL_SWIPE_MAX_DURATION_MS, Math.round((remaining / width) * HORIZONTAL_SWIPE_MAX_DURATION_MS))); [session.currentPane, session.targetPane, session.divider].forEach((node) => { node.style.transition = `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`; }); session.overlay.style.transition = `opacity ${duration}ms ease`; requestAnimationFrame(() => { applySwipePreviewOffset(session, revealPx); if (!complete) { session.overlay.style.opacity = '0'; } }); window.setTimeout(() => { teardownSwipePreview({ cancelOnly: !complete }); }, duration + 24); } function beginSwipePreview(direction, targetPath) { if (!currentMountState?.screen || activeSwipePreview) return null; const currentTopbarClone = cloneSlotChildForSwipe(topbarEl); const currentScreenClone = cloneScreenForSwipe(currentMountState.screen); const currentComposerClone = cloneSlotChildForSwipe(composerEl); if (!(currentScreenClone instanceof Node)) return null; if (currentTopbarClone instanceof Element) sanitizeSwipeClone(currentTopbarClone); if (currentScreenClone instanceof Element) sanitizeSwipeClone(currentScreenClone); if (currentComposerClone instanceof Element) sanitizeSwipeClone(currentComposerClone); const targetPreview = createSwipePreviewTarget(targetPath); if (!(targetPreview?.screen instanceof Node)) { targetPreview?.cleanup?.(); return null; } if (targetPreview.topbarNode instanceof Element) sanitizeSwipeClone(targetPreview.topbarNode); if (targetPreview.screen instanceof Element) sanitizeSwipeClone(targetPreview.screen); if (targetPreview.composerNode instanceof Element) sanitizeSwipeClone(targetPreview.composerNode); const overlay = document.createElement('div'); overlay.className = 'screen-swipe-overlay'; const currentPane = buildSwipePane({ topbarNode: currentTopbarClone, screenNode: currentScreenClone, composerNode: currentComposerClone, screenClassName: screenEl.className, screenScrollTop: screenEl.scrollTop, }); currentPane.classList.add('screen-swipe-pane--current'); const targetPane = buildSwipePane({ topbarNode: targetPreview.topbarNode || null, screenNode: targetPreview.screen, composerNode: targetPreview.composerNode || null, screenClassName: screenEl.className, screenScrollTop: 0, }); targetPane.classList.add('screen-swipe-pane--target', `screen-swipe-pane--${direction}`); const divider = document.createElement('div'); divider.className = 'screen-swipe-divider'; overlay.append(currentPane, targetPane, divider); appShellEl.append(overlay); appShellEl?.classList.add('app-shell--swiping'); topbarEl?.classList.add('topbar-slot--swipe-hidden'); screenEl.classList.add('screen-content--swipe-hidden'); composerEl?.classList.add('composer-slot--swipe-hidden'); const session = { direction, targetPath, width: screenEl.clientWidth || 1, overlay, currentPane, targetPane, divider, targetCleanup: targetPreview.cleanup || null, revealPx: 0, onComplete: () => navigate(targetPath), }; activeSwipePreview = session; applySwipePreviewOffset(session, 0); return session; } function installHorizontalTabSwipe() { if (!screenEl) return; let touchStartX = 0; let touchStartY = 0; let touchActive = false; let touchBlocked = false; let swipeLocked = false; let swipeDirection = ''; let swipeTargetPath = ''; let swipeSession = null; const reset = () => { touchActive = false; touchBlocked = false; swipeLocked = false; swipeDirection = ''; swipeTargetPath = ''; swipeSession = null; touchStartX = 0; touchStartY = 0; }; screenEl.addEventListener('touchstart', (event) => { if (event.touches.length !== 1) { reset(); return; } const target = event.target instanceof Element ? event.target : null; touchBlocked = Boolean(target?.closest('input, textarea, select, button, a, [contenteditable="true"]')); touchActive = !touchBlocked; touchStartX = Number(event.touches[0]?.clientX || 0); touchStartY = Number(event.touches[0]?.clientY || 0); }, { passive: true }); screenEl.addEventListener('touchmove', (event) => { if (!touchActive || touchBlocked) return; const touch = event.touches?.[0]; const deltaX = Number(touch?.clientX || 0) - touchStartX; const deltaY = Number(touch?.clientY || 0) - touchStartY; const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); if (!swipeLocked) { if (absX < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX && absY < HORIZONTAL_SWIPE_LOCK_DISTANCE_PX) return; if (absX <= absY * 1.05) { touchBlocked = true; return; } const currentPageId = getRoute().pageId || ''; swipeDirection = deltaX < 0 ? 'left' : 'right'; swipeTargetPath = getSwipeNavigationTarget(currentPageId, swipeDirection); if (!swipeTargetPath) { touchBlocked = true; return; } swipeSession = beginSwipePreview(swipeDirection, swipeTargetPath); if (!swipeSession) { touchBlocked = true; return; } swipeLocked = true; } if (!swipeLocked || !swipeSession) return; event.preventDefault(); const revealPx = swipeDirection === 'left' ? Math.max(0, -deltaX) : Math.max(0, deltaX); applySwipePreviewOffset(swipeSession, revealPx); }, { passive: false }); screenEl.addEventListener('touchcancel', reset, { passive: true }); screenEl.addEventListener('touchend', (event) => { if (!touchActive || touchBlocked) { if (swipeSession) { animateSwipePreviewTo(swipeSession, 0, { complete: false }); } reset(); return; } const touch = event.changedTouches?.[0]; const endX = Number(touch?.clientX || 0); const endY = Number(touch?.clientY || 0); const deltaX = endX - touchStartX; const deltaY = endY - touchStartY; const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); const session = swipeSession; const wasLocked = swipeLocked; reset(); if (wasLocked && session) { event.preventDefault(); const revealRatio = Number(session.revealPx || 0) / Math.max(1, session.width); const shouldCommit = revealRatio >= HORIZONTAL_SWIPE_COMMIT_RATIO; animateSwipePreviewTo(session, shouldCommit ? session.width : 0, { complete: shouldCommit }); return; } if (absX < HORIZONTAL_SWIPE_MIN_DISTANCE_PX) return; if (absY > HORIZONTAL_SWIPE_MAX_VERTICAL_DRIFT_PX) return; if (absX <= absY * HORIZONTAL_SWIPE_DOMINANCE_RATIO) return; const currentPageId = getRoute().pageId || ''; const direction = deltaX < 0 ? 'left' : 'right'; const target = getSwipeNavigationTarget(currentPageId, direction); if (!target) return; navigate(target); }, { passive: true }); } async function unlockHiddenDmAudio() { try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return false; if (!hiddenDmAudioContext) { hiddenDmAudioContext = new Ctx(); } if (hiddenDmAudioContext.state === 'suspended') { await hiddenDmAudioContext.resume(); } hiddenDmAudioUnlocked = hiddenDmAudioContext.state === 'running'; return hiddenDmAudioUnlocked; } catch { return false; } } async function playDmSignal({ extended = false } = {}) { try { if (!hiddenDmAudioUnlocked || !hiddenDmAudioContext) return false; if (hiddenDmAudioContext.state === 'suspended') { await hiddenDmAudioContext.resume(); } if (hiddenDmAudioContext.state !== 'running') return false; const now = hiddenDmAudioContext.currentTime; const gain = hiddenDmAudioContext.createGain(); gain.connect(hiddenDmAudioContext.destination); gain.gain.setValueAtTime(0.0001, now); const pulse = (offsetSec, freqHz, durationSec, peakGain) => { const osc = hiddenDmAudioContext.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(freqHz, now + offsetSec); osc.connect(gain); gain.gain.exponentialRampToValueAtTime(peakGain, now + offsetSec + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, now + offsetSec + durationSec); osc.start(now + offsetSec); osc.stop(now + offsetSec + durationSec + 0.02); }; if (extended) { pulse(0, 880, 0.18, 0.032); pulse(0.24, 1174, 0.2, 0.026); pulse(0.52, 1567, 0.24, 0.02); } else { pulse(0, 1046, 0.12, 0.028); pulse(0.17, 1318, 0.14, 0.022); } return true; } catch { return false; } } async function tryLockPortraitOrientation() { if (orientationLockInFlight) return false; const orientationApi = window.screen?.orientation; if (!orientationApi?.lock) return false; orientationLockInFlight = true; try { await orientationApi.lock('portrait'); return true; } catch { return false; } finally { orientationLockInFlight = false; } } async function notifyHiddenIncomingMessage(fromLogin, text) { const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`; const title = `Сообщение от ${fromLogin}`; try { const registration = await navigator.serviceWorker?.getRegistration?.(); if (registration?.showNotification) { await registration.showNotification(title, { body, tag: `shine-hidden-dm-${String(fromLogin || '').trim().toLowerCase() || 'unknown'}`, renotify: true, data: { kind: 'new_message', fromLogin, }, }); } else { new Notification(title, { body }); } } catch { // ignore notification errors } try { if (typeof navigator.vibrate === 'function') { navigator.vibrate([140, 80, 220, 90, 180]); } } catch { // ignore vibration errors } void playDmSignal({ extended: true }); } 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 (!initialConnectionCompleted) { bannerEl.hidden = true; 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 finishInitialConnectionSplash() { if (initialConnectionCompleted) return; initialConnectionCompleted = true; if (!initialSplashEl) { refreshConnectionUi(); return; } initialSplashEl.classList.add('is-leaving'); const removeSplash = () => initialSplashEl.remove(); initialSplashEl.addEventListener('transitionend', removeSplash, { once: true }); window.setTimeout(removeSplash, 700); refreshConnectionUi(); } 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 savePendingCallPushAction(action, payload = {}) { try { const item = { action: String(action || '').trim().toLowerCase(), payload: payload || {}, savedAtMs: Date.now(), }; localStorage.setItem(CALL_PUSH_PENDING_ACTION_KEY, JSON.stringify(item)); } catch { // ignore localStorage errors } } function isCallPushTargetForCurrentSession(payload = {}) { const targetSessionId = String(payload?.targetSessionId || '').trim(); if (!targetSessionId) return true; const currentSessionId = String(state?.session?.sessionId || '').trim(); return Boolean(currentSessionId) && currentSessionId === targetSessionId; } function loadPendingCallPushAction() { try { const raw = localStorage.getItem(CALL_PUSH_PENDING_ACTION_KEY); if (!raw) return null; const parsed = JSON.parse(raw); const action = String(parsed?.action || '').trim().toLowerCase(); if (action !== 'accept' && action !== 'decline') return null; return { action, payload: parsed?.payload || {}, }; } catch { return null; } } function clearPendingCallPushAction() { try { localStorage.removeItem(CALL_PUSH_PENDING_ACTION_KEY); } catch { // ignore localStorage errors } } function consumeCallPushActionFromUrlIfAny() { try { const params = new URLSearchParams(window.location.search || ''); const action = String(params.get('callPushAction') || '').trim().toLowerCase(); const rawPayload = String(params.get('callPushPayload') || ''); if (action !== 'accept' && action !== 'decline') return; let payload = {}; if (rawPayload) { try { payload = JSON.parse(decodeURIComponent(rawPayload)); } catch { payload = {}; } } savePendingCallPushAction(action, payload); params.delete('callPushAction'); params.delete('callPushPayload'); const nextQuery = params.toString(); const nextUrl = `${window.location.pathname}${nextQuery ? `?${nextQuery}` : ''}`; window.history.replaceState({}, '', nextUrl); } catch { // ignore URL parsing errors } } async function processPendingCallPushActionIfPossible() { if (!state.session.isAuthorized) return; const pending = loadPendingCallPushAction(); if (!pending) return; if (!isCallPushTargetForCurrentSession(pending.payload || {})) return; clearPendingCallPushAction(); try { await handleCallPushAction(pending.action, pending.payload || {}); } catch (error) { addAppLogEntry({ level: 'warn', source: 'web-push', message: 'Не удалось выполнить действие звонка из push', details: { action: pending.action, error: error?.message || 'unknown' }, }); } } function setConnectionStatus(nextState, text = '') { const state = String(nextState || '').trim(); if (!state) return; connectionState = state; connectionStatusText = String(text || '').trim(); if (state === 'connected') { finishInitialConnectionSplash(); } 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 runPeriodicUiVersionCheck() { if (uiUpdateReloadScheduled) return; if (uiVersionCheckInFlight) return; try { const latestHostHash = await fetchCurrentHostUiBuildHash(); if (!latestHostHash || !CURRENT_BUILD_HASH || latestHostHash === CURRENT_BUILD_HASH) return; scheduleUiReload({ source: 'ui-periodic-version-check', message: `Найдена новая версия UI: ${CURRENT_BUILD_HASH} -> ${latestHostHash}`, delayMs: 600, activateWaitingWorker: true, }); } catch { // ignore periodic check errors } } function startPeriodicUiVersionCheck() { if (uiVersionPeriodicIntervalId) return; // ВРЕМЕННО: частая проверка обновления UI (каждые 5 минут) для диагностики проблем с обновлением клиента. // Позже интервал нужно увеличить или вернуть проверку только по ручному действию. uiVersionPeriodicIntervalId = window.setInterval(() => { void runPeriodicUiVersionCheck(); }, UI_VERSION_PERIODIC_CHECK_MS); } async function checkConnectionHealth() { if (state.session.isLocalDemo) { setConnectionStatus('connected'); return; } 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 (state.session.isLocalDemo) { setConnectionStatus('connected'); return; } 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); } function sleep(ms) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } async function waitForSignedDmDecryptContext(timeoutMs = SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS) { const startedAtMs = Date.now(); while ((Date.now() - startedAtMs) < timeoutMs) { const login = String(state.session?.login || '').trim(); const storagePwd = String(state.session?.storagePwdInMemory || '').trim(); if (login && storagePwd) { return { login, storagePwd }; } await sleep(SIGNED_DM_DECRYPT_CONTEXT_POLL_MS); } return { login: String(state.session?.login || '').trim(), storagePwd: String(state.session?.storagePwdInMemory || '').trim(), }; } function resolveSignedDmChatId({ parsed, login }) { const parsedBlock = parsed || {}; const messageType = Number(parsedBlock.messageType || 0); const fromLogin = String(parsedBlock.fromLogin || '').trim(); const toLogin = String(parsedBlock.toLogin || '').trim(); const currentLogin = String(login || '').trim().toLowerCase(); if (messageType === 1) return normalizeDmChatId(fromLogin); if (messageType === 2) return normalizeDmChatId(toLogin); if (currentLogin && currentLogin === fromLogin.toLowerCase()) { return normalizeDmChatId(toLogin); } return normalizeDmChatId(fromLogin); } 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, }, }); // Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека — // алертом такие не показываем (в лог и captureClientError выше уже записаны). const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.' && !event.filename && !(event.error?.stack); if (isOpaqueCrossOriginError) return; 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.pathname || '', }, }); screenEl.innerHTML = ''; teardownSwipePreview({ cancelOnly: true }); 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); if (typeof currentChromeCleanup === 'function') { currentChromeCleanup(); currentChromeCleanup = null; } clearSlot(topbarEl, '--topbar-height'); clearSlot(composerEl, '--composer-height'); toolbarEl.innerHTML = ''; toolbarHeightObserver?.sync?.(); refreshConnectionUi(); } function renderApp() { teardownSwipePreview({ cancelOnly: true }); syncTrackedRouteHistory(window.location.pathname || '/'); const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(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']; const showAppChrome = page.pageMeta?.showAppChrome !== false; const rootPageId = resolveToolbarActive(pageId); const keepAliveEligible = showAppChrome && KEEP_ALIVE_ROOTS.has(rootPageId); const currentRoutePath = String(window.location.pathname || '/'); rememberToolbarRoute(pageId); if (currentMountState) { const shouldPreserveCurrent = currentMountState.keepAlive && currentMountState.rootPageId !== rootPageId; if (shouldPreserveCurrent) { currentMountState.routePath = currentMountState.routePath || currentRoutePath; keepAliveEntries.set(currentMountState.rootPageId, currentMountState); detachMountedScreen(currentMountState); currentMountState = null; currentCleanup = null; currentChromeCleanup = null; } else { destroyMountState(currentMountState); if (currentMountState.keepAlive) { keepAliveEntries.delete(currentMountState.rootPageId); } currentMountState = null; currentCleanup = null; currentChromeCleanup = null; } } else { if (typeof currentCleanup === 'function') { currentCleanup(); currentCleanup = null; } if (typeof currentChromeCleanup === 'function') { currentChromeCleanup(); currentChromeCleanup = null; } } const cachedEntry = keepAliveEligible ? keepAliveEntries.get(rootPageId) : null; if (cachedEntry && cachedEntry.routePath === currentRoutePath) { mountExistingEntry(cachedEntry, { showAppChrome, pageId }); toolbarEl.innerHTML = ''; if (showAppChrome) { toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); } toolbarHeightObserver?.sync?.(); refreshConnectionUi(); return; } if (cachedEntry) { destroyMountState(cachedEntry); keepAliveEntries.delete(rootPageId); } try { screenEl.innerHTML = ''; const chrome = createChromeController(showAppChrome); currentChromeCleanup = () => chrome.dispose(); const screen = page.render({ route, navigate, chrome }); if (!(screen instanceof Node)) { throw new Error('Page render returned invalid node'); } screenEl.append(screen); currentCleanup = typeof screen.cleanup === 'function' ? screen.cleanup : null; currentMountState = { pageId, rootPageId, keepAlive: keepAliveEligible, routePath: currentRoutePath, screen, cleanup: currentCleanup, chrome, destroyed: false, }; if (keepAliveEligible) { keepAliveEntries.set(rootPageId, currentMountState); } screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId)); toolbarEl.innerHTML = ''; if (showAppChrome) { toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); } toolbarHeightObserver?.sync?.(); refreshConnectionUi(); } catch (error) { console.error('[renderApp] controlled fallback', error); renderPageFailureFallback(pageId, error); } } function refreshToolbarOnly() { const route = getRoute(); const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); const page = routes[pageId] || routes['start-view']; const showAppChrome = page.pageMeta?.showAppChrome !== false; toolbarEl.innerHTML = ''; if (showAppChrome) { toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); } toolbarHeightObserver?.sync?.(); refreshConnectionUi(); } function refreshUnreadUi() { const pageId = getRoute().pageId || ''; if (pageId === 'messages-list') { renderApp(); return; } refreshToolbarOnly(); } async function tryAutoLogin() { if (state.session.isLocalDemo) return; 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.isLocalDemo) return; 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); await processPendingCallPushActionIfPossible(); } async function init() { consumeCallPushActionFromUrlIfAny(); void tryLockPortraitOrientation(); if (state.session.isLocalDemo) { setConnectionStatus('connected'); } addAppLogEntry({ level: 'info', source: 'app', message: 'Инициализация UI запущена', }); setSessionResetHandler(() => { sessionRuntimeStarted = false; startConnectionMonitor(); clearKeepAliveEntries(); if (reconnectIntervalId) { window.clearInterval(reconnectIntervalId); reconnectIntervalId = null; } navigate('start-view'); }); setSessionAuthorizedHandler(() => { void ensureSessionRuntimeStarted(); void processPendingCallPushActionIfPossible(); }); if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', (event) => { const data = event?.data || {}; if (data.type === 'SHINE_CALL_PUSH_ACTION') { const action = String(data.action || '').trim().toLowerCase(); const payload = data.payload || {}; if (action === 'accept' || action === 'decline') { if (!isCallPushTargetForCurrentSession(payload)) return; savePendingCallPushAction(action, payload); void processPendingCallPushActionIfPossible(); } return; } if (data.type !== 'SHINE_WEB_PUSH_EVENT') return; const payload = data.payload || {}; if (!isCallPushTargetForCurrentSession(payload)) return; 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, }); if (kind === 'incoming_call' && !payload.stale && state.session.isAuthorized) { void handleIncomingCallPush(payload); } else if (kind === 'stop_call' && state.session.isAuthorized) { void handleStopCallPush(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); let shouldAckSessionDelivery = true; let text = ''; let sessionLoginForRouting = String(state.session?.login || '').trim(); if (messageType === 1 || messageType === 2) { const decryptContext = await waitForSignedDmDecryptContext(); if (!decryptContext.login || !decryptContext.storagePwd) { shouldAckSessionDelivery = false; addAppLogEntry({ level: 'warn', source: 'signed-dm', message: 'Отложена обработка DM: decrypt-контекст сессии ещё не готов', details: { messageKey, baseKey: parsed.baseKey, messageType }, }); return; } sessionLoginForRouting = decryptContext.login; try { const decrypted = await authService.decryptSignedMessageContent({ parsed, login: decryptContext.login, storagePwd: decryptContext.storagePwd, }); text = String(decrypted?.text || ''); } catch (error) { text = dmDecryptFallbackText(error); addAppLogEntry({ level: 'warn', source: 'signed-dm', message: 'Не удалось расшифровать DM', details: { messageKey, baseKey: parsed.baseKey, error: error?.message || 'unknown', fallbackText: text, }, }); } } const chatId = resolveSignedDmChatId({ parsed, login: sessionLoginForRouting, }); 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, revisionTimeMs: Number(parsed.revisionTimeMs || 0), deleted: Boolean(parsed.deleted), }); if (added) { addAppLogEntry({ level: 'info', source: 'signed-dm', message: isIncomingForCurrent ? `Обновлено входящее сообщение от ${fromLogin}` : `Синхронизирована исходящая копия в чате ${chatId}`, details: { messageKey, baseKey: parsed.baseKey, messageType, revisionTimeMs: parsed.revisionTimeMs || 0, deleted: !!parsed.deleted }, }); } if (added && isIncomingForCurrent) { shouldRefreshToolbarUnread = true; } if (added && isIncomingForCurrent && !payload.backlog) { const parsedText = parseDmTechBlocks(text); const displayText = String(parsedText.displayText || ''); const suppressHiddenNotification = parsedText.callSummary?.status === 'completed'; if (document.visibilityState === 'visible') { void playDmSignal({ extended: false }); } else if (Notification.permission === 'granted' && !suppressHiddenNotification) { try { void notifyHiddenIncomingMessage(fromLogin, displayText || ''); } catch {} } else if (!suppressHiddenNotification) { void playDmSignal({ extended: true }); } } } else if (messageType === 3 || messageType === 4) { let refBaseKey = String(payload.receiptRefBaseKey || '').trim(); if (!refBaseKey) { try { const ref = parsed.readReceiptPayload || authService.parseReadReceiptPayload(parsed.payloadBytes); refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; } catch {} } if (refBaseKey) { if (messageType === 3) { markOutgoingReadByBaseKey(refBaseKey, Number(parsed.timeMs || 0)); } else { if (markIncomingReadByBaseKey(refBaseKey)) { shouldRefreshToolbarUnread = true; } } } addAppLogEntry({ level: 'info', source: 'signed-dm', message: 'Получено подтверждение прочтения', details: { messageKey, baseKey: parsed.baseKey, messageType }, }); } else if (messageType === 5 || messageType === 6) { const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey); if (changed) shouldRefreshToolbarUnread = true; addAppLogEntry({ level: 'info', source: 'signed-dm', message: 'Получено удаление сообщения', details: { messageKey, baseKey: parsed.baseKey, messageType, changed }, }); } else if (messageType === 7 || messageType === 8) { const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0)); addSignedMessageToChat({ chatId, messageKey, baseKey: parsed.baseKey, from: messageType === 8 ? 'out' : 'in', text: CONVERSATION_CLEAR_NOTICE_TEXT, messageType, unread: false, rawBlobB64: blobB64, }); if (removed > 0) shouldRefreshToolbarUnread = true; addAppLogEntry({ level: 'info', source: 'signed-dm', message: 'Получено удаление истории переписки', details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) }, }); } if (shouldAckSessionDelivery) { 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') { window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', { detail: { chatId, messageType, messageKey, }, })); if (shouldRefreshToolbarUnread) { refreshToolbarOnly(); } } else if (pageId === 'messages-list' || shouldRefreshToolbarUnread) { renderApp(); } }); authService.onEvent('IncomingCallInvite', async (evt) => { try { await handleIncomingCallInvite(evt); } catch {} }); window.addEventListener('shine-unread-state-updated', refreshUnreadUi); authService.onEvent('IncomingCallSignal', async (evt) => { try { await handleIncomingCallSignal(evt); } catch {} }); authService.onEvent('IncomingSignal', async (evt) => { try { await handleIncomingCallSignalViaSendSignal(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', }); } }); // Важно: сначала всегда отрисовываем UI (чтобы не было "чёрного экрана"), // а сетевые/авторизационные шаги выполняем фоном. if (!window.location.pathname || window.location.pathname === '/' || window.location.pathname === '/index.html') { navigate(state.session.isAuthorized ? 'messages-list' : 'start-view'); } renderApp(); void (async () => { try { await tryAutoLogin(); await hydrateMessagesFromStore(); if (!state.session.isLocalDemo) { startConnectionMonitor(); startPeriodicUiVersionCheck(); } await ensureSessionRuntimeStarted(); } finally { renderApp(); } })(); window.addEventListener('popstate', renderApp); installHorizontalTabSwipe(); document.addEventListener('pointerdown', () => { void unlockHiddenDmAudio(); }, { passive: true }); document.addEventListener('keydown', () => { void unlockHiddenDmAudio(); }, { passive: true }); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; void tryLockPortraitOrientation(); void checkConnectionHealth(); }); window.addEventListener('orientationchange', () => { void tryLockPortraitOrientation(); }); } init();