diff --git a/.env.example b/.env.example index e17457821..1a8354876 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ BUZZ_DEV_VIEWER= # Keep the same alias mapped to the same origin to retain existing memberships. # Values are public configuration included in the frontend bundle, never secrets. # BUZZ_COMMUNITY_ALIASES='{"primary":"wss://relay.example.com","secondary":"wss://other.example.com"}' + +# Optional: pause notifications in this checkout without changing saved choices. +# Only 0 pauses them; unset keeps normal notification behavior and OS permissions. +# Restart the dev server after changing this. Production builds ignore it. +# BUZZ_DEV_NOTIFICATIONS=0 diff --git a/dev/vite-config.test.mjs b/dev/vite-config.test.mjs index 898611403..60ae5d35f 100644 --- a/dev/vite-config.test.mjs +++ b/dev/vite-config.test.mjs @@ -13,12 +13,49 @@ it("loads the broker's Vite config without native-compatibility warnings", () => "-e", ` import assert from 'node:assert/strict'; + import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; import { loadConfigFromFile } from 'vite'; - const loaded = await loadConfigFromFile( - { command: 'serve', mode: 'development' }, - ); - assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker')); - assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"'); + const configFile = join(process.cwd(), 'vite.config.ts'); + // Load the real config with isolated .env files, never personal settings. + const directory = mkdtempSync(join(tmpdir(), 'buzz-vite-config-')); + const cwd = process.cwd(); + try { + process.chdir(directory); + const loaded = await loadConfigFromFile( + { command: 'serve', mode: 'development' }, configFile, + ); + assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker')); + assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"'); + for (const command of ['serve', 'build']) { + for (const setting of [undefined, '', '0', '1', 'true', 'invalid']) { + if (setting === undefined) delete process.env.BUZZ_DEV_NOTIFICATIONS; + else process.env.BUZZ_DEV_NOTIFICATIONS = setting; + const result = await loadConfigFromFile( + { command, mode: command === 'serve' ? 'development' : 'production' }, configFile, + ); + assert.equal( + result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'], + JSON.stringify(command === 'serve' && setting === '0' ? '1' : '0'), + ); + } + } + delete process.env.BUZZ_DEV_NOTIFICATIONS; + writeFileSync('.env.local', 'BUZZ_DEV_NOTIFICATIONS=0'); + for (const command of ['serve', 'build']) { + const result = await loadConfigFromFile( + { command, mode: 'development' }, configFile, + ); + assert.equal( + result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'], + JSON.stringify(command === 'serve' ? '1' : '0'), + ); + } + } finally { + process.chdir(cwd); + rmSync(directory, { recursive: true, force: true }); + } `, ], { diff --git a/docs/contributing.md b/docs/contributing.md index 464929d33..e007f8ecf 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -47,6 +47,10 @@ need their own validation. - `just web`: install locked dependencies and start Vite on port 1430 or the next available port, allowing parallel browser development across worktrees. - `just desktop`: install locked dependencies and start Tauri, which starts Vite. +- To pause notifications in your local dev server, set `BUZZ_DEV_NOTIFICATIONS=0` + in `.env.local` and restart the server. Only `0` pauses alerts and permission + requests; removing the setting restores normal behavior. Saved preferences are + untouched and production builds ignore the variable. - `just fullstack`: reserved, exits unsuccessfully with an explanation. It will eventually start local Docker services including the Buzz relay backend. - `just iterate`: install locked dependencies, format Rust, apply Biome safe diff --git a/docs/notifications.md b/docs/notifications.md index 3b1e4971f..2a63fd1a5 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -3,6 +3,12 @@ The host provides one `notifications` service for built-in messages and trusted plugins. Settings → Notifications stores account-local choices: alerts are on by default, subject to system permission; master off preserves category choices. +To pause all alerts and permission requests in a local dev server, set +`BUZZ_DEV_NOTIFICATIONS=0` in `.env.local` and restart the server. Only `0` pauses +notifications; unset or any other value keeps normal behavior. This development +gate never rewrites saved preferences. Settings shows the pause and how to remove +it; normal behavior still honors account choices and system permission. +Production builds ignore this variable. Browser sound uses the Notification API. Desktop sound is managed in OS settings; there is no separate audio player. diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx new file mode 100644 index 000000000..29ca44bc3 --- /dev/null +++ b/src/app/NotificationSettings.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { Context } from "@deepseek-ai/cordis"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { provideNavigation } from "../features/navigation/service"; +import { NotificationsService } from "../features/notifications/service"; +import { NotificationSettings } from "./NotificationSettings"; +import { PluginRuntime } from "../plugins/runtime"; + +const contexts: Context[] = []; +afterEach(async () => { + cleanup(); + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose(); + localStorage.clear(); + vi.unstubAllEnvs(); +}); + +it.each([true, false])( + "represents development pause=%s without changing saved preferences", + async (paused) => { + vi.stubEnv("VITE_BUZZ_NOTIFICATIONS_PAUSED", paused ? "1" : "0"); + const ctx = new Context(); + contexts.push(ctx); + const runtime = new PluginRuntime(ctx, async () => ({ apply() {} })); + ctx.effect(() => () => runtime.dispose()); + let permission: "default" | "granted" = "default"; + const platform = { + label: "Browser", + permission: vi.fn(async () => permission), + requestPermission: vi.fn(async () => { + permission = "granted"; + return permission; + }), + show: vi.fn(async () => {}), + dispose() {}, + }; + const service = new NotificationsService( + ctx, + provideNavigation(ctx).navigation, + platform, + ); + service.selectViewer("a".repeat(64)); + service.updatePreferences({ enabled: true }); + const saved = localStorage.getItem( + `buzz-notification-preferences.v1:${"a".repeat(64)}`, + ); + render(); + const toggle = screen.getByRole("switch", { name: "Desktop alerts" }); + if (paused) { + expect(toggle).toBeDisabled(); + expect(toggle).not.toBeChecked(); + expect(screen.getByRole("status")).toHaveTextContent( + "Remove BUZZ_DEV_NOTIFICATIONS=0", + ); + expect( + screen.queryByRole("button", { name: "Allow notifications" }), + ).not.toBeInTheDocument(); + } else { + expect(toggle).toBeEnabled(); + expect(toggle).toBeChecked(); + expect( + screen.getByRole("button", { name: "Allow notifications" }), + ).toBeEnabled(); + } + cleanup(); + await service.requestPermission(); + expect(platform.requestPermission).toHaveBeenCalledTimes(paused ? 0 : 1); + const accepted = await service.admit( + "mention", + "Mentions", + { + sourceKey: "test-event", + target: { version: 1, kind: "settings" }, + }, + () => true, + ); + expect(accepted).toBe(!paused); + if (paused) expect(platform.show).not.toHaveBeenCalled(); + else await vi.waitFor(() => expect(platform.show).toHaveBeenCalledOnce()); + expect(service.snapshot().preferences.enabled).toBe(true); + service.reloadPreferences(); + expect(service.snapshot().preferences.enabled).toBe(true); + expect( + localStorage.getItem( + `buzz-notification-preferences.v1:${"a".repeat(64)}`, + ), + ).toBe(saved); + }, +); diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx index 706f6729f..d14dd35bc 100644 --- a/src/app/NotificationSettings.tsx +++ b/src/app/NotificationSettings.tsx @@ -23,23 +23,26 @@ export function NotificationSettings({

notifications.updatePreferences({ enabled })} />

- {state.requesting - ? "Waiting for system permission…" - : permission === "granted" - ? "Permission granted. Your alert choices still apply." - : permission === "denied" - ? "Blocked. Allow notifications in your browser or system settings." - : permission === "unsupported" - ? "System notifications are unavailable in this build." - : permission === "unknown" - ? "Permission is controlled by system notification settings." - : "Allow notifications to receive alerts."} + {state.developmentPaused + ? "Notifications are paused by your local development setting. Remove BUZZ_DEV_NOTIFICATIONS=0 from .env.local and restart the dev server to resume normal behavior. Your saved alert choices are unchanged." + : state.requesting + ? "Waiting for system permission…" + : permission === "granted" + ? "Permission granted. Your alert choices still apply." + : permission === "denied" + ? "Blocked. Allow notifications in your browser or system settings." + : permission === "unsupported" + ? "System notifications are unavailable in this build." + : permission === "unknown" + ? "Permission is controlled by system notification settings." + : "Allow notifications to receive alerts."}

- {!state.systemManaged && ( + {!state.systemManaged && !state.developmentPaused && (
{permission === "default" && (