Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425import { describe, it, beforeEach, afterEach } from "node:test";import assert from "node:assert/strict";import { Signal } from "/js/signals.js";import { DesktopNotificationService } from "/js/desktopNotificationService.js";import { startActiveTabMonitor } from "/js/activeTabMonitor.js";
function createMockNotificationService({ numNotifications = 0 } = {}) { return { $numNotifications: new Signal.State(numNotifications), };}
function createMockChatNotificationService({ numNotifications = 0 } = {}) { return { $numNotifications: new Signal.State(numNotifications), };}
function macrotask() { return new Promise((resolve) => setTimeout(resolve, 0));}
// Effects render on rAF, then notify() awaits the cross-tab focus query before// constructing the Notification.async function flushEffects() { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), ); await macrotask(); await macrotask();}
function enable() { localStorage.setItem("system-notifications-enabled", "true");}
describe("DesktopNotificationService", () => { let instances; let originalNotification; let originalMatchMedia; let disposers; let navigations; let router; let originalHasFocus; let tabMonitors;
function simulateTabState({ visible, focused }) { Object.defineProperty(document, "visibilityState", { value: visible ? "visible" : "hidden", configurable: true, }); document.hasFocus = () => focused; }
function simulateTouchOnlyDevice() { window.matchMedia = (query) => ({ matches: query === "(hover: none) and (pointer: coarse)", addEventListener: () => {}, removeEventListener: () => {}, }); }
// A monitor with no peer tabs: its focus queries time out immediately, so // these tests exercise only this tab's own focus state. function createTabMonitor() { const monitor = startActiveTabMonitor({ replyTimeoutMs: 0 }); tabMonitors.push(monitor); return monitor; }
function startService(notificationService, chatNotificationService) { const activeTabMonitor = createTabMonitor(); const service = new DesktopNotificationService( notificationService, chatNotificationService, router, activeTabMonitor, ); const dispose = service.start(); disposers.push(dispose); return { service, dispose }; }
beforeEach(() => { instances = []; disposers = []; tabMonitors = []; navigations = []; router = { go: (path) => navigations.push(path) }; originalNotification = globalThis.Notification; originalMatchMedia = window.matchMedia; originalHasFocus = document.hasFocus; simulateTabState({ visible: true, focused: false }); globalThis.Notification = class { static permission = "granted"; static async requestPermission() { return globalThis.Notification.permission; } constructor(title, options) { this.title = title; this.options = options; instances.push(this); } close() {} }; localStorage.clear(); });
afterEach(async () => { for (const dispose of disposers) { dispose(); } for (const monitor of tabMonitors) { monitor.stop(); } // Let any notify() still awaiting a focus query settle while the mock // Notification is in place. await flushEffects(); globalThis.Notification = originalNotification; window.matchMedia = originalMatchMedia; document.hasFocus = originalHasFocus; delete document.visibilityState; localStorage.clear(); });
describe("start", () => { it("seeds last-seen counts without firing", async () => { const notificationService = createMockNotificationService({ numNotifications: 5, }); const chatNotificationService = createMockChatNotificationService({ numNotifications: 2, }); enable();
startService(notificationService, chatNotificationService); await flushEffects();
assert.deepEqual(instances.length, 0); });
it("notifies when the activity count increases", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 1); assert.deepEqual(instances[0].options.tag, "impro-activity"); });
it("does not notify again for an unchanged count", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects(); chatNotificationService.$numNotifications.set(1); await flushEffects();
assert.deepEqual(instances.length, 2); assert.deepEqual(instances[1].options.tag, "impro-chat"); });
it("notifies again when the count increases further", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects(); notificationService.$numNotifications.set(5); await flushEffects();
assert.deepEqual(instances.length, 2); });
it("does not notify when the count decreases", async () => { const notificationService = createMockNotificationService({ numNotifications: 5, }); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(0); await flushEffects();
assert.deepEqual(instances.length, 0); });
it("notifies when the chat count increases", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
chatNotificationService.$numNotifications.set(2); await flushEffects();
assert.deepEqual(instances.length, 1); assert.deepEqual(instances[0].options.tag, "impro-chat"); });
it("stops notifying after the effect is disposed", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); const { dispose } = startService( notificationService, chatNotificationService, );
notificationService.$numNotifications.set(1); await flushEffects(); assert.deepEqual(instances.length, 1);
dispose(); chatNotificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 1); }); });
describe("notification clicks", () => { it("navigates to the notification's url", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects(); instances[0].onclick(); assert.deepEqual(navigations, ["/notifications"]);
chatNotificationService.$numNotifications.set(1); await flushEffects(); instances[1].onclick(); assert.deepEqual(navigations, ["/notifications", "/messages"]); }); });
describe("notify gating", () => { it("does not notify when disabled", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 0); });
it("does not notify when permission is not granted", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); globalThis.Notification.permission = "default"; startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 0); });
it("does not notify while the tab is visible and focused", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); simulateTabState({ visible: true, focused: true }); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 0); });
it("notifies when the window is focused but not visible", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); simulateTabState({ visible: false, focused: true }); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 1); });
it("notifies when the tab is visible but unfocused", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); simulateTabState({ visible: true, focused: false }); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 1); }); });
describe("touch-only devices", () => { it("reports as unsupported even though the Notification API exists", () => { simulateTouchOnlyDevice(); const service = new DesktopNotificationService( createMockNotificationService(), createMockChatNotificationService(), router, createTabMonitor(), );
assert.deepEqual(typeof globalThis.Notification !== "undefined", true); assert.deepEqual(service.isSupported, false); });
it("reports as supported when hover and a fine pointer exist", () => { const service = new DesktopNotificationService( createMockNotificationService(), createMockChatNotificationService(), router, createTabMonitor(), );
assert.deepEqual(service.isSupported, true); });
it("does not notify even when enabled and permitted", async () => { simulateTouchOnlyDevice(); const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); enable(); startService(notificationService, chatNotificationService);
notificationService.$numNotifications.set(3); await flushEffects();
assert.deepEqual(instances.length, 0); });
it("does not request permission or set the storage flag", async () => { simulateTouchOnlyDevice(); const service = new DesktopNotificationService( createMockNotificationService(), createMockChatNotificationService(), router, createTabMonitor(), );
const result = await service.requestPermission();
assert.deepEqual(result, "unsupported"); assert.deepEqual(service.isEnabled, false); }); });
describe("requestPermission", () => { it("sets the storage flag when granted", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); const service = new DesktopNotificationService( notificationService, chatNotificationService, router, createTabMonitor(), ); globalThis.Notification.permission = "granted";
const result = await service.requestPermission();
assert.deepEqual(result, "granted"); assert.deepEqual(service.isEnabled, true); });
it("does not set the storage flag when denied", async () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); const service = new DesktopNotificationService( notificationService, chatNotificationService, router, createTabMonitor(), ); globalThis.Notification.permission = "denied";
const result = await service.requestPermission();
assert.deepEqual(result, "denied"); assert.deepEqual(service.isEnabled, false); }); });
describe("disable", () => { it("clears the storage flag", () => { const notificationService = createMockNotificationService(); const chatNotificationService = createMockChatNotificationService(); const service = new DesktopNotificationService( notificationService, chatNotificationService, router, createTabMonitor(), ); enable(); assert.deepEqual(service.isEnabled, true);
service.disable();
assert.deepEqual(service.isEnabled, false); }); });});