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
26 changes: 24 additions & 2 deletions frontend/src/app/components/global_ui/InstallPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,24 @@ interface BeforeInstallPromptEvent extends Event {
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}

export const DISMISSED_STORAGE_KEY = "install-prompt-dismissed";

export const isInstallPromptDismissed = (): boolean => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(DISMISSED_STORAGE_KEY) === "true";
} catch {
return false;
}
};

export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(false);

useEffect(() => {
setDismissed(isInstallPromptDismissed());

const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
Expand All @@ -25,13 +38,22 @@ export function InstallPrompt() {
if (!deferredPrompt || dismissed) return null;

const handleInstall = async () => {
deferredPrompt.prompt();
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setDeferredPrompt(null);
}
};

const handleDismiss = () => {
setDismissed(true);
try {
window.localStorage.setItem(DISMISSED_STORAGE_KEY, "true");
} catch {
// localStorage may be unavailable; dismissal still applies for this session.
}
};

return (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-xl border border-indigo-200 bg-indigo-50 px-4 py-3 shadow-lg dark:border-indigo-900/50 dark:bg-indigo-950/40">
<Download className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
Expand All @@ -47,7 +69,7 @@ export function InstallPrompt() {
</button>
<button
type="button"
onClick={() => setDismissed(true)}
onClick={handleDismiss}
className="text-indigo-400 transition hover:text-indigo-600"
aria-label="Dismiss install prompt"
>
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/app/components/global_ui/__tests__/InstallPrompt.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { InstallPrompt, DISMISSED_STORAGE_KEY } from "../InstallPrompt";

type Outcome = "accepted" | "dismissed";

interface MockBeforeInstallPromptEvent extends Event {
prompt: jest.Mock<Promise<void>, []>;
userChoice: Promise<{ outcome: Outcome }>;
}

const PROMPT_TEXT = /Install RemitLend for offline access/i;

const createInstallEvent = (outcome: Outcome): MockBeforeInstallPromptEvent => {
const event = new Event("beforeinstallprompt", {
cancelable: true,
}) as MockBeforeInstallPromptEvent;
event.prompt = jest.fn(() => Promise.resolve());
event.userChoice = Promise.resolve({ outcome });
return event;
};

const dispatchBeforeInstallPrompt = (outcome: Outcome): MockBeforeInstallPromptEvent => {
const event = createInstallEvent(outcome);
act(() => {
window.dispatchEvent(event);
});
return event;
};

const installButton = () => screen.getByRole("button", { name: /^install$/i });
const dismissButton = () => screen.getByRole("button", { name: /dismiss install prompt/i });

describe("InstallPrompt", () => {
beforeEach(() => {
window.localStorage.clear();
});

afterEach(() => {
window.localStorage.clear();
jest.restoreAllMocks();
});

it("renders nothing when the browser does not support install prompts", () => {
const { container } = render(<InstallPrompt />);

expect(container).toBeEmptyDOMElement();
expect(screen.queryByText(PROMPT_TEXT)).not.toBeInTheDocument();
});

it("shows the prompt when the browser fires beforeinstallprompt", () => {
render(<InstallPrompt />);

dispatchBeforeInstallPrompt("accepted");

expect(screen.getByText(PROMPT_TEXT)).toBeInTheDocument();
expect(installButton()).toBeInTheDocument();
});

it("prevents the default browser behavior for the install event", () => {
render(<InstallPrompt />);

const event = dispatchBeforeInstallPrompt("accepted");

expect(event.defaultPrevented).toBe(true);
});

it("calls prompt and removes the prompt when the user accepts", async () => {
render(<InstallPrompt />);

const event = dispatchBeforeInstallPrompt("accepted");

await act(async () => {
fireEvent.click(installButton());
});

expect(event.prompt).toHaveBeenCalledTimes(1);
await waitFor(() => expect(screen.queryByText(PROMPT_TEXT)).not.toBeInTheDocument());
});

it("keeps the prompt visible when the user dismisses the native install dialog", async () => {
render(<InstallPrompt />);

const event = dispatchBeforeInstallPrompt("dismissed");

await act(async () => {
fireEvent.click(installButton());
});

expect(event.prompt).toHaveBeenCalledTimes(1);
expect(screen.getByText(PROMPT_TEXT)).toBeInTheDocument();
});

it("hides the prompt when the user clicks the dismiss button", () => {
render(<InstallPrompt />);

dispatchBeforeInstallPrompt("accepted");
fireEvent.click(dismissButton());

expect(screen.queryByText(PROMPT_TEXT)).not.toBeInTheDocument();
});

it("persists the dismissal to localStorage", () => {
render(<InstallPrompt />);

dispatchBeforeInstallPrompt("accepted");
fireEvent.click(dismissButton());

expect(window.localStorage.getItem(DISMISSED_STORAGE_KEY)).toBe("true");
});

it("stays hidden on mount when a dismissal was previously persisted", () => {
window.localStorage.setItem(DISMISSED_STORAGE_KEY, "true");

render(<InstallPrompt />);

dispatchBeforeInstallPrompt("accepted");

expect(screen.queryByText(PROMPT_TEXT)).not.toBeInTheDocument();
});

it("removes the beforeinstallprompt listener on unmount", () => {
const removeSpy = jest.spyOn(window, "removeEventListener");
const { unmount } = render(<InstallPrompt />);

unmount();

expect(removeSpy).toHaveBeenCalledWith("beforeinstallprompt", expect.anything());
});
});
Loading