Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/mobile/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MOBILE_PROTOCOL_MAX } from "@openmapx/core/navigation";
import { act, fireEvent, render } from "@testing-library/react-native";
import { Linking } from "react-native";
import { BackHandler, Linking } from "react-native";
import { App } from "./App";
import { resetRuntimeConfigCache } from "./config/runtimeConfig";
import { groundSessionFixture } from "./storage/testing/sessionFixture";
Expand All @@ -22,6 +22,7 @@ jest.mock("react-native-webview", () => {
mockWebViewProps = props;
React.useImperativeHandle(ref, () => ({
injectJavaScript: (script: string) => mockInjectedScripts.push(script),
goBack: () => mockInjectedScripts.push("<goBack>"),
}));
return React.createElement(View, { testID: props.testID as string });
}),
Expand Down Expand Up @@ -84,6 +85,23 @@ describe("App WebView policy", () => {
expect(mockWebViewProps.source).toEqual({ uri: "https://openmapx.com" });
});

it("sends Android's Back button into the page's history while it has any", async () => {
let press: (() => boolean | null | undefined) | undefined;
jest.spyOn(BackHandler, "addEventListener").mockImplementation((_event, handler) => {
press = () => handler({} as never);
return { remove: jest.fn() };
});
await renderApp();
expect(press?.()).toBe(false);
await act(async () => {
(mockWebViewProps.onNavigationStateChange as (state: { canGoBack: boolean }) => void)({
canGoBack: true,
});
});
expect(press?.()).toBe(true);
expect(mockInjectedScripts).toContain("<goBack>");
});

it("allowlists only the exact configured origin", async () => {
await renderApp();
expect(mockWebViewProps.originWhitelist).toEqual(["https://openmapx.com"]);
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { LoadingOverlay } from "./shell/ShellOverlays";
import type { ShellAction } from "./shell/ShellState";
import { deviceMobileLocale } from "./shell/shellCopy";
import { INITIAL_SHELL, shellReducer } from "./shell/shellReducer";
import { useHardwareBack } from "./shell/useHardwareBack";

/**
* The entire visible product is the web UI inside one WebView pointed at the
Expand All @@ -48,6 +49,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement
const connectivity = useConnectivity();

const webViewRef = useRef<WebView>(null);
const handleNavigationStateChange = useHardwareBack(webViewRef);
const shell = useMemo(
() =>
createShellBridge({
Expand Down Expand Up @@ -232,6 +234,8 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement
thirdPartyCookiesEnabled={false}
allowsBackForwardNavigationGestures
onShouldStartLoadWithRequest={handleNavigationRequest}
// Android's Back button goes back in the page before it leaves the app.
onNavigationStateChange={handleNavigationStateChange}
onOpenWindow={(event) => {
// Popups get the same structural policy as ordinary navigation; they
// never open a second in-app browser.
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/shell/useHardwareBack.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { renderHook } from "@testing-library/react-native";
import { BackHandler } from "react-native";
import type { WebView } from "react-native-webview";
import { useHardwareBack } from "./useHardwareBack";

function setup() {
let press: (() => boolean | null | undefined) | undefined;
const remove = jest.fn();
jest.spyOn(BackHandler, "addEventListener").mockImplementation((_event, handler) => {
press = () => handler({} as never);
return { remove };
});
const goBack = jest.fn();
const webViewRef = { current: { goBack } as unknown as WebView };
return { press: () => press?.(), remove, goBack, webViewRef };
}

afterEach(() => {
jest.restoreAllMocks();
});

describe("useHardwareBack", () => {
it("leaves Back to the system while the page has no history", async () => {
const { press, goBack, webViewRef } = setup();
await renderHook(() => useHardwareBack(webViewRef));
expect(press()).toBe(false);
expect(goBack).not.toHaveBeenCalled();
});

it("steps back through the page's history once it has some", async () => {
const { press, goBack, webViewRef } = setup();
const { result } = await renderHook(() => useHardwareBack(webViewRef));
result.current({ canGoBack: true });
expect(press()).toBe(true);
expect(goBack).toHaveBeenCalledTimes(1);

result.current({ canGoBack: false });
expect(press()).toBe(false);
expect(goBack).toHaveBeenCalledTimes(1);
});

it("stops listening when the shell unmounts", async () => {
const { remove, webViewRef } = setup();
const { unmount } = await renderHook(() => useHardwareBack(webViewRef));
await unmount();
expect(remove).toHaveBeenCalledTimes(1);
});
});
35 changes: 35 additions & 0 deletions apps/mobile/src/shell/useHardwareBack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { type RefObject, useCallback, useEffect, useRef } from "react";
import { BackHandler } from "react-native";
import type { WebView, WebViewNavigation } from "react-native-webview";

/**
* Android's Back button walks the page's own history before it leaves the app.
*
* The web app gives every sheet, dialog and opened place a history entry, so
* going back in the WebView closes exactly what the user last opened. Only once
* the page has nothing left to go back to does Back fall through to the system,
* which closes the app as usual. iOS needs none of this: its edge swipe already
* drives the same history through `allowsBackForwardNavigationGestures`.
*
* Returns the WebView's `onNavigationStateChange` handler, which keeps track of
* whether the page can go back — pushState entries included.
*/
export function useHardwareBack(
webViewRef: RefObject<WebView | null>,
): (navigation: Pick<WebViewNavigation, "canGoBack">) => void {
const canGoBackRef = useRef(false);

useEffect(() => {
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
const webView = webViewRef.current;
if (!canGoBackRef.current || !webView) return false;
webView.goBack();
return true;
});
return () => subscription.remove();
}, [webViewRef]);

return useCallback((navigation) => {
canGoBackRef.current = navigation.canGoBack;
}, []);
}
2 changes: 1 addition & 1 deletion apps/web/src/app/(legal)/licenses/LicenseTextDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import CloseIcon from "@mui/icons-material/Close";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import { useState } from "react";
import Dialog from "@/components/ui/Dialog";

