mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(pwa): reopen the trip and tab the installed app was in (#1024)
This commit is contained in:
@@ -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 (
|
||||
<MobileShell isPhone={isPhone}>
|
||||
<RouteMemory />
|
||||
<ErrorBoundary
|
||||
key={location.pathname}
|
||||
boundaryId="route"
|
||||
@@ -214,6 +216,12 @@ function ViewportRoute({ phone: Phone, desktop: Desktop }: {
|
||||
* didn't set it. Only 'active_trip' costs the one lookup that finds the trip,
|
||||
* and any failure along the way lands on the dashboard.
|
||||
*/
|
||||
/** Remembers the protected route for the installed app's relaunch (#1024). Renders nothing. */
|
||||
function RouteMemory() {
|
||||
useRememberRoute()
|
||||
return null
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
const settingsLoaded = useSettingsStore((s) => 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
|
||||
|
||||
@@ -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<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<AuthState>()(
|
||||
// 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
|
||||
|
||||
@@ -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 }) => <MemoryRouter initialEntries={['/journey/9']}>{children}</MemoryRouter>
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<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)
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user