Files
openchamber/packages/ui/src/components/update/openCodeUpdateDedup.ts
T
Roberto Bertó 46bef9b0c7 Fix/dismissible infinite toasts (#1319)
* fix: make PWA install and OpenCode update toasts dismissible

Both 'Install OpenChamber' and 'OpenCode update available' toasts use
duration: Infinity with no close affordance, so they persist on screen
until the user accepts (install/update) or reloads the tab. For users
who do not want to install the PWA or upgrade right now, this is
intrusive and there is no opt-out.

Add a Dismiss button (sonner cancel action) to both toasts. When the
user dismisses:

- PWA: persist a flag in localStorage so the prompt does not reappear
  on future sessions. Accepting Install still works as before.
- OpenCode update: persist the dismissed version in localStorage. The
  toast will appear again only when a newer version becomes available.

Adds new i18n keys pwa.installPrompt.dismiss and
opencodeUpdate.toast.actions.dismiss across all seven locales (en,
es, ko, pl, pt-BR, uk, zh-CN).

* test: extract toast dedup helpers and cover with 28 unit tests

Lift the dismissal-decision logic out of usePwaInstallPrompt and
OpenCodeUpdateToast into a React-free sibling module so it can be
unit-tested directly. The React surfaces remain sole owners of side
effects (storage writes, toast.info, event listeners); the new module
only answers 'should we show?'.

New module openCodeUpdateDedup.ts exposes four helpers:
- shouldShowPwaInstallToast(input) - three gates: dismissed,
  sessionShown, hasActiveToast.
- shouldShowOpenCodeUpdateToast(input) - empty version, seen set,
  dismissed===version gates; a different dismissed version lets the
  toast resurface for the new release.
- resolveOpenCodeUpdateVersion(detail) - parses CustomEvent payloads
  defensively (null/non-object/non-string -> '').
- resolveOpenCodeUpgradeStatusVersion(status) - parses upgrade status
  payloads (status falsy / available!==true / latestVersion non-string
  -> '').

Consumers now call the helpers and only run the side-effect when the
decision is true. Behaviour is unchanged.

Coverage: 28 tests via bun:test, 33 expects, all pass first try.
2026-05-19 16:57:23 +03:00

97 lines
3.8 KiB
TypeScript

/**
* Pure decision helpers for the OpenCode update toast and PWA install toast.
*
* Extracted from `OpenCodeUpdateToast.tsx` and `usePwaInstallPrompt.ts` so the
* dedup decisions can be unit-tested without a DOM, storage, or React. The
* React surfaces remain the sole owners of side effects (storage writes,
* `toast.info`, event listeners). This module only answers the question
* "given these inputs, should we show the toast?".
*
* Exposed for unit testing. Not part of the stable consumer surface.
*/
export interface PwaInstallToastDecisionInput {
/** Persistent localStorage entry: `'true'` when the user dismissed once. */
readonly dismissed: string | null;
/** Session-scoped sessionStorage flag set the first time the toast is shown in this tab. */
readonly sessionShown: string | null;
/** Whether the current React effect already holds a toast id. */
readonly hasActiveToast: boolean;
}
/**
* Returns `true` if the PWA install prompt toast should be shown for the
* incoming `beforeinstallprompt` event.
*
* The decision composes three gates (any failure short-circuits):
* 1. Persistent dismissal wins for all future visits.
* 2. Per-tab dedup avoids re-showing inside the same browsing session.
* 3. Re-entrancy guard prevents stacking when the effect already owns one.
*/
export const shouldShowPwaInstallToast = (input: PwaInstallToastDecisionInput): boolean => {
if (input.dismissed === 'true') return false;
if (input.sessionShown === 'true') return false;
if (input.hasActiveToast) return false;
return true;
};
export interface OpenCodeUpdateToastDecisionInput {
/** Version string reported by the server (already trimmed by the caller). */
readonly version: string;
/** Most recent version the user explicitly dismissed, or `null` if none. */
readonly dismissedVersion: string | null;
/** Set of versions already surfaced in this tab session. */
readonly seenVersions: ReadonlySet<string>;
}
/**
* Returns `true` if the OpenCode update toast should be shown for `version`.
*
* Empty/whitespace-only versions short-circuit to `false`. A non-null
* `dismissedVersion` matching the incoming version also short-circuits; a
* different `dismissedVersion` means a newer release has appeared since the
* last dismissal and the toast surfaces again.
*/
export const shouldShowOpenCodeUpdateToast = (
input: OpenCodeUpdateToastDecisionInput,
): boolean => {
if (!input.version) return false;
if (input.seenVersions.has(input.version)) return false;
if (input.dismissedVersion !== null && input.dismissedVersion === input.version) return false;
return true;
};
/**
* Coerces the `detail.version` carried by an `openchamber:opencode-update-available`
* CustomEvent into a trimmed string, or returns `''` when the payload is
* missing or shaped unexpectedly.
*
* Only `string` is accepted; numeric or boolean payloads are rejected because
* downstream callers compare versions by literal equality.
*/
export const resolveOpenCodeUpdateVersion = (detail: unknown): string => {
if (detail === null || typeof detail !== 'object') return '';
const candidate = (detail as { version?: unknown }).version;
if (typeof candidate !== 'string') return '';
return candidate.trim();
};
export interface OpenCodeUpgradeStatusLike {
readonly available?: boolean | null;
readonly latestVersion?: string | null;
}
/**
* Pulls the candidate version out of an `/api/opencode/upgrade-status` JSON
* payload. Returns `''` when the payload is missing the field, has the wrong
* type, or reports `available !== true`.
*/
export const resolveOpenCodeUpgradeStatusVersion = (
status: OpenCodeUpgradeStatusLike | null | undefined,
): string => {
if (!status) return '';
if (status.available !== true) return '';
if (typeof status.latestVersion !== 'string') return '';
return status.latestVersion.trim();
};