From 8ddb78425950365f886da259d574cccc9739b777 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Thu, 14 May 2026 10:48:04 +0100 Subject: [PATCH] fix(ui): hide muted finding groups immediately with TTL persistence - Track optimistically-muted check_ids in sessionStorage with 90s TTL - Wire row-action and floating-mute paths through selection context - Hide expanded group when its last unmuted resource is muted - Honor Include muted findings filter to stay in sync with reload --- .../table/column-finding-resources.tsx | 3 +- .../findings/table/data-table-row-actions.tsx | 20 +- .../table/findings-group-table.test.tsx | 195 +++++++++++++++++- .../findings/table/findings-group-table.tsx | 141 +++++++++++-- .../table/findings-selection-context.tsx | 9 +- .../table/inline-resource-container.tsx | 10 +- ui/lib/optimistic-muted-groups.test.ts | 89 ++++++++ ui/lib/optimistic-muted-groups.ts | 127 ++++++++++++ 8 files changed, 556 insertions(+), 38 deletions(-) create mode 100644 ui/lib/optimistic-muted-groups.test.ts create mode 100644 ui/lib/optimistic-muted-groups.ts diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 5789b8a96b..32fd4c400b 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -87,9 +87,10 @@ const ResourceRowActions = ({ row }: { row: Row }) => { }; const handleMuteComplete = () => { + const mutedIds = getDisplayIds(); clearSelection(); setResolvedIds([]); - onMuteComplete?.(); + onMuteComplete?.(mutedIds); }; return ( diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index 4041fe728d..4cfe39b7ac 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -89,11 +89,15 @@ export function DataTableRowActions({ // Get selection context - if there are other selected rows, include them const selectionContext = useContext(FindingsSelectionContext); - const { selectedFindingIds, clearSelection, resolveMuteIds } = - selectionContext || { - selectedFindingIds: [], - clearSelection: () => {}, - }; + const { + selectedFindingIds, + clearSelection, + resolveMuteIds, + onMuteComplete: contextOnMuteComplete, + } = selectionContext || { + selectedFindingIds: [], + clearSelection: () => {}, + }; const [resolvedIds, setResolvedIds] = useState([]); const [isResolving, setIsResolving] = useState(false); @@ -175,8 +179,10 @@ export function DataTableRowActions({ // the wrong findings would appear selected clearSelection(); setResolvedIds([]); - if (onMuteComplete) { - onMuteComplete(getDisplayIds()); + const ids = getDisplayIds(); + const handler = onMuteComplete ?? contextOnMuteComplete; + if (handler) { + handler(ids); return; } diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 38887bf1fb..c72ff9bb69 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -1,8 +1,14 @@ import { render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { type ReactNode, useContext } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { FindingGroupRow } from "@/types"; import { FindingsGroupTable } from "./findings-group-table"; +import { FindingsSelectionContext } from "./findings-selection-context"; + +const STORAGE_KEY = "prowler:optimistic-muted-groups"; vi.mock("next/navigation", () => ({ useRouter: () => ({ @@ -12,12 +18,37 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/components/ui/table", () => ({ - DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => ( -
-
{toolbarRightContent}
- 10 Total Entries -
- ), + DataTable: ({ + data, + toolbarRightContent, + }: { + data?: FindingGroupRow[]; + toolbarRightContent?: ReactNode; + }) => { + const ctx = useContext(FindingsSelectionContext); + const rows = data ?? []; + return ( +
+
{toolbarRightContent}
+ {rows.length} +
    + {rows.map((row) => ( +
  • {row.checkId}
  • + ))} +
+ + + 10 Total Entries +
+ ); + }, })); vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({ @@ -45,10 +76,35 @@ vi.mock("../floating-mute-button", () => ({ FloatingMuteButton: () => null, })); +const buildGroup = ( + checkId: string, + overrides: Partial = {}, +): FindingGroupRow => + ({ + id: checkId, + rowType: "group", + checkId, + checkTitle: `Title ${checkId}`, + severity: "high", + status: "FAIL", + muted: false, + resourcesTotal: 10, + resourcesFail: 5, + mutedCount: 0, + newCount: 0, + changedCount: 0, + providers: [], + updatedAt: "2026-04-01T00:00:00Z", + ...overrides, + }) as FindingGroupRow; + describe("FindingsGroupTable", () => { + afterEach(() => { + sessionStorage.clear(); + }); + describe("toolbar", () => { it("should render the muted findings checkbox inside the table toolbar", () => { - // Given render( { />, ); - // When const toolbar = screen.getByTestId("table-toolbar-right"); - // Then expect( screen.getByRole("checkbox", { name: "Include muted findings" }), ).toBeInTheDocument(); expect(toolbar).toHaveTextContent("Include muted findings"); }); }); + + describe("optimistic mute via context onMuteComplete", () => { + it("hides muted groups from the table immediately and persists them in sessionStorage", async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("3"); + expect(screen.getByText("group-a")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "mute-first-row" })); + + expect(screen.getByTestId("row-count")).toHaveTextContent("2"); + expect(screen.queryByText("group-a")).not.toBeInTheDocument(); + expect(screen.getByText("group-b")).toBeInTheDocument(); + + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}"); + expect(Object.keys(stored)).toContain("group-a"); + }); + + it("keeps muted groups visible when 'Include muted findings' is active (matches post-reload state)", async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("2"); + + await user.click(screen.getByRole("button", { name: "mute-first-row" })); + + expect(screen.getByTestId("row-count")).toHaveTextContent("2"); + expect(screen.getByText("group-a")).toBeInTheDocument(); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("is a no-op when onMuteComplete fires without IDs (resource-level mute)", async () => { + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("2"); + + await user.click( + screen.getByRole("button", { name: "mute-without-ids" }), + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("2"); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("hydrates from sessionStorage on mount", () => { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + "group-a": { expiresAt: Date.now() + 60_000 }, + }), + ); + + render( + , + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("1"); + expect(screen.queryByText("group-a")).not.toBeInTheDocument(); + expect(screen.getByText("group-b")).toBeInTheDocument(); + }); + + it("removes the storage entry once the server payload no longer includes the group", async () => { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + "group-a": { expiresAt: Date.now() + 60_000 }, + }), + ); + + const { rerender } = render( + , + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("1"); + + rerender( + , + ); + + expect(screen.getByTestId("row-count")).toHaveTextContent("1"); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + }); }); diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 8c5e9def0d..4a7dd2b953 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -2,12 +2,18 @@ import { Row, RowSelectionState } from "@tanstack/react-table"; import { useRouter, useSearchParams } from "next/navigation"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings"; import { DataTable } from "@/components/ui/table"; +import { includesMutedFindings } from "@/lib/findings-filters"; import { canDrillDownFindingGroup } from "@/lib/findings-groups"; +import { + loadOptimisticallyMutedCheckIds, + persistOptimisticallyMutedCheckIds, + removePersistedOptimisticEntries, +} from "@/lib/optimistic-muted-groups"; import { FindingGroupRow, MetaDataProps } from "@/types"; import { FloatingMuteButton } from "../floating-mute-button"; @@ -57,21 +63,53 @@ export function FindingsGroupTable({ const [resourceSearchInput, setResourceSearchInput] = useState(""); const [resourceSearch, setResourceSearch] = useState(""); const [resourceSelection, setResourceSelection] = useState([]); + // Group check_ids the user just muted, hidden client-side until the server + // catches up. Hydrated from sessionStorage on mount so a fast reload still + // honours the optimistic hide. See lib/optimistic-muted-groups.ts. + const [optimisticallyMutedCheckIds, setOptimisticallyMutedCheckIds] = + useState>(() => new Set()); const inlineRef = useRef(null); + useEffect(() => { + const persisted = loadOptimisticallyMutedCheckIds(); + if (persisted.size > 0) setOptimisticallyMutedCheckIds(persisted); + }, []); + // State resets (selection, drill-down) are handled by the parent via // key={groupKey} — when data changes, the component remounts with fresh state. - const safeData = data ?? []; + const visibleData = (data ?? []).filter( + (g) => !optimisticallyMutedCheckIds.has(g.checkId), + ); const hasResourceSelection = resourceSelection.length > 0; const filters = resolvedFilters; + // When a previously-hidden group disappears from the server payload, drop + // it from both client state and storage so we don't keep a stale entry. + useEffect(() => { + if (optimisticallyMutedCheckIds.size === 0) return; + const incoming = new Set((data ?? []).map((g) => g.checkId)); + const confirmed: string[] = []; + const stillPending = new Set(); + optimisticallyMutedCheckIds.forEach((id) => { + if (incoming.has(id)) { + stillPending.add(id); + } else { + confirmed.push(id); + } + }); + if (confirmed.length > 0) { + removePersistedOptimisticEntries(confirmed); + setOptimisticallyMutedCheckIds(stillPending); + } + }, [data, optimisticallyMutedCheckIds]); + // Get selected group check IDs. When the expanded group has individual resource // selections, exclude it from group-level mute targets — the resource-level // FloatingMuteButton handles those. const selectedCheckIds = Object.keys(rowSelection) .filter((key) => rowSelection[key]) - .map((idx) => safeData[parseInt(idx)]?.checkId) + .map((idx) => visibleData[parseInt(idx)]?.checkId) .filter(Boolean) .filter( (checkId) => !(hasResourceSelection && checkId === expandedCheckId), @@ -79,11 +117,11 @@ export function FindingsGroupTable({ const selectedFindings = Object.keys(rowSelection) .filter((key) => rowSelection[key]) - .map((idx) => safeData[parseInt(idx)]) + .map((idx) => visibleData[parseInt(idx)]) .filter(Boolean); // Count of selectable rows (groups where not ALL findings are muted) - const selectableRowCount = safeData.filter((g) => + const selectableRowCount = visibleData.filter((g) => canMuteFindingGroup({ resourcesFail: g.resourcesFail, resourcesTotal: g.resourcesTotal, @@ -132,12 +170,12 @@ export function FindingsGroupTable({ const resolveMuteIds = async (checkIds: string[]) => resolveGroupMuteIds(checkIds); - const handleMuteComplete = () => { - clearSelection(); + const handleCollapse = () => { + setExpandedCheckId(null); + setExpandedGroup(null); + setResourceSearchInput(""); + setResourceSearch(""); setResourceSelection([]); - inlineRef.current?.clearSelection(); - inlineRef.current?.refresh(); - router.refresh(); }; const handleDrillDown = (checkId: string, group: FindingGroupRow) => { @@ -156,12 +194,81 @@ export function FindingsGroupTable({ setResourceSelection([]); }; - const handleCollapse = () => { - setExpandedCheckId(null); - setExpandedGroup(null); - setResourceSearchInput(""); - setResourceSearch(""); + const hideMutedGroups = (mutedCheckIds: string[]) => { + if (mutedCheckIds.length === 0) return; + // When the user opted into showing muted findings, the row stays visible + // (with the muted indicator) after reload — don't hide it client-side or + // we'll diverge from the post-reload state. + if (includesMutedFindings(resolvedFilters)) return; + persistOptimisticallyMutedCheckIds(mutedCheckIds); + setOptimisticallyMutedCheckIds((prev) => { + const next = new Set(prev); + mutedCheckIds.forEach((id) => next.add(id)); + return next; + }); + if (expandedCheckId && mutedCheckIds.includes(expandedCheckId)) { + handleCollapse(); + } + }; + + /** + * True when muting `mutedResourceCount` findings is expected to leave the + * given group fully muted on the next read. Conservative: requires that + * every unmuted FAIL is covered AND there are no unmuted PASS/MANUAL + * findings, which matches the API model where `muted=True` only when every + * finding in the group is muted. + */ + const willResourceMuteEmptyGroup = ( + group: FindingGroupRow, + mutedResourceCount: number, + ): boolean => { + const unmutedFail = group.failCount ?? group.resourcesFail; + const unmutedPass = group.passCount ?? 0; + const unmutedManual = group.manualCount ?? 0; + return ( + mutedResourceCount >= unmutedFail && + unmutedPass === 0 && + unmutedManual === 0 + ); + }; + + const handleMuteComplete = () => { + // Snapshot the group selection BEFORE clearing it; the optimistic-hide + // helper needs the IDs that were actually muted as whole groups. + const mutedGroupCheckIds = [...selectedCheckIds]; + // If the FloatingMuteButton flow includes resource-level mutes that fully + // empty the expanded group, hide that group too (the row would have stayed + // visible otherwise until the server caught up). + if ( + expandedGroup && + resourceSelection.length > 0 && + !mutedGroupCheckIds.includes(expandedGroup.checkId) && + willResourceMuteEmptyGroup(expandedGroup, resourceSelection.length) + ) { + mutedGroupCheckIds.push(expandedGroup.checkId); + } + clearSelection(); setResourceSelection([]); + inlineRef.current?.clearSelection(); + inlineRef.current?.refresh(); + hideMutedGroups(mutedGroupCheckIds); + router.refresh(); + }; + + // Triggered by group row-action mutes (single-row dropdown). Receives the + // group check IDs that were sent to the mute API. + const handleRowMuteComplete = (mutedIds?: string[]) => { + hideMutedGroups(mutedIds ?? []); + router.refresh(); + }; + + // Triggered by resource-row mutes inside the drill-down. Hides the + // surrounding group when the mute leaves it fully muted. + const handleResourceMuteFromDrillDown = (mutedResourceCount: number) => { + if (mutedResourceCount <= 0 || !expandedGroup) return; + if (willResourceMuteEmptyGroup(expandedGroup, mutedResourceCount)) { + hideMutedGroups([expandedGroup.checkId]); + } }; const columns = getColumnFindingGroups({ @@ -187,6 +294,7 @@ export function FindingsGroupTable({ resourceSearch={resourceSearch} columnCount={columns.length} onResourceSelectionChange={setResourceSelection} + onResourceMuteCompleted={handleResourceMuteFromDrillDown} /> ); }; @@ -199,11 +307,12 @@ export function FindingsGroupTable({ clearSelection, isSelected, resolveMuteIds, + onMuteComplete: handleRowMuteComplete, }} > boolean; /** Resolves display IDs (check_ids or resource_ids) into real finding UUIDs for the mute API. */ resolveMuteIds?: (ids: string[]) => Promise; - /** Called after a mute operation completes to refresh data. */ - onMuteComplete?: () => void; + /** + * Called after a mute operation completes. Receives the display IDs that + * were just muted (group check_ids for group rows, finding UUIDs for + * resource rows). Parents can use them to hide rows optimistically while + * the server-side reaggregation catches up. + */ + onMuteComplete?: (mutedIds?: string[]) => void; } export const FindingsSelectionContext = diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index 22ccb027e1..39e4ac1b72 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -40,6 +40,10 @@ interface InlineResourceContainerProps { columnCount: number; /** Called with selected finding IDs (real UUIDs) for parent-level mute */ onResourceSelectionChange: (findingIds: string[]) => void; + /** Called after a resource-row mute completes inside the drill-down with + * the count of finding UUIDs that were just muted. The parent uses it to + * decide whether the surrounding finding group should also be hidden. */ + onResourceMuteCompleted?: (mutedCount: number) => void; ref?: React.Ref; } @@ -126,6 +130,7 @@ export function InlineResourceContainer({ resourceSearch, columnCount, onResourceSelectionChange, + onResourceMuteCompleted, ref, }: InlineResourceContainerProps) { const scrollContainerRef = useRef(null); @@ -210,7 +215,10 @@ export function InlineResourceContainer({ clearSelection, isSelected, resolveMuteIds: resolveSelectedFindingIds, - onMuteComplete: handleMuteComplete, + onMuteComplete: (mutedIds?: string[]) => { + handleMuteComplete(); + onResourceMuteCompleted?.(mutedIds?.length ?? 0); + }, }} > diff --git a/ui/lib/optimistic-muted-groups.test.ts b/ui/lib/optimistic-muted-groups.test.ts new file mode 100644 index 0000000000..400a256afb --- /dev/null +++ b/ui/lib/optimistic-muted-groups.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + clearAllOptimisticEntries, + loadOptimisticallyMutedCheckIds, + OPTIMISTIC_MUTED_GROUPS_TTL_MS, + persistOptimisticallyMutedCheckIds, + removePersistedOptimisticEntries, +} from "./optimistic-muted-groups"; + +const STORAGE_KEY = "prowler:optimistic-muted-groups"; + +describe("optimistic-muted-groups", () => { + afterEach(() => { + sessionStorage.clear(); + }); + + it("returns an empty set when storage is empty", () => { + expect(loadOptimisticallyMutedCheckIds()).toEqual(new Set()); + }); + + it("persists ids and reloads them within the TTL window", () => { + persistOptimisticallyMutedCheckIds(["check-a", "check-b"], 1_000); + + expect(loadOptimisticallyMutedCheckIds(1_500)).toEqual( + new Set(["check-a", "check-b"]), + ); + }); + + it("prunes expired entries on load and rewrites storage", () => { + persistOptimisticallyMutedCheckIds(["check-old"], 0); + persistOptimisticallyMutedCheckIds( + ["check-fresh"], + OPTIMISTIC_MUTED_GROUPS_TTL_MS - 1_000, + ); + + const result = loadOptimisticallyMutedCheckIds( + OPTIMISTIC_MUTED_GROUPS_TTL_MS + 100, + ); + + expect(result).toEqual(new Set(["check-fresh"])); + const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}"); + expect(Object.keys(stored)).toEqual(["check-fresh"]); + }); + + it("removes only the listed ids and keeps the rest", () => { + persistOptimisticallyMutedCheckIds(["check-a", "check-b", "check-c"], 0); + + removePersistedOptimisticEntries(["check-b"]); + + expect(loadOptimisticallyMutedCheckIds(1_000)).toEqual( + new Set(["check-a", "check-c"]), + ); + }); + + it("refreshes expiresAt when persisting an existing id", () => { + persistOptimisticallyMutedCheckIds(["check-a"], 0); + const firstStored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}"); + + persistOptimisticallyMutedCheckIds(["check-a"], 5_000); + const secondStored = JSON.parse( + sessionStorage.getItem(STORAGE_KEY) ?? "{}", + ); + + expect(secondStored["check-a"].expiresAt).toBeGreaterThan( + firstStored["check-a"].expiresAt, + ); + }); + + it("removes the storage entry when the map becomes empty", () => { + persistOptimisticallyMutedCheckIds(["check-a"], 0); + expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull(); + + removePersistedOptimisticEntries(["check-a"]); + + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("ignores corrupted storage payloads", () => { + sessionStorage.setItem(STORAGE_KEY, "not-json"); + expect(loadOptimisticallyMutedCheckIds()).toEqual(new Set()); + }); + + it("clearAllOptimisticEntries removes the storage entry", () => { + persistOptimisticallyMutedCheckIds(["check-a"], 0); + clearAllOptimisticEntries(); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/ui/lib/optimistic-muted-groups.ts b/ui/lib/optimistic-muted-groups.ts new file mode 100644 index 0000000000..87cfeec15b --- /dev/null +++ b/ui/lib/optimistic-muted-groups.ts @@ -0,0 +1,127 @@ +/** + * Per-tab persistence for finding groups the user just muted, used by the + * findings table to keep a row hidden across a fast page reload while the + * server-side reaggregation (Celery chain queued by POST /mute-rules) is + * still in flight. + * + * Storage is `sessionStorage` (per-tab; closing the tab wipes it). Cross-tab + * consistency is intentionally out of scope — the worst case is that another + * tab keeps showing the row until the server data refreshes naturally. + */ + +const STORAGE_KEY = "prowler:optimistic-muted-groups"; + +/** Default time-to-live for an optimistic entry. ~90s gives plenty of headroom + * over the typical Celery reaggregation window. */ +export const OPTIMISTIC_MUTED_GROUPS_TTL_MS = 90_000; + +interface StoredEntry { + expiresAt: number; +} + +type StoredMap = Record; + +function safeGetStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.sessionStorage; + } catch { + return null; + } +} + +function readMap(): StoredMap { + const storage = safeGetStorage(); + if (!storage) return {}; + try { + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as StoredMap; + return {}; + } catch { + return {}; + } +} + +function writeMap(map: StoredMap): void { + const storage = safeGetStorage(); + if (!storage) return; + try { + if (Object.keys(map).length === 0) { + storage.removeItem(STORAGE_KEY); + } else { + storage.setItem(STORAGE_KEY, JSON.stringify(map)); + } + } catch { + // Quota exceeded or storage disabled — silently ignore. + } +} + +function pruneExpired( + map: StoredMap, + now: number, +): { + pruned: StoredMap; + changed: boolean; +} { + let changed = false; + const pruned: StoredMap = {}; + for (const [checkId, entry] of Object.entries(map)) { + if (entry?.expiresAt && entry.expiresAt > now) { + pruned[checkId] = entry; + } else { + changed = true; + } + } + return { pruned, changed }; +} + +/** Return the set of currently-active optimistic checkIds. Side effect: + * silently removes expired entries from storage. */ +export function loadOptimisticallyMutedCheckIds( + now: number = Date.now(), +): Set { + const map = readMap(); + const { pruned, changed } = pruneExpired(map, now); + if (changed) writeMap(pruned); + return new Set(Object.keys(pruned)); +} + +/** Mark each checkId as optimistically muted, refreshing its TTL. */ +export function persistOptimisticallyMutedCheckIds( + checkIds: Iterable, + now: number = Date.now(), +): void { + const ids = Array.from(checkIds); + if (ids.length === 0) return; + const { pruned } = pruneExpired(readMap(), now); + const expiresAt = now + OPTIMISTIC_MUTED_GROUPS_TTL_MS; + for (const id of ids) { + pruned[id] = { expiresAt }; + } + writeMap(pruned); +} + +/** Drop the listed checkIds from storage (e.g. after the server payload no + * longer mentions them). No-op for entries that aren't there. */ +export function removePersistedOptimisticEntries( + checkIds: Iterable, +): void { + const ids = Array.from(checkIds); + if (ids.length === 0) return; + const map = readMap(); + let changed = false; + for (const id of ids) { + if (id in map) { + delete map[id]; + changed = true; + } + } + if (changed) writeMap(map); +} + +/** Wipe every optimistic entry. Mainly useful for tests. */ +export function clearAllOptimisticEntries(): void { + writeMap({}); +}