diff --git a/package.json b/package.json index 6341717c..b1ea9449 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.87", + "version": "0.20.88", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/appBadgeService.js b/src/js/appBadgeService.js index 71b85bc8..f8c914d1 100644 --- a/src/js/appBadgeService.js +++ b/src/js/appBadgeService.js @@ -14,18 +14,33 @@ export class AppBadgeService { start() { return effect(() => { - const activityCount = - this.notificationService.$numNotifications.get() ?? 0; - const chatCount = - this.chatNotificationService.$numNotifications.get() ?? 0; + const activityCount = this.notificationService.$numNotifications.get(); + const chatCount = this.chatNotificationService.$numNotifications.get(); const pushEnabled = this.pushNotificationService?.isEnabled ?? false; + if (activityCount === 0 && chatCount === 0) { + closeDisplayedNotifications(); + } + if (!("setAppBadge" in navigator)) return; const badgeEnabled = !isTouchOnlyDevice() || pushEnabled; - const total = badgeEnabled ? activityCount + chatCount : 0; + const total = badgeEnabled ? (activityCount ?? 0) + (chatCount ?? 0) : 0; const applied = total > 0 ? navigator.setAppBadge(total) : navigator.clearAppBadge(); applied?.catch?.(() => {}); }); } } + +async function closeDisplayedNotifications() { + if (!("serviceWorker" in navigator)) return; + try { + const registration = await navigator.serviceWorker.getRegistration(); + const notifications = (await registration?.getNotifications()) ?? []; + for (const notification of notifications) { + notification.close(); + } + } catch (error) { + console.warn("Failed to close displayed notifications", error); + } +} diff --git a/src/js/chatNotificationService.js b/src/js/chatNotificationService.js index fae74250..7c5026f1 100644 --- a/src/js/chatNotificationService.js +++ b/src/js/chatNotificationService.js @@ -7,7 +7,7 @@ const OPEN_CONVO_CONFIRM_DELAY_MS = 7000; export class ChatNotificationService { constructor(api) { this.api = api; - this.$numNotifications = new Signal.State(0); + this.$numNotifications = new Signal.State(null); this.$numUnreadRequestConvos = new Signal.State(0); this._optimisticallyReadIds = new Set(); this._lastServerTotal = 0; diff --git a/src/js/notificationService.js b/src/js/notificationService.js index d5ec6efb..1235e269 100644 --- a/src/js/notificationService.js +++ b/src/js/notificationService.js @@ -8,7 +8,7 @@ const VERIFY_RETRY_MS = 1000; export class NotificationService { constructor(api) { this.api = api; - this.$numNotifications = new Signal.State(0); + this.$numNotifications = new Signal.State(null); this.$numNotifications.__debugName = "$numNotifications"; this._lastVerifiedTopUri = null; this.poller = new Poller( diff --git a/tests/unit/specs/appBadgeService.test.js b/tests/unit/specs/appBadgeService.test.js index cfa0f2a6..664e7068 100644 --- a/tests/unit/specs/appBadgeService.test.js +++ b/tests/unit/specs/appBadgeService.test.js @@ -26,6 +26,7 @@ function flushEffects() { describe("AppBadgeService", () => { let badgeCalls; + let displayedNotifications; let originalMatchMedia; let disposers; @@ -54,7 +55,13 @@ describe("AppBadgeService", () => { beforeEach(() => { badgeCalls = []; + displayedNotifications = []; disposers = []; + navigator.serviceWorker = { + getRegistration: async () => ({ + getNotifications: async () => displayedNotifications, + }), + }; originalMatchMedia = window.matchMedia; navigator.setAppBadge = async (count) => { badgeCalls.push(count); @@ -70,6 +77,7 @@ describe("AppBadgeService", () => { } delete navigator.setAppBadge; delete navigator.clearAppBadge; + delete navigator.serviceWorker; window.matchMedia = originalMatchMedia; }); @@ -188,4 +196,80 @@ describe("AppBadgeService", () => { assert.deepEqual(badgeCalls, [4]); }); + + describe("displayed notifications", () => { + function displayNotification() { + const notification = { closed: false }; + notification.close = () => { + notification.closed = true; + }; + displayedNotifications.push(notification); + return notification; + } + + it("closes displayed notifications once everything is read", async () => { + const notification = displayNotification(); + const notificationService = createMockNotificationService({ + numNotifications: 2, + }); + const chatNotificationService = createMockNotificationService({ + numNotifications: 1, + }); + startSync(notificationService, chatNotificationService); + await flushEffects(); + assert.equal(notification.closed, false); + + notificationService.$numNotifications.set(0); + await flushEffects(); + assert.equal(notification.closed, false); + + chatNotificationService.$numNotifications.set(0); + await flushEffects(); + assert.equal(notification.closed, true); + }); + + it("closes displayed notifications on start when nothing is unread", async () => { + const notification = displayNotification(); + startSync( + createMockNotificationService(), + createMockNotificationService(), + ); + await flushEffects(); + + assert.equal(notification.closed, true); + }); + + it("waits for both counts to load before closing", async () => { + const notification = displayNotification(); + const notificationService = createMockNotificationService({ + numNotifications: null, + }); + const chatNotificationService = createMockNotificationService({ + numNotifications: null, + }); + startSync(notificationService, chatNotificationService); + await flushEffects(); + + notificationService.$numNotifications.set(0); + await flushEffects(); + assert.equal(notification.closed, false); + + chatNotificationService.$numNotifications.set(0); + await flushEffects(); + assert.equal(notification.closed, true); + }); + + it("closes notifications on touch-only devices even when push is disabled", async () => { + simulateTouchOnlyDevice(); + const notification = displayNotification(); + startSync( + createMockNotificationService(), + createMockNotificationService(), + createMockPushNotificationService({ enabled: false }), + ); + await flushEffects(); + + assert.equal(notification.closed, true); + }); + }); }); diff --git a/tests/unit/specs/chatNotificationService.test.js b/tests/unit/specs/chatNotificationService.test.js index 24b1d193..3a4c3ae5 100644 --- a/tests/unit/specs/chatNotificationService.test.js +++ b/tests/unit/specs/chatNotificationService.test.js @@ -21,10 +21,10 @@ function createMockApi({ } describe("constructor", () => { - it("should initialize with zero notifications", () => { + it("should initialize with a null count until loaded", () => { const api = createMockApi(); const service = new ChatNotificationService(api); - assert.deepEqual(service.$numNotifications.get(), 0); + assert.equal(service.$numNotifications.get(), null); assert.deepEqual(service.$numUnreadRequestConvos.get(), 0); }); }); @@ -55,7 +55,7 @@ describe("fetchNumNotifications", () => { const api = createMockApi({ unreadAcceptedConvos: 1 }); const service = new ChatNotificationService(api); - assert.deepEqual(service.$numNotifications.get(), 0); + assert.equal(service.$numNotifications.get(), null); await service.fetchNumNotifications(); diff --git a/tests/unit/specs/notificationService.test.js b/tests/unit/specs/notificationService.test.js index ccdc9c76..ccf23288 100644 --- a/tests/unit/specs/notificationService.test.js +++ b/tests/unit/specs/notificationService.test.js @@ -33,10 +33,10 @@ describe("NotificationService", () => { }); describe("constructor", () => { - it("should initialize with zero notifications", () => { + it("should initialize with a null count until loaded", () => { const api = createMockApi(); const service = new NotificationService(api); - assert.deepEqual(service.$numNotifications.get(), 0); + assert.equal(service.$numNotifications.get(), null); }); }); @@ -54,13 +54,31 @@ describe("NotificationService", () => { const api = createMockApi({ numNotifications: 3 }); const service = new NotificationService(api); - assert.deepEqual(service.$numNotifications.get(), 0); + assert.equal(service.$numNotifications.get(), null); await service.fetchNumNotifications(); assert.deepEqual(service.$numNotifications.get(), 3); }); + it("sets a zero count on the first fetch", async () => { + const api = createMockApi({ numNotifications: 0 }); + const service = new NotificationService(api); + + await service.fetchNumNotifications(); + + assert.equal(service.$numNotifications.get(), 0); + }); + + it("keeps the count null while a first increase is unverified", async () => { + const api = createMockApi({ numNotifications: 3, topUri: null }); + const service = new NotificationService(api); + + await service.fetchNumNotifications(); + + assert.equal(service.$numNotifications.get(), null); + }); + it("commits the increased count once the list reflects a new top item", async () => { let topUri = "at://did:example/app.bsky.feed.post/first"; const getNotificationsFn = mock.fn(async () => ({ @@ -244,7 +262,7 @@ describe("NotificationService", () => { const api = createMockApi({ numNotifications: 7 }); const service = new NotificationService(api); - assert.deepEqual(service.$numNotifications.get(), 0); + assert.equal(service.$numNotifications.get(), null); await service.fetchNumNotifications();