Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 42 additions & 5 deletions dev/vite-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
`,
],
{
Expand Down
4 changes: 4 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
90 changes: 90 additions & 0 deletions src/app/NotificationSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NotificationSettings notifications={service} />);
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);
},
);
32 changes: 19 additions & 13 deletions src/app/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,26 @@ export function NotificationSettings({
</p>
<Toggle
label="Desktop alerts"
checked={preferences.enabled}
checked={!state.developmentPaused && preferences.enabled}
disabled={state.developmentPaused}
onChange={(enabled) => notifications.updatePreferences({ enabled })}
/>
<p role="status" className="text-body-sm text-muted">
{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."}
</p>
{!state.systemManaged && (
{!state.systemManaged && !state.developmentPaused && (
<div className="flex gap-2">
{permission === "default" && (
<button
Expand Down Expand Up @@ -134,9 +137,11 @@ function Toggle({
label,
checked,
onChange,
disabled = false,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange(checked: boolean): void;
}) {
return (
Expand All @@ -146,6 +151,7 @@ function Toggle({
type="checkbox"
role="switch"
checked={checked}
disabled={disabled}
aria-checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/features/notifications/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type NotificationSnapshot = Readonly<{
preferencesError: string | null;
platform: string;
systemManaged: boolean;
developmentPaused: boolean;
}>;

/** Running-session delivery only: no notification journal, inbox, or recovery protocol. */
Expand Down Expand Up @@ -94,6 +95,7 @@ export class NotificationsService extends Service implements Notifications {
requesting: false,
platform: platform.label,
systemManaged: platform.systemManaged ?? false,
developmentPaused: import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED === "1",
});
ctx.effect(() => {
const stopPreferences = preferences.subscribe(() => {
Expand Down Expand Up @@ -182,7 +184,7 @@ export class NotificationsService extends Service implements Notifications {
}
}
requestPermission() {
if (this.closed) return Promise.resolve();
if (this.closed || this.state.developmentPaused) return Promise.resolve();
if (this.permissionRequest) return this.permissionRequest;
const generation = ++this.permissionGeneration;
this.publish({ requesting: true, error: null });
Expand All @@ -209,6 +211,7 @@ export class NotificationsService extends Service implements Notifications {
private allowed(item: Candidate) {
return (
!this.closed &&
!this.state.developmentPaused &&
!item.cancelled &&
item.viewer === this.state.viewer &&
item.expires > Date.now() &&
Expand Down
3 changes: 3 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export default defineConfig(async ({ command, mode }) => {
plugins,
define: {
"import.meta.env.VITE_BUZZ_LIVE": JSON.stringify(live ? "1" : "0"),
"import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED": JSON.stringify(
command === "serve" && env.BUZZ_DEV_NOTIFICATIONS === "0" ? "1" : "0",
),
"import.meta.env.VITE_BUZZ_COMMUNITY_ALIASES": JSON.stringify(aliases),
},
clearScreen: false,
Expand Down
Loading