Skip to content
Closed
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
172 changes: 172 additions & 0 deletions web/src/lib/swipeActionPreferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_SWIPE_ACTIONS,
isSwipeAction,
normalizeSwipeActions,
readSwipeActions,
useSwipeActions,
writeSwipeActions,
} from "./swipeActionPreferences";

const STORAGE_KEY = "omnigent:swipe-actions";

afterEach(() => {
cleanup();
localStorage.clear();
});

describe("swipeActionPreferences — read/write", () => {
it("returns the defaults when nothing is stored", () => {
expect(readSwipeActions()).toEqual(DEFAULT_SWIPE_ACTIONS);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});

it("round-trips a written preference", () => {
writeSwipeActions({ left: "delete", right: "archive" });
expect(readSwipeActions()).toEqual({ left: "delete", right: "archive" });
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
left: "delete",
right: "archive",
});
});

it("allows both directions to map to the same action", () => {
writeSwipeActions({ left: "archive", right: "archive" });
expect(readSwipeActions()).toEqual({ left: "archive", right: "archive" });
});

it("persists a 'none' direction", () => {
writeSwipeActions({ left: "none", right: "delete" });
expect(readSwipeActions()).toEqual({ left: "none", right: "delete" });
});
});

describe("isSwipeAction", () => {
it("accepts the known actions and rejects everything else", () => {
expect(isSwipeAction("archive")).toBe(true);
expect(isSwipeAction("delete")).toBe(true);
expect(isSwipeAction("none")).toBe(true);
expect(isSwipeAction("bogus")).toBe(false);
expect(isSwipeAction(null)).toBe(false);
expect(isSwipeAction(undefined)).toBe(false);
expect(isSwipeAction(42)).toBe(false);
});
});

describe("normalizeSwipeActions", () => {
it("passes through a valid object", () => {
expect(normalizeSwipeActions({ left: "delete", right: "none" })).toEqual({
left: "delete",
right: "none",
});
});

it("fills absent directions from the defaults", () => {
expect(normalizeSwipeActions({ left: "delete" })).toEqual({
left: "delete",
right: DEFAULT_SWIPE_ACTIONS.right,
});
});

it("makes a present but unrecognized action inert", () => {
expect(normalizeSwipeActions({ left: "trash", right: "archive" })).toEqual({
left: "none",
right: "archive",
});
});

it("maps null, arrays, and primitives to the defaults", () => {
expect(normalizeSwipeActions(null)).toEqual(DEFAULT_SWIPE_ACTIONS);
expect(normalizeSwipeActions("nope")).toEqual(DEFAULT_SWIPE_ACTIONS);
expect(normalizeSwipeActions(123)).toEqual(DEFAULT_SWIPE_ACTIONS);
expect(normalizeSwipeActions([])).toEqual(DEFAULT_SWIPE_ACTIONS);
});
});

describe("readSwipeActions — corrupt storage", () => {
it("returns the safe defaults when localStorage access throws", () => {
const descriptor = Object.getOwnPropertyDescriptor(window, "localStorage")!;
Object.defineProperty(window, "localStorage", {
configurable: true,
get: () => {
throw new DOMException("Storage is unavailable", "SecurityError");
},
});

try {
expect(readSwipeActions()).toEqual(DEFAULT_SWIPE_ACTIONS);
} finally {
Object.defineProperty(window, "localStorage", descriptor);
}
});

it("falls back to the defaults on unparseable JSON", () => {
localStorage.setItem(STORAGE_KEY, "{not json");
expect(readSwipeActions()).toEqual(DEFAULT_SWIPE_ACTIONS);
});

it("normalizes a partially-valid stored object", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: "delete", right: "bogus" }));
expect(readSwipeActions()).toEqual({ left: "delete", right: DEFAULT_SWIPE_ACTIONS.right });
});

it("makes an unrecognized stored action inert", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: "trash", right: "archive" }));
expect(readSwipeActions()).toEqual({ left: "none", right: "archive" });
});
});

describe("useSwipeActions — live updates", () => {
it("starts at the current stored value", () => {
writeSwipeActions({ left: "delete", right: "archive" });
const { result } = renderHook(() => useSwipeActions());
expect(result.current).toEqual({ left: "delete", right: "archive" });
});

it("re-renders on a same-tab write (Settings change updates mounted rows)", () => {
const { result } = renderHook(() => useSwipeActions());
expect(result.current).toEqual(DEFAULT_SWIPE_ACTIONS);

act(() => {
writeSwipeActions({ left: "delete", right: "delete" });
});
expect(result.current).toEqual({ left: "delete", right: "delete" });
});

it("re-renders on a cross-tab storage event", () => {
const { result } = renderHook(() => useSwipeActions());

// Another tab wrote the new value; simulate the DOM `storage` broadcast.
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: "none", right: "archive" }));
act(() => {
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY }));
});
expect(result.current).toEqual({ left: "none", right: "archive" });
});

it("ignores storage events for unrelated keys", () => {
const { result } = renderHook(() => useSwipeActions());
const before = result.current;

localStorage.setItem("some:other:key", "x");
act(() => {
window.dispatchEvent(new StorageEvent("storage", { key: "some:other:key" }));
});
// Same reference — no spurious re-render/value change.
expect(result.current).toBe(before);
});