interface Props {
packageName: string;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { WaypointMarkers } from "@/components/map/WaypointMarkers";
import { HamburgerMenu } from "@/components/menu/HamburgerMenu";
import { HideDuringNavigation } from "@/components/navigation/HideDuringNavigation";
import { NavIncidentsProvider } from "@/components/navigation/NavIncidentsProvider";
import { NavigationBackHandler } from "@/components/navigation/NavigationBackHandler";
import { NavigationView } from "@/components/navigation/NavigationView";
import { TransitNavigationView } from "@/components/navigation/TransitNavigationView";
import { MapClickFloatingCard } from "@/components/panels/MapClickFloatingCard";
Expand Down Expand Up @@ -109,6 +110,7 @@ export default function HomePage() {
<NavTrafficSignalsLayer />
<NavigationView />
<TransitNavigationView />
<NavigationBackHandler />
<FlightArcLayer />
<TimelineMapLayer />
<TransitRouteLayer />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import Alert from "@mui/material/Alert";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand Down Expand Up @@ -41,6 +40,7 @@ import {
} from "@openmapx/core";
import { useLocale, useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { useEnv } from "@/integration-api/runtime/EnvProvider";
import type { ClientEnv } from "@/integration-api/runtime/env";
import { haptics } from "@/lib/haptics";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/activity/AuditLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import FormControl from "@mui/material/FormControl";
import IconButton from "@mui/material/IconButton";
import InputLabel from "@mui/material/InputLabel";
import ListSubheader from "@mui/material/ListSubheader";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
Expand All @@ -24,6 +23,7 @@ import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import Menu from "@/components/ui/Menu";
import { useEnv } from "@/integration-api/runtime/EnvProvider";
import { relativeTimeFromIso } from "@/lib/formatTime";
import { AdminTablePagination } from "../shared/AdminTablePagination";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand Down Expand Up @@ -47,6 +46,7 @@ import type {
} from "@openmapx/core/api-contracts";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { useActivityClient } from "@/components/admin/activity/activityQueries";
import Dialog from "@/components/ui/Dialog";
import {
useAddExtensionSource,
useExtensionCatalog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -16,6 +15,7 @@ import Typography from "@mui/material/Typography";
import type { CredentialSetup } from "@openmapx/integration-framework";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { useIntegrationAdmin } from "@/lib/admin/integrationAdmin";
import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner";
import { CredentialSetupGuide } from "./CredentialSetupGuide";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -20,6 +19,7 @@ import Typography from "@mui/material/Typography";
import { createApiClient } from "@openmapx/core";
import { type QueryKey, useMutation } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs";
import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner";
import {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/services/BackupsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -34,6 +33,7 @@ import type { AdminServiceBackup } from "@openmapx/core/api-contracts";
import { useMutation, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useMemo, useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { serviceAdminQueries, serviceOrThrow } from "@/lib/admin/serviceAdmin";
import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs";
import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -19,6 +18,7 @@ import type {
AdminServiceCredentialChanged,
} from "@openmapx/core/api-contracts";
import { useState } from "react";
import Dialog from "@/components/ui/Dialog";
import {
useDeleteServiceCredential,
useServiceCredentials,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -27,6 +26,7 @@ import type { TransitSource } from "@openmapx/core/api-contracts";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useMemo, useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { formatBytes } from "@/lib/storageFormat";
import { AdminTablePagination } from "../shared/AdminTablePagination";
import { TableEmptyState } from "../shared/TableEmptyState";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/shared/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import Dialog from "@/components/ui/Dialog";

export interface ConfirmDialogProps {
open: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import Chip from "@mui/material/Chip";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -30,6 +29,7 @@ import Typography from "@mui/material/Typography";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useEffect, useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { activityJobQueryOptions, useActivityClient } from "../activity/activityQueries";
import { AdminPageHeader } from "../shared/AdminPageHeader";
import { AdminTableSurface } from "../shared/AdminTableSurface";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/users/BanUserDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
Expand All @@ -10,6 +9,7 @@ import TextField from "@mui/material/TextField";
import { authClient } from "@openmapx/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { useAdminToast } from "../shared/AdminToast";

interface BanUserDialogProps {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/users/CreateUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
Expand All @@ -16,6 +15,7 @@ import TextField from "@mui/material/TextField";
import { authClient } from "@openmapx/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import Dialog from "@/components/ui/Dialog";
import { useAdminToast } from "../shared/AdminToast";

interface CreateUserDialogProps {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/users/UserList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import FormControl from "@mui/material/FormControl";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
Expand All @@ -37,6 +36,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Menu from "@/components/ui/Menu";
import { AdminPageHeader } from "../shared/AdminPageHeader";
import { AdminTablePagination } from "../shared/AdminTablePagination";
import { AdminTableSurface } from "../shared/AdminTableSurface";
Expand Down
Loading
Loading