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
5 changes: 3 additions & 2 deletions apps/postcards/src/lib/store/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ function loadAutoGuides(): boolean {
// On first run the map is the bundled offline vector basemap — zero outbound
// requests. Streaming detailed tiles from OpenStreetMap (which discloses your IP
// and the area you're looking at) waits for a deliberate opt-in: the one-tap
// "Show detailed map" consent on the map, or this toggle in Settings. Only "1"
// enables, so a fresh install never reaches the network for a tile.
// "Show detailed map" consent on the map, this toggle in Settings, or Start on the
// welcome page with "Detailed map" left on. Only "1" enables, so a fresh install
// never reaches the network for a tile before one of those.
function loadOnlineMap(): boolean {
return readLocal(ONLINE_MAP_KEY) === "1";
}
Expand Down
53 changes: 47 additions & 6 deletions apps/postcards/src/ui/IntroScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,26 @@ function Switch({
);
}

/** The welcome page has been closed once (App writes this when it closes). */
const INTRO_KEY = "postcards-intro-seen";
function introSeen(): boolean {
try {
return localStorage.getItem(INTRO_KEY) != null;
} catch {
return true;
}
}

/**
* First-run welcome — a full page (not a modal) with a live spinning earth,
* one line about what Postcards is, and a switch per optional feature so a
* newcomer can flip on what they want on the spot. Skippable; shown once.
*
* On a first run, "Detailed map" and "Full city list" start switched on, but
* nothing reaches the network until the newcomer taps Start with them still on:
* that tap, with both switches in view, is the opt-in (Constitution: data leaves
* the device only on explicit user action). Skip applies neither. Opened again
* later, the switches show and change the current settings directly.
*/
export function IntroScreen({ onClose }: { onClose: () => void }) {
const t = useT();
Expand All @@ -54,6 +70,10 @@ export function IntroScreen({ onClose }: { onClose: () => void }) {
const setReduceMapWork = useSettings((s) => s.setReduceMapWork);
const showToast = useToast((s) => s.show);
const startRef = useRef<HTMLButtonElement>(null);
// First run: the two network features wait for Start (see above).
const [firstRun] = useState(() => !introSeen());
const [wantMap, setWantMap] = useState(true);
const [wantCities, setWantCities] = useState(true);

// Detect a phone / slower device (touch + small screen, or touch + few CPU
// cores) so we can recommend the smooth-map settings up front. A laptop/desktop
Expand Down Expand Up @@ -103,6 +123,27 @@ export function IntroScreen({ onClose }: { onClose: () => void }) {
showToast(ok ? t("settings.cities.toast.done") : t("settings.cities.toast.failed"));
}

// Offline mode makes no optional request, so nothing waits for Start there:
// flipping the city list on downloads it at once, as it always has.
const deferred = firstRun && !offlineMode;
const mapOn = deferred ? wantMap : onlineMap;
const citiesOn = cities !== "idle" || (deferred && wantCities);
function toggleMap(on: boolean) {
if (deferred) setWantMap(on);
else setOnlineMap(on);
}
function toggleCities(on: boolean) {
if (deferred) setWantCities(on);
else if (on) void getCities();
}
function start() {
if (deferred) {
setOnlineMap(wantMap);
if (wantCities && cities === "idle") void getCities();
}
onClose();
}

return (
<div className="intro" role="dialog" aria-modal="true" aria-labelledby="intro-title">
<button className="intro-skip" type="button" title={t("intro.skip")} onClick={onClose}>
Expand Down Expand Up @@ -178,7 +219,7 @@ export function IntroScreen({ onClose }: { onClose: () => void }) {
<span className="intro-row-title">🛰️ {t("intro.map.title")}</span>
<span className="intro-row-desc">{t("intro.map.desc")}</span>
</div>
<Switch on={onlineMap} onChange={setOnlineMap} label={t("intro.map.title")} />
<Switch on={mapOn} onChange={toggleMap} label={t("intro.map.title")} />
</li>
)}

Expand All @@ -196,17 +237,17 @@ export function IntroScreen({ onClose }: { onClose: () => void }) {
/>
</li>

{/* Offline city search — flipping it on downloads the full gazetteer
(~17 MB); once saved it stays on. */}
{/* Offline city search — on, it downloads the full gazetteer (~17 MB), on a
first run once Start is tapped; once saved it stays on. */}
<li className="intro-row">
<div className="intro-row-text">
<span className="intro-row-title">🏙️ {t("intro.cities.title")}</span>
<span className="intro-row-desc">{t("intro.cities.desc")}</span>
</div>
<Switch
on={cities !== "idle"}
on={citiesOn}
disabled={cities !== "idle"}
onChange={() => void getCities()}
onChange={toggleCities}
label={t("intro.cities.title")}
/>
</li>
Expand All @@ -219,7 +260,7 @@ export function IntroScreen({ onClose }: { onClose: () => void }) {
className="intro-start"
type="button"
title={t("intro.start")}
onClick={onClose}
onClick={start}
>
{t("intro.start")}
</button>
Expand Down
63 changes: 63 additions & 0 deletions apps/postcards/tests/unit/introDefaults.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";

const download = vi.fn(async () => true);
vi.mock("../../src/lib/reference/referenceData", async (orig) => ({
...(await orig<typeof import("../../src/lib/reference/referenceData")>()),
downloadFullCities: () => download(),
fullCitiesEnabled: () => false,
}));

import { IntroScreen } from "../../src/ui/IntroScreen";
import { useSettings } from "../../src/lib/store/useSettings";

beforeEach(() => {
localStorage.clear();
download.mockClear();
useSettings.getState().setOnlineMap(false);
useSettings.getState().setOfflineMode(false);
});
afterEach(cleanup);

const map = () => screen.getByRole("switch", { name: "Detailed map" }) as HTMLInputElement;
const cities = () => screen.getByRole("switch", { name: "Full city list" }) as HTMLInputElement;

it("starts a first run with the detailed map and full city list switched on", () => {
render(<IntroScreen onClose={() => {}} />);
expect(map().checked).toBe(true);
expect(cities().checked).toBe(true);
// Nothing reaches the network before Start.
expect(useSettings.getState().onlineMap).toBe(false);
expect(download).not.toHaveBeenCalled();
});

it("turns both on when Start is tapped", () => {
const onClose = vi.fn();
render(<IntroScreen onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "Get started" }));
expect(useSettings.getState().onlineMap).toBe(true);
expect(download).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalled();
});

it("leaves off what was switched off, and Skip applies neither", () => {
render(<IntroScreen onClose={() => {}} />);
fireEvent.click(cities());
fireEvent.click(screen.getByRole("button", { name: "Get started" }));
expect(useSettings.getState().onlineMap).toBe(true);
expect(download).not.toHaveBeenCalled();
cleanup();

useSettings.getState().setOnlineMap(false);
render(<IntroScreen onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
expect(useSettings.getState().onlineMap).toBe(false);
expect(download).not.toHaveBeenCalled();
});

it("shows the current settings once the welcome has been seen", () => {
localStorage.setItem("postcards-intro-seen", "1");
render(<IntroScreen onClose={() => {}} />);
expect(map().checked).toBe(false);
expect(cities().checked).toBe(false);
});
Loading