mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 13:49:18 +02:00
The in-app inbox (sidebar badge, real-time WS updates, settings, inbox page) was already shared and worked on web. The only Desktop-only piece was the native OS banner: handleInboxNew called desktopAPI.showNotification, which is undefined on web, so no banner fired for new inbox items while the app was unfocused. Add the browser equivalent, keeping handleInboxNew as the single decision point (focus + source-workspace mute gating stays shared with desktop): - packages/core/platform/system-notification.ts: browser Notification engine (showWebNotification) + permission helpers + a click-handler registry. Lives in core (the caller does) but injects the click-routing decision so core stays headless. - handleInboxNew: branch desktopAPI (unchanged) → else showWebNotification. - apps/web WebNotificationBridge: registers click routing to the source workspace's inbox (?issue=…), mirroring desktop's DesktopInboxBridge. - Settings → Notifications: web-only opt-in to grant browser permission (hidden on desktop / where the API is unavailable); en/zh-Hans/ja/ko. Permission is an explicit settings opt-in (no auto-prompt on load, per browser best practice). Tests cover the engine and the web path in handleInboxNew. Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
getWebNotificationPermission,
|
|
isWebNotificationSupported,
|
|
registerSystemNotificationClickHandler,
|
|
requestWebNotificationPermission,
|
|
showWebNotification,
|
|
type SystemNotificationPayload,
|
|
} from "./system-notification";
|
|
|
|
// The core test environment is Node — `window`/`Notification` don't exist.
|
|
// These tests install a minimal `window.Notification` stub on `globalThis`
|
|
// (the same surface `getNotificationCtor` reads) to exercise the browser path.
|
|
|
|
type Created = { title: string; options?: NotificationOptions; instance: FakeNotification };
|
|
|
|
class FakeNotification {
|
|
static permission: NotificationPermission = "granted";
|
|
static requestPermission = vi.fn(async () => FakeNotification.permission);
|
|
onclick: (() => void) | null = null;
|
|
close = vi.fn();
|
|
constructor(
|
|
public title: string,
|
|
public options?: NotificationOptions,
|
|
) {
|
|
created.push({ title, options, instance: this });
|
|
}
|
|
}
|
|
|
|
let created: Created[] = [];
|
|
|
|
function installWindow(
|
|
ctor: unknown,
|
|
focus: () => void = () => {},
|
|
): void {
|
|
(globalThis as Record<string, unknown>).window = { Notification: ctor, focus };
|
|
}
|
|
|
|
function payload(
|
|
overrides: Partial<SystemNotificationPayload> = {},
|
|
): SystemNotificationPayload {
|
|
return {
|
|
slug: "workspace-a",
|
|
itemId: "item-1",
|
|
issueKey: "issue-1",
|
|
title: "Mentioned you",
|
|
body: "in a comment",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
created = [];
|
|
FakeNotification.permission = "granted";
|
|
FakeNotification.requestPermission.mockClear();
|
|
registerSystemNotificationClickHandler(null);
|
|
delete (globalThis as Record<string, unknown>).window;
|
|
});
|
|
|
|
describe("isWebNotificationSupported / getWebNotificationPermission", () => {
|
|
it("reports unsupported when there is no window", () => {
|
|
expect(isWebNotificationSupported()).toBe(false);
|
|
expect(getWebNotificationPermission()).toBe("unsupported");
|
|
});
|
|
|
|
it("reflects the browser's current permission when supported", () => {
|
|
installWindow(FakeNotification);
|
|
FakeNotification.permission = "denied";
|
|
expect(isWebNotificationSupported()).toBe(true);
|
|
expect(getWebNotificationPermission()).toBe("denied");
|
|
});
|
|
});
|
|
|
|
describe("requestWebNotificationPermission", () => {
|
|
it("returns 'unsupported' without the API", async () => {
|
|
await expect(requestWebNotificationPermission()).resolves.toBe("unsupported");
|
|
});
|
|
|
|
it("prompts only when permission is 'default'", async () => {
|
|
installWindow(FakeNotification);
|
|
FakeNotification.permission = "default";
|
|
FakeNotification.requestPermission.mockResolvedValueOnce("granted");
|
|
|
|
await expect(requestWebNotificationPermission()).resolves.toBe("granted");
|
|
expect(FakeNotification.requestPermission).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not re-prompt once already decided", async () => {
|
|
installWindow(FakeNotification);
|
|
FakeNotification.permission = "denied";
|
|
|
|
await expect(requestWebNotificationPermission()).resolves.toBe("denied");
|
|
expect(FakeNotification.requestPermission).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("showWebNotification", () => {
|
|
it("does nothing when the API is unavailable", () => {
|
|
expect(() => showWebNotification(payload())).not.toThrow();
|
|
expect(created).toHaveLength(0);
|
|
});
|
|
|
|
it("does nothing unless permission is granted", () => {
|
|
installWindow(FakeNotification);
|
|
FakeNotification.permission = "default";
|
|
showWebNotification(payload());
|
|
expect(created).toHaveLength(0);
|
|
});
|
|
|
|
it("shows a banner with body + a dedup tag when granted", () => {
|
|
installWindow(FakeNotification);
|
|
showWebNotification(payload());
|
|
expect(created).toHaveLength(1);
|
|
expect(created[0]?.title).toBe("Mentioned you");
|
|
expect(created[0]?.options).toMatchObject({ body: "in a comment", tag: "item-1" });
|
|
});
|
|
|
|
it("routes a click to the registered handler and closes the banner", () => {
|
|
const focus = vi.fn();
|
|
installWindow(FakeNotification, focus);
|
|
const onClick = vi.fn();
|
|
registerSystemNotificationClickHandler(onClick);
|
|
|
|
showWebNotification(payload());
|
|
created[0]?.instance.onclick?.();
|
|
|
|
expect(focus).toHaveBeenCalledTimes(1);
|
|
expect(created[0]?.instance.close).toHaveBeenCalledTimes(1);
|
|
expect(onClick).toHaveBeenCalledWith(payload());
|
|
});
|
|
|
|
it("swallows constructors that throw (e.g. service-worker-only engines)", () => {
|
|
class ThrowingNotification {
|
|
static permission: NotificationPermission = "granted";
|
|
constructor() {
|
|
throw new Error("requires a ServiceWorkerRegistration");
|
|
}
|
|
}
|
|
installWindow(ThrowingNotification);
|
|
expect(() => showWebNotification(payload())).not.toThrow();
|
|
});
|
|
});
|