UI: свайпы вкладок и навигация тредов

This commit is contained in:
AidarKC
2026-08-12 19:37:29 +04:00
parent b4b23afc10
commit e8a4713f6e
9 changed files with 770 additions and 26 deletions
+493 -8
View File
@@ -1,4 +1,14 @@
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
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';
@@ -163,6 +173,14 @@ 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;
@@ -184,6 +202,9 @@ 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',
@@ -283,6 +304,13 @@ function createChromeController(showAppChrome) {
composerNode = null;
apply();
},
suspend() {
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
},
resume() {
apply();
},
dispose() {
disposed = true;
clearSlot(topbarEl, '--topbar-height');
@@ -291,6 +319,403 @@ function createChromeController(showAppChrome) {
};
}
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;
@@ -971,6 +1396,7 @@ function renderPageFailureFallback(pageId, error) {
});
screenEl.innerHTML = '';
teardownSwipePreview({ cancelOnly: true });
const wrap = document.createElement('section');
wrap.className = 'stack';
@@ -1007,6 +1433,8 @@ function renderPageFailureFallback(pageId, error) {
}
function renderApp() {
teardownSwipePreview({ cancelOnly: true });
syncTrackedRouteHistory(window.location.pathname || '/');
const route = getRoute();
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
@@ -1021,19 +1449,61 @@ function renderApp() {
}
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 || '/');
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
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;
}
}
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 showAppChrome = page.pageMeta?.showAppChrome !== false;
const chrome = createChromeController(showAppChrome);
currentChromeCleanup = () => chrome.dispose();
const screen = page.render({ route, navigate, chrome });
@@ -1043,6 +1513,19 @@ function renderApp() {
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));
@@ -1163,6 +1646,7 @@ async function init() {
setSessionResetHandler(() => {
sessionRuntimeStarted = false;
startConnectionMonitor();
clearKeepAliveEntries();
if (reconnectIntervalId) {
window.clearInterval(reconnectIntervalId);
reconnectIntervalId = null;
@@ -1524,6 +2008,7 @@ async function init() {
})();
window.addEventListener('popstate', renderApp);
installHorizontalTabSwipe();
document.addEventListener('pointerdown', () => {
void unlockHiddenDmAudio();
}, { passive: true });