142 lines
6.2 KiB
JavaScript
142 lines
6.2 KiB
JavaScript
import React from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import App from './App.jsx';
|
|
import './index.css';
|
|
|
|
// Register service worker
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
|
.then(reg => {
|
|
console.log('[SW] Registered, scope:', reg.scope);
|
|
// iOS aggressively HTTP-caches sw.js — force a check whenever the app
|
|
// becomes visible so updates are picked up without a full cold launch.
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') reg.update().catch(() => {});
|
|
});
|
|
})
|
|
.catch(err => console.error('[SW] Registration failed:', err));
|
|
});
|
|
}
|
|
|
|
|
|
|
|
// ─── Touch gesture handler ───────────────────────────────────────────────────
|
|
// Handles two behaviours in one unified listener set to avoid conflicts:
|
|
//
|
|
// 1. PINCH → font scale only (not viewport zoom).
|
|
// viewport has user-scalable=no so the browser never zooms the layout.
|
|
// We intercept the pinch and adjust --font-scale on <html> instead,
|
|
// which scales only text (rem-based font sizes). Persisted to localStorage.
|
|
// On first launch, html { font-size: 100% } inherits the Android system
|
|
// font size as the 1rem baseline automatically.
|
|
//
|
|
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
|
|
(function () {
|
|
const LS_KEY = 'rosterchirp_font_scale';
|
|
const MIN_SCALE = 0.8;
|
|
const MAX_SCALE = 2.0;
|
|
|
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
|
|| window.navigator.standalone === true;
|
|
|
|
// Restore saved font scale on launch
|
|
const saved = parseFloat(localStorage.getItem(LS_KEY));
|
|
let currentScale = (saved >= MIN_SCALE && saved <= MAX_SCALE) ? saved : 1.0;
|
|
document.documentElement.style.setProperty('--font-scale', currentScale);
|
|
|
|
let pinchStartDist = null;
|
|
let pinchStartScale = currentScale;
|
|
let singleStartY = 0;
|
|
|
|
function getTouchDist(e) {
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
document.addEventListener('touchstart', function (e) {
|
|
if (e.touches.length === 2) {
|
|
pinchStartDist = getTouchDist(e);
|
|
pinchStartScale = currentScale;
|
|
} else if (e.touches.length === 1) {
|
|
singleStartY = e.touches[0].clientY;
|
|
}
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('touchmove', function (e) {
|
|
if (e.touches.length === 2 && pinchStartDist !== null) {
|
|
// Two-finger pinch: scale fonts, not viewport.
|
|
// Skip when a lightbox is open — let the browser handle pinch natively there.
|
|
if (document.documentElement.dataset.lightboxOpen) return;
|
|
e.preventDefault();
|
|
const ratio = getTouchDist(e) / pinchStartDist;
|
|
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchStartScale * ratio));
|
|
currentScale = Math.round(newScale * 100) / 100;
|
|
document.documentElement.style.setProperty('--font-scale', currentScale);
|
|
} else if (e.touches.length === 1 && isStandalone) {
|
|
// Single finger: block pull-to-refresh only when no scrollable ancestor
|
|
// has scrolled content above the viewport.
|
|
// Without this ancestor check, document.scrollTop is always 0 in this
|
|
// flex layout, so the naive condition blocked ALL upward swipes (dy > 0),
|
|
// making any scroll container impossible to scroll back up after reaching
|
|
// the bottom — freezing the window.
|
|
const dy = e.touches[0].clientY - singleStartY;
|
|
if (dy > 0) {
|
|
let el = e.target;
|
|
let canScrollUp = false;
|
|
while (el && el !== document.documentElement) {
|
|
if (el.scrollTop > 0) { canScrollUp = true; break; }
|
|
el = el.parentElement;
|
|
}
|
|
if (!canScrollUp) e.preventDefault();
|
|
}
|
|
}
|
|
}, { passive: false });
|
|
|
|
document.addEventListener('touchend', function (e) {
|
|
if (e.touches.length < 2 && pinchStartDist !== null) {
|
|
pinchStartDist = null;
|
|
localStorage.setItem(LS_KEY, currentScale);
|
|
}
|
|
}, { passive: true });
|
|
})();
|
|
|
|
// ─── iOS virtual keyboard layout fix ────────────────────────────────────────
|
|
// iOS Safari/PWA ignores `interactive-widget=resizes-content` and instead
|
|
// scrolls the page when the keyboard opens, causing two bugs:
|
|
// 1. The chat header scrolls off-screen ("NO MESSAGE TITLE")
|
|
// 2. env(safe-area-inset-bottom) stays at ~34px, adding extra padding below input
|
|
//
|
|
// Fix: track the Visual Viewport height and expose it as --visual-viewport-height
|
|
// so .chat-layout always fills exactly the visible area above the keyboard.
|
|
// Also toggle a `keyboard-open` class so CSS can remove the safe-area padding
|
|
// from the message input (the keyboard covers the home indicator area anyway).
|
|
if (window.visualViewport) {
|
|
const onViewportChange = () => {
|
|
const vv = window.visualViewport;
|
|
document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`);
|
|
// Expose the visual viewport's vertical offset so .chat-layout can stay
|
|
// pinned to the visible area even when iOS scrolls the page on keyboard open.
|
|
document.documentElement.style.setProperty('--visual-viewport-offset', `${vv.offsetTop}px`);
|
|
// window.innerHeight doesn't shrink on iOS when keyboard opens — the gap IS the keyboard.
|
|
const keyboardVisible = (window.innerHeight - vv.height) > 150;
|
|
document.documentElement.classList.toggle('keyboard-open', keyboardVisible);
|
|
};
|
|
window.visualViewport.addEventListener('resize', onViewportChange);
|
|
window.visualViewport.addEventListener('scroll', onViewportChange);
|
|
onViewportChange(); // set immediately so first render uses the correct height
|
|
}
|
|
|
|
// Clear badge count when user focuses the app
|
|
window.addEventListener('focus', () => {
|
|
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
|
navigator.serviceWorker?.controller?.postMessage({ type: 'CLEAR_BADGE' });
|
|
});
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>
|
|
);
|