diff --git a/README.md b/README.md index 8f0f615..b9216de 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ phone. iPhone: - Open the URL in Safari. +- Tap the ... menu. - Tap Share. - Tap Add to Home Screen. - Open MAC Study from the new home-screen icon. diff --git a/docs/ios-pwa-mobile-nav.md b/docs/ios-pwa-mobile-nav.md new file mode 100644 index 0000000..b9ae417 --- /dev/null +++ b/docs/ios-pwa-mobile-nav.md @@ -0,0 +1,99 @@ +# iOS home-screen app: mobile nav and the bottom gap + +How the mobile nav fits the screen on an installed iPhone app, and why the +status bar style is `black` rather than `black-translucent`. + +## Symptom + +On an iPhone running the app from the home screen (standalone PWA), a dark +band about 59pt tall sat under the bottom nav. The nav and the page content +both stopped short of the bottom edge. It showed on a cold launch and had been +there since the PWA was first set up. Safari in a normal tab was not affected. + +## Cause + +This is an iOS 26 WebKit regression. It hits standalone web apps that use: + +- `apple-mobile-web-app-status-bar-style: black-translucent`, and +- `viewport-fit=cover`. + +With that combination, iOS makes the page's layout viewport shorter than the +screen by the top safe-area inset, which is the status bar height (about 59pt +on Dynamic Island iPhones, 47pt on notched ones). Every height the page can +read reports the short value: `100dvh`, `100vh`, `window.innerHeight` and +`visualViewport.height`. The page is also clipped at that line, so +`position: fixed; bottom: 0` elements end up above the real bottom edge. Only +the page background colour shows in the strip below. + +Measured from a screenshot on our device, the scroll area and the nav both +ended about 59pt above the bottom, matching the top inset exactly. + +## What did not work (commit `cfb43e5`, reverted in `f5fe454`) + +We read the real screen height (`screen.height`) on iOS standalone, wrote it to +`--app-viewport-height`, and anchored the nav from the top instead of +`bottom: 0`. The nav moved down but was **cut off** at the same line where the +gap used to begin. That is how we confirmed the strip is outside the area the +page is allowed to draw in, so no CSS or JS resizing can reach it. Other +developers hitting the same bug report the same result. + +## Fix (commit `e54c53b`) + +`src/app/layout.tsx`: + +```ts +appleWebApp: { + capable: true, + statusBarStyle: "black", + title: "MAC Study", +}, +``` + +With `black`, iOS places the web view **below** the status bar instead of +under it. The top safe-area inset becomes 0, the layout viewport is no longer +short, and the nav reaches the bottom of the screen. + +### Trade-off + +The status bar strip is pure black (`#000`) instead of the app background +(`#171717`), so there is a faint colour step at the very top. Content no longer +draws behind the status bar. + +### Things that still apply + +- `--safe-area-top` resolves to `0` in the installed app now, so headers that + pad by it (for example the chat headers) simply lose that padding. No change + needed. +- `--safe-area-bottom` is still the home indicator inset (about 34pt). The + `@media (display-mode: standalone)` block in `src/app/globals.css` uses it to + lift the nav icons just clear of the indicator. Adjust the `- 1.25rem` in + `--mobile-nav-control-bottom` to change that spacing. + +## Testing a change here + +iOS reads the status bar style **when the app is added to the home screen**. +After changing it: + +1. Deploy. +2. Delete the app from the home screen and add it again. Relaunching alone keeps + the old setting. +3. Cold-launch the app and check: + - The nav sits on the bottom edge, with no band below and nothing cut off. + - Headers aren't hidden under the status bar. + - In a chat, the composer sits at the bottom, rides above the keyboard, and + drops back when the keyboard closes. + +## Before switching back to `black-translucent` + +Only switch back once Apple fixes the regression, and test the result on a real +iOS 26+ device from the home screen. The simulator and Safari tabs do not show +the bug. + +## References + +- busssss PR #23, which traces the 47px bottom gap to an iOS 26 WebKit + regression: https://github.com/tpdbf5509/busssss/pull/23 +- arc PR #8, which fixes the 59pt black strip by using `black` instead of + `black-translucent`: https://github.com/joejohnston72-dev/arc/pull/8 +- 0xchat PR #135, the same gap, tried with `100lvh`: + https://github.com/endziu/0xchat/pull/135 diff --git a/src/app/globals.css b/src/app/globals.css index b3b1020..802e068 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -151,6 +151,21 @@ body { } } +/* Hide scroll indicators on mobile; scrolling itself still works. */ +@media (max-width: 63.999rem) { + html, + body, + * { + scrollbar-width: none; + } + + ::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } +} + @media (min-width: 64rem) { .mac-desktop-shell { background: var(--color-background); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8deb6bd..7d1f1bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,10 @@ export const metadata: Metadata = { manifest: "/manifest.webmanifest", appleWebApp: { capable: true, - statusBarStyle: "black-translucent", + // Not "black-translucent": on iOS 26 that shrinks the installed app's + // viewport and leaves a gap under the mobile nav. + // See docs/ios-pwa-mobile-nav.md. + statusBarStyle: "black", title: "MAC Study", }, icons: { diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 0c7ccb1..6028902 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -16,16 +16,20 @@ import { } from "lucide-react"; import type { AppAuthState } from "@/lib/auth/app-auth"; import { + cacheRemoteFriendsSnapshot, cacheRemoteGroupsSnapshot, cacheRemoteTimerState, cacheRemoteUnitState, dedupeRemoteRequest, + getCachedRemoteFriendsSnapshot, getCachedRemoteGroupsSnapshot, getCachedRemoteTimerState, + getStaleRemoteTimerState, } from "@/lib/client-cache"; import { getMascotSrc, MASCOT_KEYS } from "@/lib/mascots"; import { fetchRemoteDirectMessageUnreadCount, + fetchRemoteFriendsSnapshot, fetchRemoteGroupsSnapshot, fetchRemoteTimerState, fetchRemoteUnitState, @@ -203,6 +207,23 @@ export function AppShell({ } })(); + if (!getCachedRemoteFriendsSnapshot(cacheUserId)) { + void (async () => { + try { + const supabase = createSupabaseBrowserClient(); + const snapshot = await dedupeRemoteRequest({ + key: "friends", + load: () => fetchRemoteFriendsSnapshot(supabase), + userId: cacheUserId, + }); + + if (!cancelled && snapshot) cacheRemoteFriendsSnapshot(snapshot); + } catch { + // Preloading is opportunistic; the Friends view can retry on demand. + } + })(); + } + if (getCachedRemoteGroupsSnapshot(cacheUserId)) return; void (async () => { @@ -219,7 +240,9 @@ export function AppShell({ // Preloading is opportunistic; the Groups view can retry on demand. } })(); - }, 350); + // Queued behind the timer request so Home still goes first, but early + // enough that the other tabs are warm by the time the splash leaves. + }, 0); return () => { cancelled = true; @@ -255,8 +278,12 @@ export function AppShell({ const cacheUserId = currentUserId; let cancelled = false; - // Fresh cached timer data is enough; don't refetch on every navigation. - if (getCachedRemoteTimerState(cacheUserId)) { + // Any recent timer snapshot is enough to paint Home: the dashboard shows + // it straight away and refetches behind it, so don't hold the splash. + if ( + getCachedRemoteTimerState(cacheUserId) || + getStaleRemoteTimerState(cacheUserId) + ) { revealApp(); return () => window.cancelAnimationFrame(readyFrame); } diff --git a/src/components/pwa/install-onboarding.tsx b/src/components/pwa/install-onboarding.tsx index b5d63ba..663a108 100644 --- a/src/components/pwa/install-onboarding.tsx +++ b/src/components/pwa/install-onboarding.tsx @@ -12,6 +12,7 @@ import { import { Download, MonitorDown, + MoreHorizontal, MoreVertical, Pin, Share, @@ -53,6 +54,7 @@ type LauncherKind = "pc" | "phone"; const phoneGuides: Record = { ios: [ { title: "Open study.monashcoding.com in Safari" }, + { glyph: MoreHorizontal, title: "Tap the menu" }, { glyph: Share, title: "Tap Share" }, { glyph: SquarePlus, title: "Add to Home Screen" }, ], diff --git a/src/components/timer/timer-dashboard.tsx b/src/components/timer/timer-dashboard.tsx index 07b7b39..ff59ebc 100644 --- a/src/components/timer/timer-dashboard.tsx +++ b/src/components/timer/timer-dashboard.tsx @@ -29,6 +29,7 @@ import { cacheRemoteTimerState, dedupeRemoteRequest, getCachedRemoteTimerState, + getStaleRemoteTimerState, subscribeToRemoteTableChanges, } from "@/lib/client-cache"; import { @@ -229,7 +230,9 @@ export function TimerDashboard({ let cancelled = false; async function loadInitialState() { - const cachedRemoteState = getCachedRemoteTimerState(userId); + // A stale snapshot paints immediately; the fetch below replaces it. + const cachedRemoteState = + getCachedRemoteTimerState(userId) ?? getStaleRemoteTimerState(userId); if (cachedRemoteState) { applyRemoteTimerState(cachedRemoteState); @@ -932,7 +935,7 @@ export function TimerDashboard({ /> ) : ( { }); describe("remote client cache freshness", () => { - it("expires cached data and removes it from persistent storage", () => { + it("stops serving expired data but keeps it stored for the stale window", () => { cacheRemoteFriendsSnapshot(friendsSnapshot); expect(getCachedRemoteFriendsSnapshot("viewer")).toEqual(friendsSnapshot); expect(storage.size).toBe(1); vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1); + expect(getCachedRemoteFriendsSnapshot("viewer")).toBeNull(); + expect(storage.size).toBe(1); + + vi.advanceTimersByTime(REMOTE_STALE_CACHE_MAX_AGE_MS); + expect(getCachedRemoteFriendsSnapshot("viewer")).toBeNull(); expect(storage.size).toBe(0); }); + it("serves stale timer state for painting until the stale window ends", () => { + cacheRemoteTimerState(timerState); + vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1); + + expect(getCachedRemoteTimerState("viewer")).toBeNull(); + expect(getStaleRemoteTimerState("viewer")).toEqual(timerState); + expect(getStaleRemoteTimerState("someone-else")).toBeNull(); + + vi.advanceTimersByTime(REMOTE_STALE_CACHE_MAX_AGE_MS); + + expect(getStaleRemoteTimerState("viewer")).toBeNull(); + }); + + it("drops stale timer state once a table change invalidates it", () => { + cacheRemoteTimerState(timerState); + vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1); + + invalidateRemoteCachesForTable("study_sessions"); + + expect(getStaleRemoteTimerState("viewer")).toBeNull(); + }); + it("invalidates only the caches affected by a changed table", () => { cacheRemoteTimerState(timerState); cacheRemoteFriendsSnapshot(friendsSnapshot); diff --git a/src/lib/client-cache.ts b/src/lib/client-cache.ts index 386ffdf..b29d4cd 100644 --- a/src/lib/client-cache.ts +++ b/src/lib/client-cache.ts @@ -8,6 +8,9 @@ import type { } from "@/lib/supabase/app-data/types"; export const REMOTE_CACHE_MAX_AGE_MS = 2 * 60 * 1000; +// Past the fresh window, data is still good enough to paint on launch while +// a refetch runs, so the app opens straight onto the last-known screen. +export const REMOTE_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; const REMOTE_TIMER_CACHE_KEY = "mac-study-remote-timer-cache-v3"; const REMOTE_FRIENDS_CACHE_KEY = "mac-study-remote-friends-cache-v3"; @@ -46,6 +49,17 @@ export function getCachedRemoteTimerState(userId: string | null) { return value; } +/** Last-known timer state up to a day old, for painting before a refetch. */ +export function getStaleRemoteTimerState(userId: string | null) { + if (!userId) return null; + timerCache ??= readCache(REMOTE_TIMER_CACHE_KEY); + if (!timerCache || timerCache.userId !== userId) return null; + if (Date.now() - timerCache.cachedAt > REMOTE_STALE_CACHE_MAX_AGE_MS) { + return null; + } + return timerCache.value; +} + export function cacheRemoteTimerState(state: RemoteTimerState) { timerCache = createEnvelope(state, state.currentUserId); writeCache(REMOTE_TIMER_CACHE_KEY, timerCache); @@ -215,14 +229,16 @@ function getFreshValue( ): T | null { if (!envelope) return null; - if ( - envelope.userId !== userId || - Date.now() - envelope.cachedAt > REMOTE_CACHE_MAX_AGE_MS - ) { + const age = Date.now() - envelope.cachedAt; + + if (envelope.userId !== userId || age > REMOTE_STALE_CACHE_MAX_AGE_MS) { removeCache(key); return null; } + // Expired but within the stale window: keep it stored for stale readers. + if (age > REMOTE_CACHE_MAX_AGE_MS) return null; + return envelope.value; }