authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-13 19:29:36+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 13:29:36-03:00
log42b6bcc6f59bd0369377746b0f2ef370c275b624
tree485814a29cb5f2bcc5cb0b57f2817928d7d07bcb
parentca2cc1339bbd1904b932c3a92d23a2c0ffe1d267
signature Signed by PGP key B5690EEEBB952194

feat(web): add opt-in in-app thread notifications (#11570)


8 files changed, 367 insertions(+), 20 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";...@@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";
19const clientSettings: ClientSettings = {19const clientSettings: ClientSettings = {
20 ...DEFAULT_CLIENT_SETTINGS,20 ...DEFAULT_CLIENT_SETTINGS,
21 notificationMode: "notifications-and-sound",21 notificationMode: "notifications-and-sound",
22 inAppNotificationsEnabled: true,
22 appearanceContrast: 100,23 appearanceContrast: 100,
23 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },24 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
24 browserDefaultZoomFactor: 1.25,25 browserDefaultZoomFactor: 1.25,
apps/web/src/components/ThreadNotificationCoordinator.test.tsx created+249
...@@ -0,0 +1,249 @@
1import type { ClientSettings } from "@t3tools/contracts/settings";
2import * as Option from "effect/Option";
3import { act } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 mode: "off" as ClientSettings["notificationMode"],
9 inApp: true,
10 active: { environmentId: "env-1", threadId: "other-thread" },
11 focused: true,
12 visible: "visible",
13 live: true,
14 completedAt: null as string | null,
15 archivedAt: null as string | null,
16 input: false,
17 approval: false,
18 sessionError: false,
19 turnError: false,
20 add: vi.fn(
21 (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
22 "toast-1",
23 ),
24 close: vi.fn(),
25 navigate: vi.fn(),
26 sound: vi.fn(),
27 notification: vi.fn(function () {}),
28}));
29
30vi.mock("@effect/atom-react", () => ({
31 useAtomValue: () => ({
32 status: state.live ? "live" : "disconnected",
33 snapshot: Option.some({
34 threads: [
35 {
36 id: "thread-1",
37 title: "Fix the login form",
38 archivedAt: state.archivedAt,
39 hasPendingUserInput: state.input,
40 hasPendingApprovals: state.approval,
41 session: state.sessionError ? { status: "error" } : null,
42 latestTurn: {
43 turnId: "turn-1",
44 state: state.turnError ? "error" : state.completedAt ? "completed" : "running",
45 completedAt: state.completedAt,
46 },
47 },
48 ],
49 }),
50 }),
51}));
52vi.mock("@tanstack/react-router", () => ({
53 useNavigate: () => state.navigate,
54 useParams: () => state.active,
55}));
56vi.mock("../hooks/useSettings", () => ({
57 useClientSettings: (
58 select: (
59 settings: Pick<ClientSettings, "notificationMode" | "inAppNotificationsEnabled">,
60 ) => unknown,
61 ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }),
62 getClientSettings: () => ({ notificationMode: state.mode }),
63}));
64vi.mock("../state/environments", () => ({
65 useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }),
66}));
67vi.mock("../state/shell", () => ({
68 environmentShell: { stateValueAtom: vi.fn() },
69}));
70vi.mock("../threadNotifications", async (importOriginal) => ({
71 ...(await importOriginal<typeof import("../threadNotifications")>()),
72 playNotificationSound: state.sound,
73}));
74vi.mock("./ui/toast", () => ({
75 toastManager: { add: state.add, close: state.close },
76}));
77
78import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator";
79
80let renderer: ReactTestRenderer | undefined;
81
82async function render() {
83 await act(() => {
84 if (renderer) renderer.update(<ThreadNotificationCoordinator />);
85 else renderer = create(<ThreadNotificationCoordinator />);
86 });
87}
88
89async function complete() {
90 state.completedAt = "2026-09-13T10:00:00.000Z";
91 await render();
92}
93
94beforeEach(() => {
95 vi.clearAllMocks();
96 Object.assign(state, {
97 mode: "off",
98 inApp: true,
99 active: { environmentId: "env-1", threadId: "other-thread" },
100 focused: true,
101 visible: "visible",
102 live: true,
103 completedAt: null,
104 archivedAt: null,
105 input: false,
106 approval: false,
107 sessionError: false,
108 turnError: false,
109 });
110 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
111 vi.stubGlobal("document", {
112 get visibilityState() {
113 return state.visible;
114 },
115 hasFocus: () => state.focused,
116 addEventListener: vi.fn(),
117 removeEventListener: vi.fn(),
118 });
119 vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" }));
120});
121
122afterEach(async () => {
123 await act(() => renderer?.unmount());
124 renderer = undefined;
125 vi.unstubAllGlobals();
126});
127
128describe("thread notifications", () => {
129 it("alerts once with system alerts off and opens the completed thread", async () => {
130 await render();
131 await complete();
132 await render();
133 expect(state.add).toHaveBeenCalledTimes(1);
134 const toast = state.add.mock.calls[0]?.[0];
135 expect(toast?.title).toBe("Thread completed");
136 expect(toast?.description).toBe("Fix the login form");
137 toast?.actionProps.onClick();
138 expect(state.close).toHaveBeenCalledWith("toast-1");
139 expect(state.navigate).toHaveBeenCalledWith({
140 to: "/$environmentId/$threadId",
141 params: { environmentId: "env-1", threadId: "thread-1" },
142 });
143 expect(state.notification).not.toHaveBeenCalled();
144 });
145
146 it.each(["active", "blurred", "hidden", "archived", "disabled"])(
147 "does not show a completion toast for %s threads",
148 async (condition) => {
149 await render();
150 if (condition === "active") state.active.threadId = "thread-1";
151 if (condition === "blurred") state.focused = false;
152 if (condition === "hidden") state.visible = "hidden";
153 if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z";
154 if (condition === "disabled") state.inApp = false;
155 await complete();
156 expect(state.add).not.toHaveBeenCalled();
157 },
158 );
159
160 it.each([
161 ["input", "Input needed"],
162 ["approval", "Approval needed"],
163 ["sessionError", "Thread failed"],
164 ["turnError", "Thread failed"],
165 ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => {
166 state.mode = "notifications-and-sound";
167 await render();
168 state[event] = true;
169 await render();
170 await render();
171 expect(state.add).toHaveBeenCalledTimes(1);
172 expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title }));
173 expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function));
174 expect(state.notification).not.toHaveBeenCalled();
175
176 state[event] = false;
177 await render();
178 state.focused = false;
179 state[event] = true;
180 await render();
181 await render();
182 expect(state.add).toHaveBeenCalledTimes(1);
183 expect(state.notification).toHaveBeenCalledTimes(1);
184 expect(state.notification).toHaveBeenCalledWith(title, {
185 body: "Fix the login form",
186 tag: "env-1:thread-1",
187 silent: true,
188 });
189 });
190
191 it("keeps desktop alerts when in-app notifications are disabled", async () => {
192 state.inApp = false;
193 state.mode = "notifications";
194 await render();
195 await complete();
196 expect(state.add).not.toHaveBeenCalled();
197 expect(state.notification).toHaveBeenCalledTimes(1);
198 state.inApp = true;
199 await render();
200 expect(state.add).not.toHaveBeenCalled();
201 });
202
203 it("does not replay a completion when opting in from all alerts off", async () => {
204 state.inApp = false;
205 await render();
206 await complete();
207 state.inApp = true;
208 await render();
209 expect(state.add).not.toHaveBeenCalled();
210 });
211
212 it("compares the environment as well as the thread", async () => {
213 state.active = { environmentId: "env-2", threadId: "thread-1" };
214 await render();
215 await complete();
216 expect(state.add).toHaveBeenCalledTimes(1);
217 });
218
219 it("does not replay completed threads on first load or reconnect", async () => {
220 await complete();
221 state.live = false;
222 await render();
223 state.live = true;
224 await render();
225 expect(state.add).not.toHaveBeenCalled();
226 });
227
228 it("keeps sound but replaces the system popup when showing a toast", async () => {
229 state.mode = "notifications-and-sound";
230 await render();
231 await complete();
232 expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function));
233 expect(state.add).toHaveBeenCalledTimes(1);
234 expect(state.notification).not.toHaveBeenCalled();
235 });
236
237 it("keeps system alerts when the app is in the background", async () => {
238 state.mode = "notifications";
239 state.focused = false;
240 await render();
241 await complete();
242 expect(state.add).not.toHaveBeenCalled();
243 expect(state.notification).toHaveBeenCalledWith("Thread completed", {
244 body: "Fix the login form",
245 tag: "env-1:thread-1",
246 silent: true,
247 });
248 });
249});
apps/web/src/components/ThreadNotificationCoordinator.tsx+69-19
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { useEffect, useRef } from "react";5import { useEffect, useRef } from "react";
...@@ -14,10 +14,14 @@ import {...@@ -14,10 +14,14 @@ import {
14 unlockNotificationAudio,14 unlockNotificationAudio,
15} from "../threadNotifications";15} from "../threadNotifications";
16import { resolveSidebarThreadStatus } from "./Sidebar.logic";16import { resolveSidebarThreadStatus } from "./Sidebar.logic";
17import { toastManager } from "./ui/toast";
1718
18export function ThreadNotificationCoordinator() {19export function ThreadNotificationCoordinator() {
19 const { environments } = useEnvironments();20 const { environments } = useEnvironments();
20 const mode = useClientSettings((settings) => settings.notificationMode);21 const mode = useClientSettings((settings) => settings.notificationMode);
22 const inAppNotificationsEnabled = useClientSettings(
23 (settings) => settings.inAppNotificationsEnabled,
24 );
2125
22 useEffect(() => {26 useEffect(() => {
23 if (!hasNotificationSound(mode)) return;27 if (!hasNotificationSound(mode)) return;
...@@ -29,7 +33,7 @@ export function ThreadNotificationCoordinator() {...@@ -29,7 +33,7 @@ export function ThreadNotificationCoordinator() {
29 };33 };
30 }, [mode]);34 }, [mode]);
3135
32 if (mode === "off") return null;36 if (mode === "off" && !inAppNotificationsEnabled) return null;
3337
34 return environments.map((environment) => (38 return environments.map((environment) => (
35 <EnvironmentNotifications39 <EnvironmentNotifications
...@@ -42,20 +46,29 @@ export function ThreadNotificationCoordinator() {...@@ -42,20 +46,29 @@ export function ThreadNotificationCoordinator() {
42function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {46function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {
43 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));47 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
44 const mode = useClientSettings((settings) => settings.notificationMode);48 const mode = useClientSettings((settings) => settings.notificationMode);
49 const inAppNotificationsEnabled = useClientSettings(
50 (settings) => settings.inAppNotificationsEnabled,
51 );
45 const navigate = useNavigate();52 const navigate = useNavigate();
46 const previous = useRef(new Map<ThreadId, { input: string | null; completion: number | null }>());53 const { environmentId: activeEnvironmentId, threadId: activeThreadId } = useParams({
54 strict: false,
55 });
56 const previous = useRef(
57 new Map<ThreadId, { attention: string | null; completion: number | null }>(),
58 );
4759
48 useEffect(() => {60 useEffect(() => {
49 if (shell.status !== "live" || Option.isNone(shell.snapshot)) {61 if (shell.status !== "live" || Option.isNone(shell.snapshot)) {
50 previous.current.clear();62 previous.current.clear();
51 return;63 return;
52 }64 }
53 const next = new Map<ThreadId, { input: string | null; completion: number | null }>();65 const next = new Map<ThreadId, { attention: string | null; completion: number | null }>();
54 for (const thread of shell.snapshot.value.threads) {66 for (const thread of shell.snapshot.value.threads) {
55 const status = resolveSidebarThreadStatus(thread);67 let status = resolveSidebarThreadStatus(thread);
68 if (status === "ready" && thread.latestTurn?.state === "error") status = "failed";
56 const prior = previous.current.get(thread.id);69 const prior = previous.current.get(thread.id);
57 const input =70 const attention =
58 status === "input" || status === "approval"71 status === "input" || status === "approval" || status === "failed"
59 ? `${thread.latestTurn?.turnId ?? ""}:${status}`72 ? `${thread.latestTurn?.turnId ?? ""}:${status}`
60 : null;73 : null;
61 const completedAt = Date.parse(thread.latestTurn?.completedAt ?? "");74 const completedAt = Date.parse(thread.latestTurn?.completedAt ?? "");
...@@ -65,20 +78,52 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen...@@ -65,20 +78,52 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
65 Number.isFinite(completedAt)78 Number.isFinite(completedAt)
66 ? completedAt79 ? completedAt
67 : (prior?.completion ?? null);80 : (prior?.completion ?? null);
68 next.set(thread.id, { input, completion });81 next.set(thread.id, { attention, completion });
69 if (!prior || mode === "off" || thread.archivedAt !== null) continue;82 if (!prior || thread.archivedAt !== null) continue;
70 const kind =83 const kind =
71 input && input !== prior.input84 attention && attention !== prior.attention
72 ? "input"85 ? "input"
73 : completion !== null && (prior.completion === null || completion > prior.completion)86 : completion !== null && (prior.completion === null || completion > prior.completion)
74 ? "completion"87 ? "completion"
75 : null;88 : null;
76 if (!kind) continue;89 if (!kind) continue;
90 const title =
91 kind === "completion"
92 ? "Thread completed"
93 : status === "approval"
94 ? "Approval needed"
95 : status === "failed"
96 ? "Thread failed"
97 : "Input needed";
77 if (hasNotificationSound(mode)) {98 if (hasNotificationSound(mode)) {
78 void playNotificationSound(kind, () =>99 void playNotificationSound(kind, () =>
79 hasNotificationSound(getClientSettings().notificationMode),100 hasNotificationSound(getClientSettings().notificationMode),
80 );101 );
81 }102 }
103 if (
104 inAppNotificationsEnabled &&
105 document.visibilityState === "visible" &&
106 document.hasFocus() &&
107 (activeEnvironmentId !== environmentId || activeThreadId !== thread.id)
108 ) {
109 const toastId = toastManager.add({
110 type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning",
111 title,
112 description: thread.title,
113 data: { hideCopyButton: true },
114 actionProps: {
115 children: "Open thread",
116 onClick: () => {
117 toastManager.close(toastId);
118 void navigate({
119 to: "/$environmentId/$threadId",
120 params: { environmentId, threadId: thread.id },
121 });
122 },
123 },
124 });
125 continue;
126 }
82 if (127 if (
83 !hasDesktopNotifications(mode) ||128 !hasDesktopNotifications(mode) ||
84 typeof Notification === "undefined" ||129 typeof Notification === "undefined" ||
...@@ -86,14 +131,11 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen...@@ -86,14 +131,11 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
86 )131 )
87 continue;132 continue;
88 try {133 try {
89 const notification = new Notification(134 const notification = new Notification(title, {
90 kind === "completion"135 body: thread.title,
91 ? "Thread completed"136 tag: `${environmentId}:${thread.id}`,
92 : status === "approval"137 silent: true,
93 ? "Approval needed"138 });
94 : "Input needed",
95 { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true },
96 );
97 notification.addEventListener("click", () => {139 notification.addEventListener("click", () => {
98 notification.close();140 notification.close();
99 window.focus();141 window.focus();
...@@ -107,7 +149,15 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen...@@ -107,7 +149,15 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
107 }149 }
108 }150 }
109 previous.current = next;151 previous.current = next;
110 }, [environmentId, mode, navigate, shell]);152 }, [
153 activeEnvironmentId,
154 activeThreadId,
155 environmentId,
156 inAppNotificationsEnabled,
157 mode,
158 navigate,
159 shell,
160 ]);
111161
112 return null;162 return null;
113}163}
apps/web/src/components/settings/NotificationSettings.tsx+1-1
...@@ -22,7 +22,7 @@ export function NotificationSettings() {...@@ -22,7 +22,7 @@ export function NotificationSettings() {
22 {...searchableSetting("thread-notifications")}22 {...searchableSetting("thread-notifications")}
23 description={23 description={
24 permissionMessage ??24 permissionMessage ??
25 "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open."25 "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while T3 Code is open."
26 }26 }
27 control={27 control={
28 <Select28 <Select
apps/web/src/components/settings/SettingsPanels.tsx+16
...@@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -525,6 +525,9 @@ export function useSettingsRestore(onRestored?: () => void) {
525 ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode525 ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
526 ? ["Thread notifications"]526 ? ["Thread notifications"]
527 : []),527 : []),
528 ...(settings.inAppNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled
529 ? ["In-app notifications"]
530 : []),
528 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount531 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
529 ? ["Visible threads"]532 ? ["Visible threads"]
530 : []),533 : []),
...@@ -646,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -646,6 +649,7 @@ export function useSettingsRestore(onRestored?: () => void) {
646 settings.showSkillsInSlashMenu,649 settings.showSkillsInSlashMenu,
647 settings.timestampFormat,650 settings.timestampFormat,
648 settings.notificationMode,651 settings.notificationMode,
652 settings.inAppNotificationsEnabled,
649 settings.wordWrap,653 settings.wordWrap,
650 followSystem,654 followSystem,
651 theme,655 theme,
...@@ -721,6 +725,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -721,6 +725,7 @@ export function useSettingsRestore(onRestored?: () => void) {
721 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,725 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
722 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,726 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
723 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,727 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
728 inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
724 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,729 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
725 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,730 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
726 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,731 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
...@@ -2277,6 +2282,17 @@ export function GeneralSettingsPanel() {...@@ -2277,6 +2282,17 @@ export function GeneralSettingsPanel() {
22772282
2278 <SettingsSection id="behavior" title="Behavior">2283 <SettingsSection id="behavior" title="Behavior">
2279 <NotificationSettings />2284 <NotificationSettings />
2285 <SettingsRow
2286 {...searchableSetting("in-app-notifications")}
2287 description="Show a toast when another thread finishes, fails, or needs input or approval while this app has focus."
2288 control={
2289 <Switch
2290 checked={settings.inAppNotificationsEnabled}
2291 onCheckedChange={(checked) => updateSettings({ inAppNotificationsEnabled: checked })}
2292 aria-label="In-app notifications"
2293 />
2294 }
2295 />
2280 <SettingsRow2296 <SettingsRow
2281 {...searchableSetting("time-format")}2297 {...searchableSetting("time-format")}
2282 description="System default follows your browser or OS clock preference."2298 description="System default follows your browser or OS clock preference."
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -247,6 +247,12 @@ export const SETTINGS_SEARCH_ITEMS = [
247 to: "/settings/general",247 to: "/settings/general",
248 searchTerms: ["notification sound alert completion input approval desktop"],248 searchTerms: ["notification sound alert completion input approval desktop"],
249 },249 },
250 {
251 id: "in-app-notifications",
252 title: "In-app notifications",
253 to: "/settings/general",
254 searchTerms: ["notification toast popup completion input approval failure"],
255 },
250 {256 {
251 id: "time-format",257 id: "time-format",
252 title: "Time format",258 title: "Time format",
packages/contracts/src/settings.test.ts+23
...@@ -167,9 +167,32 @@ describe("ClaudeSettings auto-compaction", () => {...@@ -167,9 +167,32 @@ describe("ClaudeSettings auto-compaction", () => {
167describe("ClientSettings notifications", () => {167describe("ClientSettings notifications", () => {
168 it("requires opt-in when existing settings omit notification preferences", () => {168 it("requires opt-in when existing settings omit notification preferences", () => {
169 expect(decodeClientSettings({}).notificationMode).toBe("off");169 expect(decodeClientSettings({}).notificationMode).toBe("off");
170 expect(decodeClientSettings({}).inAppNotificationsEnabled).toBe(false);
171 expect(decodeClientSettingsPatch({})).not.toHaveProperty("inAppNotificationsEnabled");
170 expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");172 expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
171 });173 });
172174
175 it.each([true, false])(
176 "round-trips in-app notifications set to %s",
177 (inAppNotificationsEnabled) => {
178 const settings = decodeClientSettings({ inAppNotificationsEnabled });
179 expect(encodeClientSettings(settings).inAppNotificationsEnabled).toBe(
180 inAppNotificationsEnabled,
181 );
182 expect(
183 decodeClientSettingsPatch({ inAppNotificationsEnabled }).inAppNotificationsEnabled,
184 ).toBe(inAppNotificationsEnabled);
185 },
186 );
187
188 it.each(["true", 1, null])(
189 "rejects an invalid in-app notification preference %s",
190 (inAppNotificationsEnabled) => {
191 expect(() => decodeClientSettings({ inAppNotificationsEnabled })).toThrow();
192 expect(() => decodeClientSettingsPatch({ inAppNotificationsEnabled })).toThrow();
193 },
194 );
195
173 it.each(["off", "notifications", "sound", "notifications-and-sound"])(196 it.each(["off", "notifications", "sound", "notifications-and-sound"])(
174 "round-trips the %s mode",197 "round-trips the %s mode",
175 (notificationMode) => {198 (notificationMode) => {
packages/contracts/src/settings.ts+2
...@@ -291,6 +291,7 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -291,6 +291,7 @@ export const ClientSettingsSchema = Schema.Struct({
291 notificationMode: NotificationMode.pipe(291 notificationMode: NotificationMode.pipe(
292 Schema.withDecodingDefault(Effect.succeed("off" as const)),292 Schema.withDecodingDefault(Effect.succeed("off" as const)),
293 ),293 ),
294 inAppNotificationsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
294 diffColorScheme: DiffColorScheme.pipe(295 diffColorScheme: DiffColorScheme.pipe(
295 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),296 Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
296 ),297 ),
...@@ -1429,6 +1430,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;...@@ -1429,6 +1430,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
14291430
1430export const ClientSettingsPatch = Schema.Struct({1431export const ClientSettingsPatch = Schema.Struct({
1431 notificationMode: Schema.optionalKey(NotificationMode),1432 notificationMode: Schema.optionalKey(NotificationMode),
1433 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
1432 diffColorScheme: Schema.optionalKey(DiffColorScheme),1434 diffColorScheme: Schema.optionalKey(DiffColorScheme),
1433 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),1435 loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
1434 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),1436 loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),