diff --git a/client/src/App.tsx b/client/src/App.tsx index 1106f25e1..a8963c1cd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -23,6 +23,7 @@ import { TranslationProvider, useTranslation } from './i18n' import { authApi, isAuthPublicPath } from './api/client' import { tripRepo } from './repo/tripRepo' import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination' +import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync' @@ -144,6 +145,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR // instance across a navigation and the error would follow the user around. return ( + s.isLoaded) @@ -233,6 +241,10 @@ function RootRedirect() { useEffect(() => { if (isLoading || !isAuthenticated || target) return + // The installed app coming back after the system threw it away (#1024): + // back to the trip and tab it was in, ahead of the start page. + const resume = takeResumeRoute() + if (resume) { setTarget(resume); return } const mirrored = readStartDestination() if (!mirrored && !settingsLoaded && !settingsGaveUp) { // Ask for the settings instead of waiting for whoever else might. The diff --git a/client/src/components/Planner/placeNavigation.ts b/client/src/components/Planner/placeNavigation.ts index 20fa78b8a..6ffe7bcd1 100644 --- a/client/src/components/Planner/placeNavigation.ts +++ b/client/src/components/Planner/placeNavigation.ts @@ -5,6 +5,7 @@ import { getCoMapsUrlForPlace } from './placeCoMaps' import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap' import { useSettingsStore } from '../../store/settingsStore' +import { isInstalledApp } from '../../utils/resumeRoute' type PlaceLike = Pick @@ -197,13 +198,3 @@ export function openNavigationTarget(target: NavigationTarget): void { if (target.id === 'geo' || isInstalledApp()) window.location.href = target.url else window.open(target.url, '_blank', 'noopener,noreferrer') } - -/** True in a display mode that has no tab strip to close a stray window from. */ -function isInstalledApp(): boolean { - if (typeof window === 'undefined') return false - const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some( - mode => window.matchMedia?.(`(display-mode: ${mode})`).matches, - ) - // iOS predates the display-mode query for home-screen apps. - return standalone || (window.navigator as { standalone?: boolean }).standalone === true -} diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index ff45d8d0e..1970ed966 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -14,6 +14,7 @@ import { clearAppearanceSnapshot } from '../theme/applyAppearance' import { clearAllPluginSessions } from './pluginStore' import { forgetStartDestination } from '../utils/startDestination' import { forgetServerLanguage } from './settingsStore' +import { forgetResumeRoute } from '../utils/resumeRoute' import { markSignedOut, clearSignedOut } from '../utils/signedOut' import { forgetPushDeviceOnLogout, resyncPushSubscription } from '../push/webPush' @@ -267,6 +268,7 @@ export const useAuthStore = create()( // browser language is one TREK ships, so otherwise the next user here stays // in the previous account's language, launch after launch. forgetServerLanguage() + forgetResumeRoute() // And work-offline, for the same reason with sharper teeth: the switch lives // in localStorage, step 6 below deletes the offline database it reads from, // and the next account would come up believing it is offline over a working diff --git a/client/src/utils/resumeRoute.test.tsx b/client/src/utils/resumeRoute.test.tsx new file mode 100644 index 000000000..040ebc7e6 --- /dev/null +++ b/client/src/utils/resumeRoute.test.tsx @@ -0,0 +1,61 @@ +// FE-UTIL-RESUME-001 to FE-UTIL-RESUME-005 +import { beforeEach, describe, expect, it } from 'vitest' +import { renderHook } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import type { ReactNode } from 'react' +import { forgetResumeRoute, rememberRoute, RESUME_MAX_AGE_MS, takeResumeRoute, useRememberRoute } from './resumeRoute' + +const NOW = 1_800_000_000_000 + +beforeEach(() => { + localStorage.clear() + sessionStorage.clear() +}) + +describe('resume route (#1024)', () => { + it('FE-UTIL-RESUME-001: an installed relaunch goes back to the trip and its tab, once', () => { + sessionStorage.setItem('trip-tab-7', 'buchungen') + rememberRoute('/trips/7?tab=plan', NOW) + sessionStorage.clear() // the system threw the app away + expect(takeResumeRoute(NOW + 60_000, true)).toBe('/trips/7') + expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen') + // Within the same run the start page is the start page again. + expect(takeResumeRoute(NOW + 120_000, true)).toBeNull() + }) + + it('FE-UTIL-RESUME-002: a deep-linked tab is kept when the planner never switched', () => { + rememberRoute('/trips/7?tab=finanzplan', NOW) + sessionStorage.clear() + expect(takeResumeRoute(NOW, true)).toBe('/trips/7') + expect(sessionStorage.getItem('trip-tab-7')).toBe('finanzplan') + }) + + it('FE-UTIL-RESUME-003: not in a browser tab, not when stale, not for places that are no destination', () => { + rememberRoute('/journey/3', NOW) + expect(takeResumeRoute(NOW, false)).toBeNull() + sessionStorage.clear() + expect(takeResumeRoute(NOW + RESUME_MAX_AGE_MS + 1, true)).toBeNull() + sessionStorage.clear() + rememberRoute('/settings', NOW) + expect(localStorage.getItem('trek:resume-route')).toBeNull() + expect(takeResumeRoute(NOW, true)).toBeNull() + }) + + it('FE-UTIL-RESUME-004: logout forgets it, and a broken record is ignored', () => { + rememberRoute('/atlas', NOW) + forgetResumeRoute() + expect(takeResumeRoute(NOW, true)).toBeNull() + sessionStorage.clear() + localStorage.setItem('trek:resume-route', '{nope') + expect(takeResumeRoute(NOW, true)).toBeNull() + }) + + it('FE-UTIL-RESUME-005: the hook records the route it renders on, and again when the page is hidden', () => { + const wrapper = ({ children }: { children: ReactNode }) => {children} + renderHook(() => useRememberRoute(), { wrapper }) + expect(JSON.parse(localStorage.getItem('trek:resume-route')!).path).toBe('/journey/9') + localStorage.clear() + window.dispatchEvent(new Event('pagehide')) + expect(JSON.parse(localStorage.getItem('trek:resume-route')!).path).toBe('/journey/9') + }) +}) diff --git a/client/src/utils/resumeRoute.ts b/client/src/utils/resumeRoute.ts new file mode 100644 index 000000000..b4721780f --- /dev/null +++ b/client/src/utils/resumeRoute.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router' + +/** + * Where the installed app was when it went to the background (#1024). + * + * iOS throws a home-screen app away soon after it is switched out (to Google + * Maps, say), and the relaunch opens the start URL: back on the dashboard, the + * trip and the tab the traveller was in gone. The route and the trip's tab are + * kept here instead, and RootRedirect resumes them on a relaunch that comes soon + * enough to still be the same session of use. + * + * Installed app only: in a browser tab, typing the address means "start". + */ +const KEY = 'trek:resume-route' +const CHECKED_KEY = 'trek:resume-checked' +/** Older than this, a relaunch is a fresh start again. */ +export const RESUME_MAX_AGE_MS = 6 * 60 * 60 * 1000 + +interface ResumeRecord { path: string; tab?: string; at: number } + +/** True in a display mode that has no tab strip, i.e. launched from the home screen. */ +export function isInstalledApp(): boolean { + if (typeof window === 'undefined') return false + const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some( + mode => window.matchMedia?.(`(display-mode: ${mode})`).matches, + ) + // iOS predates the display-mode query for home-screen apps. + return standalone || (window.navigator as { standalone?: boolean }).standalone === true +} + +/** Routes that are a place to come back to; the root, auth pages and settings are not. */ +function resumable(path: string): boolean { + return /^\/(trips\/\d+|journey(\/\d+)?|collections|vacay|atlas|files)(\/|\?|$)/.test(path) +} + +const tripIdOf = (path: string): string | null => /^\/trips\/(\d+)/.exec(path)?.[1] ?? null + +function withoutTabParam(path: string): string { + const [pathname, search = ''] = path.split('?') + const params = new URLSearchParams(search) + params.delete('tab') + const rest = params.toString() + return rest ? `${pathname}?${rest}` : pathname +} + +/** Called on every protected navigation and when the app is hidden. */ +export function rememberRoute(path: string, now = Date.now()): void { + try { + if (!resumable(path)) { + localStorage.removeItem(KEY) + return + } + const tripId = tripIdOf(path) + // The tab the planner last switched to, else the one the address asked for. Kept + // apart from the address, where a ?tab= would outrank a tab switched to since. + const asked = new URLSearchParams(path.split('?')[1] ?? '').get('tab') ?? undefined + const tab = tripId ? sessionStorage.getItem(`trip-tab-${tripId}`) ?? asked : undefined + const kept = tripId ? withoutTabParam(path) : path + const record: ResumeRecord = { path: kept, at: now, ...(tab ? { tab } : {}) } + localStorage.setItem(KEY, JSON.stringify(record)) + } catch { + // Storage refused: the relaunch simply starts where it always did. + } +} + +/** + * The route to resume on this launch, or null. A trip's tab is put back where + * the planner reads it, so the tab opens rather than the plan. + */ +export function takeResumeRoute(now = Date.now(), installed = isInstalledApp()): string | null { + if (!installed) return null + try { + // Once per launch. sessionStorage dies with the app the system threw away and + // survives everything else, so a later trip to the start page within the same + // run goes to the start page, not back into the trip. + if (sessionStorage.getItem(CHECKED_KEY)) return null + sessionStorage.setItem(CHECKED_KEY, '1') + const raw = localStorage.getItem(KEY) + if (!raw) return null + const record = JSON.parse(raw) as Partial + if (typeof record.path !== 'string' || typeof record.at !== 'number') return null + if (now - record.at > RESUME_MAX_AGE_MS || !resumable(record.path)) return null + const tripId = tripIdOf(record.path) + if (tripId && record.tab) sessionStorage.setItem(`trip-tab-${tripId}`, record.tab) + return record.path + } catch { + return null + } +} + +/** On logout, so the next account on this device does not resume someone else's trip. */ +export function forgetResumeRoute(): void { + try { localStorage.removeItem(KEY) } catch { /* nothing to forget */ } +} + +/** + * Keeps the record current: on each navigation, and once more as the app is + * hidden, which is the last moment a tab switched inside the trip gets saved. + */ +export function useRememberRoute(): void { + const location = useLocation() + const path = location.pathname + location.search + useEffect(() => { + rememberRoute(path) + const onVisibility = () => { if (document.visibilityState === 'hidden') rememberRoute(path) } + const onPageHide = () => rememberRoute(path) + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('pagehide', onPageHide) + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('pagehide', onPageHide) + } + }, [path]) +}