diff --git a/src/app/globals.css b/src/app/globals.css
index 97334ee..729073f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -136,6 +136,58 @@
--vx-focus-ring: rgba(15, 168, 113, 0.5);
}
+ /* ── High-contrast theme ─────────────────────────────────────────────────── */
+ /*
+ * WCAG AAA palette: pure black surfaces with pure white text (21:1), and
+ * saturated accents chosen to clear 7:1 against the black background.
+ * Selected only when data-theme="high-contrast" is set on ; works
+ * purely by variable substitution, consistent with light/dark.
+ */
+ html[data-theme="high-contrast"] {
+ color-scheme: dark;
+
+ /* Surfaces */
+ --vx-ink: #000000;
+ --vx-base: #000000;
+ --vx-surface: #0A0A0A;
+ --vx-card: #000000;
+ --vx-border: #FFFFFF;
+ --vx-line: #FFFFFF;
+
+ /* Primary: bright sage (≈15:1 on black) */
+ --vx-sage: #7CFFC4;
+ --vx-sage-dim: #4CEBA8;
+ --vx-sage-bg: rgba(124, 255, 196, 0.18);
+
+ /* Accent: bright lavender (≈9:1 on black) */
+ --vx-lav: #C4B5FD;
+ --vx-lav-dim: #A78BFA;
+ --vx-lav-bg: rgba(196, 181, 253, 0.18);
+
+ /* State */
+ --vx-amber: #FFD166;
+ --vx-rose: #FF8A8A;
+
+ /* Text */
+ --vx-text: #FFFFFF;
+ --vx-muted: #E5E7EB;
+ --vx-dim: #9CA3AF;
+
+ /* Body background */
+ --vx-body-bg: #000000;
+ --vx-body-radial1: transparent;
+ --vx-body-radial2: transparent;
+
+ /* Scrollbar */
+ --vx-scrollbar-thumb: #FFFFFF;
+
+ /* Selection */
+ --vx-selection-bg: #7CFFC4;
+
+ /* Focus ring */
+ --vx-focus-ring: #FFD166;
+ }
+
/* ── Body ────────────────────────────────────────────────────────────────── */
body {
background-color: var(--vx-body-bg);
@@ -251,190 +303,6 @@
.btn-swap {
background: linear-gradient(135deg, var(--vx-sage) 0%, #38BDF8 100%);
color: var(--vx-ink);
- font-weight: 700;
- font-size: 0.9375rem;
- padding: 0.875rem;
- border-radius: 0.75rem;
- width: 100%;
- transition:
- filter 0.15s,
- transform 0.1s;
- border: none;
- cursor: pointer;
-}
+ font-weight
-.btn-swap:hover {
- filter: brightness(1.08);
-}
-.btn-swap:active {
- transform: scale(0.99);
-}
-.btn-swap:disabled {
- background: linear-gradient(135deg, color-mix(in srgb, var(--vx-sage) 25%, transparent) 0%, rgba(56,189,248,0.25) 100%);
- color: color-mix(in srgb, var(--vx-ink) 50%, transparent);
- cursor: not-allowed;
-}
-
-/* ── Light-mode .btn-swap text fix ───────────────────────────────────────── */
-/* On dark: ink is #080C14 (nearly black) — fine on the sage gradient.
- On light: ink is #FFFFFF — also fine, gives white text on the green button. */
-
-/* Flow animation for intent pipeline */
-.intent-flow-line {
- stroke-dasharray: 6 4;
- stroke-dashoffset: 10;
- animation: flow var(--vx-motion-duration) linear infinite;
-}
-
-@keyframes flow {
- to {
- stroke-dashoffset: 0;
- }
-}
-
-/* ── Reduced motion ──────────────────────────────────────────────────────── */
-/*
- * Motion is disabled when either the OS requests it (prefers-reduced-motion)
- * or the user picks "reduce" in SettingsPanel (html[data-motion="reduce"]).
- * Durations collapse to 0.01ms and infinite loops are stopped so no animation
- * keeps running (WCAG 2.2.2). Essential progress indicators stay visible as
- * static marks rather than disappearing.
- */
-@media (prefers-reduced-motion: reduce) {
- :root {
- --vx-motion-duration: 0.01ms;
- --vx-motion-duration-fast: 0.01ms;
- }
-
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- scroll-behavior: auto !important;
- }
-}
-
-html[data-motion="reduce"] {
- --vx-motion-duration: 0.01ms;
- --vx-motion-duration-fast: 0.01ms;
-}
-
-html[data-motion="reduce"] *,
-html[data-motion="reduce"] *::before,
-html[data-motion="reduce"] *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- scroll-behavior: auto !important;
-}
-
-/* ── Print: force the light palette ──────────────────────────────────────── */
-/*
- * Printing on a dark palette wastes ink and is often unreadable. When the
- * user prints, force the light token values regardless of the active theme.
- */
-@media print {
- :root,
- html[data-theme="dark"],
- html[data-theme="light"] {
- color-scheme: light;
-
- --vx-ink: #FFFFFF;
- --vx-base: #FFFFFF;
- --vx-surface: #FFFFFF;
- --vx-card: #FFFFFF;
- --vx-border: rgba(0, 0, 0, 0.15);
- --vx-line: rgba(0, 0, 0, 0.1);
-
- --vx-sage: #0A6849;
- --vx-sage-dim: #0A6849;
- --vx-sage-bg: rgba(15, 168, 113, 0.08);
-
- --vx-lav: #4C1D95;
- --vx-lav-dim: #4C1D95;
- --vx-lav-bg: rgba(124, 58, 237, 0.08);
-
- --vx-amber: #92400E;
- --vx-rose: #991B1B;
-
- --vx-text: #000000;
- --vx-muted: #374151;
- --vx-dim: #9CA3AF;
-
- --vx-body-bg: #FFFFFF;
- --vx-body-radial1: transparent;
- --vx-body-radial2: transparent;
-
- --vx-scrollbar-thumb: rgba(0, 0, 0, 0.2);
- --vx-selection-bg: rgba(15, 168, 113, 0.2);
- --vx-focus-ring: rgba(15, 168, 113, 0.5);
- }
-
- body {
- background-image: none;
- }
-}
-
-/* ── Print (#447) ─────────────────────────────────────────────────────────
- * Printed receipts/records always use a light palette regardless of the
- * user's theme, and drop navigation and interactive chrome.
- */
-@media print {
- @page {
- margin: 16mm;
- }
-
- :root,
- :root[data-theme] {
- --vx-ink: #ffffff;
- --vx-base: #ffffff;
- --vx-surface: #f5f5f5;
- --vx-card: #ffffff;
- --vx-border: rgba(0, 0, 0, 0.2);
- --vx-line: rgba(0, 0, 0, 0.1);
- --vx-text: #111111;
- --vx-muted: #444444;
- --vx-dim: #666666;
- --vx-sage: #1b6b49;
- --vx-body-bg: #ffffff;
- }
-
- body {
- background: #ffffff !important;
- color: #111111 !important;
- print-color-adjust: exact;
- -webkit-print-color-adjust: exact;
- }
-
- nav,
- footer {
- display: none !important;
- }
-
- .card {
- box-shadow: none !important;
- backdrop-filter: none !important;
- }
-
- .receipt dd {
- word-break: break-all;
- }
-
- .receipt dl > div {
- break-inside: avoid;
- }
-
- /*
- * Logical safe-area padding: in RTL the notch/inset sits on the opposite
- * physical edge, so expose a logical-start variant that mirrors correctly.
- */
- .ps-safe {
- padding-inline-start: env(safe-area-inset-left, 0px);
- }
-
- .pe-safe {
- padding-inline-end: env(safe-area-inset-right, 0px);
- }
-}
+/* … truncated 4591 chars — edit only what you need near the top … */
diff --git a/src/components/NotificationSettings.tsx b/src/components/NotificationSettings.tsx
index 1a609d9..4be83c8 100644
--- a/src/components/NotificationSettings.tsx
+++ b/src/components/NotificationSettings.tsx
@@ -1,15 +1,32 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "@/lib/i18n/I18nProvider";
import { NOTIFIABLE_STATUSES, useNotificationPreferences } from "@/store/notificationPreferences";
-import type { NotificationPermissionState } from "@/lib/notifications/dispatcher";
+import {
+ clearNotificationHistory,
+ dispatchNotification,
+ getNotificationHistory,
+ subscribeNotificationHistory,
+ type NotificationPermissionState,
+} from "@/lib/notifications/dispatcher";
const inputClass = "bg-vx-surface border border-vx-border rounded-md px-2 py-1 text-sm text-vx-text";
+const EMPTY_HISTORY: ReturnType = [];
+
+function useNotificationHistory() {
+ return useSyncExternalStore(
+ subscribeNotificationHistory,
+ getNotificationHistory,
+ () => EMPTY_HISTORY,
+ );
+}
+
export function NotificationSettings() {
const { t } = useTranslation();
const prefs = useNotificationPreferences();
+ const history = useNotificationHistory();
const [permission, setPermission] = useState("unsupported");
useEffect(() => {
@@ -27,6 +44,19 @@ export function NotificationSettings() {
prefs.update({ channels: { ...prefs.channels, browser: enabled && permission === "granted" } });
};
+ // Fires a sample notification through the same dispatch pipeline as a real
+ // update, so users can verify their configured channels (and quiet hours).
+ const sendTestNotification = () => {
+ dispatchNotification({
+ id: `test-${Date.now()}`,
+ status: "pending",
+ title: t("notifications.test.title"),
+ body: t("notifications.test.body"),
+ href: "/",
+ timestamp: Date.now(),
+ });
+ };
+
return (
)}
-
+
+
{t("notifications.channel.sound")}
+
+ {NOTIFIABLE_STATUSES.map((status) => (
+
+ ))}
+
+
+
+
+
+
+
+
{t("notifications.inbox.title")}
+ {history.length > 0 && (
+
+ )}
+
+ {history.length === 0 ? (
+ {t("notifications.inbox.empty")}
+ ) : (
+
+ )}
+
);
}
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index dd0ad04..93f2946 100644
--- a/src/components/SettingsPanel.tsx
+++ b/src/components/SettingsPanel.tsx
@@ -6,7 +6,8 @@ import { useLocale, useSetLocale } from "@/lib/i18n/I18nProvider";
import { useDismissableOverlay } from "@/hooks/useDismissableOverlay";
type MotionPreference = "system" | "reduce" | "allow";
-type ThemePreference = "system" | "light" | "dark";
+type ThemePreference = "system" | "light" | "dark" | "high-contrast";
+type ResolvedTheme = "light" | "dark" | "high-contrast";
const LOCALE_LABELS: Record = {
en: "English",
@@ -16,9 +17,10 @@ const LOCALE_LABELS: Record = {
const STORAGE_KEY = "vortex-motion-preference";
const THEME_STORAGE_KEY = "vortex-theme-preference";
-const THEME_COLORS: Record<"light" | "dark", string> = {
+const THEME_COLORS: Record = {
light: "#f8fafc",
dark: "#0b1120",
+ "high-contrast": "#000000",
};
function applyMotionPreference(preference: MotionPreference) {
@@ -36,15 +38,15 @@ function systemPrefersDark(): boolean {
function resolveTheme(
preference: ThemePreference,
prefersDark: boolean
-): "light" | "dark" {
+): ResolvedTheme {
if (preference === "system") return prefersDark ? "dark" : "light";
return preference;
}
-function applyTheme(theme: "light" | "dark") {
+function applyTheme(theme: ResolvedTheme) {
const root = document.documentElement;
root.dataset["theme"] = theme;
- root.style.colorScheme = theme;
+ root.style.colorScheme = theme === "high-contrast" ? "dark" : theme;
const meta = document.querySelector(
'meta[name="theme-color"]'
);
@@ -79,7 +81,9 @@ export function SettingsPanel() {
useEffect(() => {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
const preference: ThemePreference =
- stored === "light" || stored === "dark" ? stored : "system";
+ stored === "light" || stored === "dark" || stored === "high-contrast"
+ ? stored
+ : "system";
setThemePreference(preference);
applyTheme(resolveTheme(preference, systemPrefersDark()));
}, []);
@@ -229,6 +233,12 @@ export function SettingsPanel() {
+
diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts
index a634e01..7822868 100644
--- a/src/lib/notifications/dispatcher.ts
+++ b/src/lib/notifications/dispatcher.ts
@@ -1,65 +1,152 @@
-import type { FeedItem } from "@/lib/types";
-import type { NotifiableStatus, NotificationPreferences } from "@/store/notificationPreferences";
+import { secureLogger } from "@/lib/secureLogging";
+import { loadJSON, saveJSON } from "@/lib/persist";
+import type { NotificationPreferences } from "@/store/notificationPreferences";
-export type NotificationPermissionState = NotificationPermission | "unsupported";
+export type NotificationPermissionState = "default" | "granted" | "denied" | "unsupported";
-export type DispatchContext = {
- prefs: NotificationPreferences;
- status: FeedItem["status"];
- visible: boolean;
- isLeader: boolean;
- permission: NotificationPermissionState;
- now: Date;
-};
+export interface NotificationIntent {
+ status: string;
+ title: string;
+ body: string;
+ href: string;
+}
+
+export interface NotificationHistoryEntry extends NotificationIntent {
+ id: string;
+ timestamp: number;
+}
+
+export const NOTIFICATION_HISTORY_CAP = 50;
+const HISTORY_STORAGE_KEY = "vx.notifications.history";
+
+let history: NotificationHistoryEntry[] = [];
+let historyLoaded = false;
+const historyListeners = new Set<() => void>();
+
+function ensureHistoryLoaded(): void {
+ if (historyLoaded) return;
+ historyLoaded = true;
+ const stored = loadJSON(HISTORY_STORAGE_KEY, []);
+ history = Array.isArray(stored) ? stored.slice(0, NOTIFICATION_HISTORY_CAP) : [];
+}
+
+function persistHistory(): void {
+ saveJSON(HISTORY_STORAGE_KEY, history);
+}
+
+function emitHistoryChange(): void {
+ for (const listener of historyListeners) listener();
+}
+
+export function getNotificationHistory(): NotificationHistoryEntry[] {
+ ensureHistoryLoaded();
+ return history;
+}
-export type ChannelDecision = { toast: boolean; browser: boolean; sound: boolean };
+export function subscribeNotificationHistory(listener: () => void): () => void {
+ historyListeners.add(listener);
+ return () => {
+ historyListeners.delete(listener);
+ };
+}
-const NONE: ChannelDecision = { toast: false, browser: false, sound: false };
+export function clearNotificationHistory(): void {
+ ensureHistoryLoaded();
+ history = [];
+ persistHistory();
+ emitHistoryChange();
+}
-function toMinutes(hhmm: string): number | null {
- const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
- return m ? Number(m[1]) * 60 + Number(m[2]) : null;
+function recordHistory(intent: NotificationIntent): void {
+ ensureHistoryLoaded();
+ const entry: NotificationHistoryEntry = {
+ ...intent,
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ timestamp: Date.now(),
+ };
+ // Newest first; evict oldest entries past the cap.
+ history = [entry, ...history].slice(0, NOTIFICATION_HISTORY_CAP);
+ persistHistory();
+ emitHistoryChange();
}
-export function isQuietHours(start: string, end: string, now: Date): boolean {
- const s = toMinutes(start);
- const e = toMinutes(end);
- if (s === null || e === null || s === e) return false;
- const cur = now.getHours() * 60 + now.getMinutes();
- // Ranges may wrap past midnight (e.g. 22:00-07:00).
- return s < e ? cur >= s && cur < e : cur >= s || cur < e;
+export function buildNotificationBody(intent: NotificationIntent, showDetails: boolean): string {
+ return showDetails ? intent.body : intent.title;
}
/**
- * Pure channel decision. Visible tab → in-app toast only (no double alert);
- * hidden tab → browser notification from the leader tab only, when granted.
+ * Resolve whether sound is enabled for a given status. Falls back to the
+ * legacy flat `channels.sound` boolean when no per-status entry exists, so
+ * existing users keep their prior all-on/all-off preference.
*/
-export function decideChannels(ctx: DispatchContext): ChannelDecision {
- const { prefs, status, visible, isLeader, permission, now } = ctx;
- if (status === "pending" || !prefs.statuses[status as NotifiableStatus]) return NONE;
- const quiet = isQuietHours(prefs.quietStart, prefs.quietEnd, now);
+export function isSoundEnabledForStatus(prefs: NotificationPreferences, status: string): boolean {
+ const perStatus = prefs.channels.soundByStatus;
+ if (perStatus && typeof perStatus[status] === "boolean") {
+ return perStatus[status];
+ }
+ return prefs.channels.sound;
+}
+
+export function decideChannels(
+ prefs: NotificationPreferences,
+ intent: NotificationIntent,
+ now: Date = new Date(),
+): { toast: boolean; browser: boolean; sound: boolean } {
+ const statusEnabled = prefs.statuses[intent.status] !== false;
+ const decision = {
+ toast: prefs.channels.toast && statusEnabled,
+ browser: prefs.channels.browser && statusEnabled,
+ sound: isSoundEnabledForStatus(prefs, intent.status) && statusEnabled,
+ };
+
+ const suppressed = isQuietHours(prefs, now) || (!decision.toast && !decision.browser && !decision.sound);
+ if (suppressed) {
+ return { toast: false, browser: false, sound: false };
+ }
+
+ // Record only notifications that are actually dispatched.
+ recordHistory(intent);
+ return decision;
+}
+
+function isQuietHours(prefs: NotificationPreferences, now: Date): boolean {
+ const { quietStart, quietEnd } = prefs;
+ if (!quietStart || !quietEnd || quietStart === quietEnd) return false;
+ const minutes = now.getHours() * 60 + now.getMinutes();
+ const [sh, sm] = quietStart.split(":").map(Number);
+ const [eh, em] = quietEnd.split(":").map(Number);
+ const start = sh * 60 + sm;
+ const end = eh * 60 + em;
+ return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
+}
- if (visible) {
- return { toast: prefs.channels.toast, browser: false, sound: prefs.channels.sound && !quiet };
+export function dispatchNotification(prefs: NotificationPreferences, intent: NotificationIntent): void {
+ const channels = decideChannels(prefs, intent);
+ if (!channels.toast && !channels.browser && !channels.sound) return;
+ const body = buildNotificationBody(intent, prefs.showDetails);
+ if (channels.browser && typeof Notification !== "undefined" && Notification.permission === "granted") {
+ try {
+ new Notification(intent.title, { body });
+ } catch (error) {
+ secureLogger.warn("Failed to show browser notification", error);
+ }
}
- const browser = prefs.channels.browser && permission === "granted" && isLeader && !quiet;
+}
+
+/**
+ * Build a clearly-labeled sample intent used by the "Send test notification"
+ * button. It flows through the same decideChannels/buildNotificationBody
+ * pipeline as a real update, so quiet hours and channel config are respected.
+ */
+export function buildTestNotificationIntent(): NotificationIntent {
return {
- // Toasts still queue in hidden tabs so they're seen on return, unless a
- // browser notification already covered it.
- toast: prefs.channels.toast && !browser,
- browser,
- sound: false,
+ status: "test",
+ title: "Test: Vexa notification",
+ body: "Test: This is a sample notification to verify your notification settings.",
+ href: "/settings",
};
}
-/** Notification copy. Amounts/tokens only appear when the user opted into details. */
-export function buildNotificationBody(items: FeedItem[], showDetails: boolean): { title: string; body: string; href: string } {
- if (items.length > 1) {
- return { title: "Vortex", body: `${items.length} of your intents updated`, href: "/my-intents" };
- }
- const item = items[0] as FeedItem;
- const body = showDetails
- ? `${item.srcAmount} ${item.srcToken} → ${item.dstToken} is now ${item.status}`
- : `Intent ${item.id.slice(0, 8)} is now ${item.status}`;
- return { title: "Vortex", body, href: `/explore/${item.id}` };
+export function sendTestNotification(prefs: NotificationPreferences): void {
+ dispatchNotification(prefs, buildTestNotificationIntent());
}
diff --git a/src/store/notificationPreferences.ts b/src/store/notificationPreferences.ts
index 6029072..fa30e94 100644
--- a/src/store/notificationPreferences.ts
+++ b/src/store/notificationPreferences.ts
@@ -6,7 +6,14 @@ export const NOTIFIABLE_STATUSES: readonly NotifiableStatus[] = ["accepted", "fi
export type NotificationPreferences = {
statuses: Record;
- channels: { toast: boolean; browser: boolean; sound: boolean };
+ channels: {
+ toast: boolean;
+ browser: boolean;
+ /** Global sound toggle; retained as the migration source for per-status sound. */
+ sound: boolean;
+ /** Per-status sound enablement, keyed by NOTIFIABLE_STATUSES. */
+ soundByStatus: Record;
+ };
/** "HH:MM" local time; quiet hours are disabled when either is empty. */
quietStart: string;
quietEnd: string;
@@ -17,7 +24,12 @@ export type NotificationPreferences = {
export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
statuses: { accepted: true, filled: true, failed: true },
- channels: { toast: true, browser: false, sound: false },
+ channels: {
+ toast: true,
+ browser: false,
+ sound: false,
+ soundByStatus: { accepted: false, filled: false, failed: false },
+ },
quietStart: "",
quietEnd: "",
batchWindowMs: 1000,
@@ -25,27 +37,79 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
};
export const NOTIFICATION_PREFS_KEY = "vortex-notification-prefs";
-export const NOTIFICATION_PREFS_VERSION = 1;
+export const NOTIFICATION_PREFS_VERSION = 2;
+
+/** Maximum number of dispatched notifications retained in the local inbox. */
+export const NOTIFICATION_HISTORY_LIMIT = 50;
+
+export type NotificationHistoryEntry = {
+ id: string;
+ status: NotifiableStatus;
+ title: string;
+ body: string;
+ /** Intent link derived from buildNotificationBody, if any. */
+ href?: string;
+ /** Epoch milliseconds when the notification was dispatched. */
+ timestamp: number;
+};
type State = NotificationPreferences & {
+ history: NotificationHistoryEntry[];
update: (patch: Partial) => void;
reset: () => void;
+ recordHistory: (entry: NotificationHistoryEntry) => void;
+ clearHistory: () => void;
};
+/** Build a per-status sound map from a single global boolean. */
+export function soundByStatusFromGlobal(sound: boolean): Record {
+ return { accepted: sound, filled: sound, failed: sound };
+}
+
export const useNotificationPreferences = create()(
persist(
(set) => ({
...DEFAULT_NOTIFICATION_PREFERENCES,
+ history: [],
update: (patch) => set(patch),
reset: () => set(DEFAULT_NOTIFICATION_PREFERENCES),
+ recordHistory: (entry) =>
+ set((state) => ({
+ history: [entry, ...state.history].slice(0, NOTIFICATION_HISTORY_LIMIT),
+ })),
+ clearHistory: () => set({ history: [] }),
}),
{
name: NOTIFICATION_PREFS_KEY,
version: NOTIFICATION_PREFS_VERSION,
storage: createJSONStorage(() => localStorage),
- // Unknown/older versions fall back to defaults rather than guessing.
- migrate: () => ({ ...DEFAULT_NOTIFICATION_PREFERENCES }),
- partialize: ({ update: _update, reset: _reset, ...prefs }) => prefs,
+ // Migrate older persisted shapes. v1 had a flat channels.sound boolean;
+ // apply it to every status so existing all-on/all-off preferences are preserved.
+ migrate: (persisted, version) => {
+ const base = { ...DEFAULT_NOTIFICATION_PREFERENCES, history: [] };
+ if (!persisted || typeof persisted !== "object") return base;
+ const prev = persisted as Partial & {
+ channels?: Partial;
+ };
+ const prevChannels = prev.channels ?? {};
+ const globalSound = typeof prevChannels.sound === "boolean" ? prevChannels.sound : base.channels.sound;
+ const soundByStatus =
+ version >= 2 && prevChannels.soundByStatus
+ ? { ...soundByStatusFromGlobal(globalSound), ...prevChannels.soundByStatus }
+ : soundByStatusFromGlobal(globalSound);
+ return {
+ ...base,
+ ...prev,
+ statuses: { ...base.statuses, ...prev.statuses },
+ channels: {
+ ...base.channels,
+ ...prevChannels,
+ sound: globalSound,
+ soundByStatus,
+ },
+ };
+ },
+ partialize: ({ update: _update, reset: _reset, recordHistory: _record, clearHistory: _clear, ...prefs }) => prefs,
},
),
);