it("keeps a stable snapshot reference when the value is unchanged", () => {
writeSwipeActions({ left: "archive", right: "delete" });
const { result } = renderHook(() => useSwipeActions());
const first = result.current;

// A write of the identical value still pings subscribers; the value must
// stay referentially stable so consumers don't needlessly re-run effects.
act(() => {
writeSwipeActions({ left: "archive", right: "delete" });
});
expect(result.current).toBe(first);
});
});
148 changes: 148 additions & 0 deletions web/src/lib/swipeActionPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Persisted, per-device preference for what a horizontal swipe on a session
// row does on touch devices. Two independent directions — swipe-left and
// swipe-right — each map to an action. Modeled after the other `*Preferences`
// helpers: a localStorage-backed value, SSR-safe reads, and writes that swallow
// quota/access errors so a corrupt entry can't break app boot.
//
// Unlike the simpler helpers this one also exposes a live subscription
// (useSwipeActions): Settings and every mounted session row read one source, so
// changing the preference updates open rows in the same session without a
// reload. writeSwipeActions notifies same-tab subscribers; a `storage` listener
// covers other tabs.
//
// Defaults mirror a phone mail app: swipe-left archives (the common tidy-away
// gesture), swipe-right is inert. Both directions may map to the SAME action —
// nothing prevents e.g. archiving on either side.

import { useSyncExternalStore } from "react";

const STORAGE_KEY = "omnigent:swipe-actions";
// Same-tab change signal. The DOM `storage` event only fires in OTHER tabs, so
// writeSwipeActions dispatches this to refresh subscribers in the writing tab.
const SWIPE_ACTIONS_EVENT = "omnigent:swipe-actions-changed";

export const swipeActions = ["archive", "delete", "none"] as const;
export type SwipeAction = (typeof swipeActions)[number];

export type SwipeDirection = "left" | "right";

export interface SwipeActionPreferences {
left: SwipeAction;
right: SwipeAction;
}

/** Default: swipe-left archives, swipe-right does nothing. */
export const DEFAULT_SWIPE_ACTIONS: SwipeActionPreferences = {
left: "archive",
right: "none",
};

/** Return whether a string is one of the selectable swipe actions. */
export function isSwipeAction(value: unknown): value is SwipeAction {
return value === "archive" || value === "delete" || value === "none";
}

/**
* Normalize an arbitrary parsed value to a valid preferences object. Missing
* directions use the defaults; present but unknown actions become inert.
* Used both on read (localStorage drift / manual edits) and to sanitize writes.
*/
export function normalizeSwipeActions(value: unknown): SwipeActionPreferences {
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
// A direction set to something unrecognized goes inert rather than inheriting
// the default, which would silently arm archive on a swipe meant to be safe.
function actionFor(direction: SwipeDirection): SwipeAction {
const action = obj[direction];
if (isSwipeAction(action)) return action;
return Object.hasOwn(obj, direction) ? "none" : DEFAULT_SWIPE_ACTIONS[direction];
}
return { left: actionFor("left"), right: actionFor("right") };
}

/**
* Read the persisted swipe actions. Returns the defaults when nothing is
* stored, on a server render (no `window`), or when the stored value is
* missing/malformed — never throws, so a corrupt entry can't break app boot.
*/
export function readSwipeActions(): SwipeActionPreferences {
if (typeof window === "undefined") return { ...DEFAULT_SWIPE_ACTIONS };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SWIPE_ACTIONS };
return normalizeSwipeActions(JSON.parse(raw));
} catch {
return { ...DEFAULT_SWIPE_ACTIONS };
}
}

/**
* Persist the swipe actions. Normalizes first so only valid values land in
* storage. Swallows quota/access errors so a failed write can't break the app.
*/
export function writeSwipeActions(value: SwipeActionPreferences): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeSwipeActions(value)));
} catch {
// localStorage quota or access errors shouldn't break the app.
}
// Refresh same-tab subscribers (the `storage` event only fires in other tabs).
window.dispatchEvent(new Event(SWIPE_ACTIONS_EVENT));
}

// Cached snapshot so useSyncExternalStore's getSnapshot is a cheap identity
// read on every render — a fresh object every call would loop it, and a
// re-parse every call would tax every row render. Refreshed on the change
// events and when a subscriber attaches (covering writes that happened while
// nothing was mounted); swapped only on a real change so the reference is
// stable between changes.
let snapshot: SwipeActionPreferences = readSwipeActions();

function refreshSnapshot(): void {
const next = readSwipeActions();
if (next.left !== snapshot.left || next.right !== snapshot.right) snapshot = next;
}

function getSnapshot(): SwipeActionPreferences {
return snapshot;
}

// One module-level window listener pair fanning out to a shared set (the
// useMediaQuery shape): N mounted rows cost one registration, not 2N.
const listeners = new Set<() => void>();

function handleChange(e: Event): void {
// Ignore unrelated `storage` events; refresh on our key or the same-tab ping.
if (e instanceof StorageEvent && e.key !== null && e.key !== STORAGE_KEY) return;
refreshSnapshot();
for (const listener of listeners) listener();
}

function subscribe(onChange: () => void): () => void {
if (typeof window === "undefined") return () => {};
if (listeners.size === 0) {
window.addEventListener("storage", handleChange);
window.addEventListener(SWIPE_ACTIONS_EVENT, handleChange);
// Catch up on writes made while no subscriber was mounted (React re-checks
// the snapshot right after subscribing, so a change here still renders).
refreshSnapshot();
}
listeners.add(onChange);
return () => {
listeners.delete(onChange);
if (listeners.size === 0) {
window.removeEventListener("storage", handleChange);
window.removeEventListener(SWIPE_ACTIONS_EVENT, handleChange);
}
};
}

/**
* Subscribe to the live swipe-action preference. Returns the current value and
* re-renders on same-tab writes (via writeSwipeActions) and cross-tab `storage`
* events, so Settings and every mounted session row share one source of truth.
* SSR-safe: renders the defaults on the server.
*/
export function useSwipeActions(): SwipeActionPreferences {
return useSyncExternalStore(subscribe, getSnapshot, () => DEFAULT_SWIPE_ACTIONS);
}
Loading
Loading