Files
TREK/client/src/utils/resumeRoute.ts
T

136 lines
5.6 KiB
TypeScript

import { useEffect } from 'react'
import { useLocation } from 'react-router'
import { useTripStore } from '../store/tripStore'
/**
* 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'
const DAY_KEY = 'trek:resume-day-'
/**
* The day the plan was on when the app went away (#666), handed over once to
* whoever seeds the trip's first day selection. Null when there is none.
*/
export function takeResumeDay(tripId: number): number | null {
try {
const raw = sessionStorage.getItem(`${DAY_KEY}${tripId}`)
if (raw == null) return null
sessionStorage.removeItem(`${DAY_KEY}${tripId}`)
const day = Number(raw)
return Number.isInteger(day) ? day : null
} catch {
return null
}
}
/** 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; day?: number; 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(), day: number | null = null): 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 } : {}), ...(tripId && day != null ? { day } : {}) }
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<ResumeRecord>
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)
if (tripId && typeof record.day === 'number') sessionStorage.setItem(`${DAY_KEY}${tripId}`, String(record.day))
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(() => {
const save = () => rememberRoute(path, Date.now(), useTripStore.getState().selectedDayId ?? null)
save()
const onVisibility = () => { if (document.visibilityState === 'hidden') save() }
const onPageHide = () => save()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('pagehide', onPageHide)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pagehide', onPageHide)
}
}, [path])
}