diff --git a/src/GleapNotificationManager.js b/src/GleapNotificationManager.js index 7f78a007..4ec9d82b 100644 --- a/src/GleapNotificationManager.js +++ b/src/GleapNotificationManager.js @@ -91,8 +91,12 @@ export default class GleapNotificationManager { try { const notificationsFromCache = loadFromGleapCache(this.unreadNotificationsKey); if (notificationsFromCache && notificationsFromCache.length > 0) { + const gleapId = GleapSession.getInstance().session?.gleapId; let nots = notificationsFromCache.filter( (notification) => + // Only the contact that received it (unstamped pre-fix entries are dropped). + gleapId && + notification?.cachedForGleapId === gleapId && // Never resurface chatbar-routed notifications in the widget bubble. // (Pre-fix builds may have cached them before the routing guard existed.) notification?.data?.lastSource !== 'chatbar' && @@ -187,6 +191,9 @@ export default class GleapNotificationManager { const notificationsForOutbound = this.notifications.find((e) => notification.outbound === e.outbound); if (!notificationsForOutbound) { + // The cache key is shared by every contact on this browser; remember who + // received it so a reload never shows it to another contact. + notification.cachedForGleapId = GleapSession.getInstance().session?.gleapId; this.notifications.push(notification); // Play sound only if it's a new one and its page rules allow the @@ -645,10 +652,9 @@ export default class GleapNotificationManager { * @param {boolean} fromOtherTab - True when applying a clear broadcast from a sibling tab (prevents re-broadcast loops). */ clearAllNotifications(uiOnly = false, fromOtherTab = false) { - if (!this.notificationContainer) { - return; - } - + // Clear the state and cache even before the UI is mounted: a clearIdentity() + // queued in the snippet runs before injectNotificationUI(), which would + // otherwise replay the previous contact's notifications from the cache. if (!uiOnly) { this.notifications = []; // Reset currentNewsIndex whenever we clear them out @@ -656,6 +662,10 @@ export default class GleapNotificationManager { saveToGleapCache(this.unreadNotificationsKey, this.notifications); } + if (!this.notificationContainer) { + return; + } + while (this.notificationContainer.firstChild) { this.notificationContainer.removeChild(this.notificationContainer.firstChild); } diff --git a/src/GleapNotificationManager.test.js b/src/GleapNotificationManager.test.js index c50f95dd..37afd7f4 100644 --- a/src/GleapNotificationManager.test.js +++ b/src/GleapNotificationManager.test.js @@ -253,7 +253,7 @@ describe('page rules — render-time evaluation (#141052)', () => { test('cached page-ruled notifications survive a reload on an excluded page but stay hidden', () => { setUrl(EXCLUDED_URL); const nm = withContainer(GleapNotificationManager.getInstance()); - const cached = pageRuledNotification(); + const cached = { ...pageRuledNotification(), cachedForGleapId: 'user-1' }; loadFromGleapCache.mockReturnValue([cached]); nm.reloadNotificationsFromCache(); @@ -319,7 +319,9 @@ describe('notification stack', () => { test('reloadNotificationsFromCache keeps the NEWEST 4 (the same end shift() trims from)', () => { const nm = withContainer(GleapNotificationManager.getInstance()); jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {}); - loadFromGleapCache.mockReturnValue([1, 2, 3, 4, 5, 6].map((i) => plainNotification(`n${i}`))); + loadFromGleapCache.mockReturnValue( + [1, 2, 3, 4, 5, 6].map((i) => ({ ...plainNotification(`n${i}`), cachedForGleapId: 'user-1' })) + ); nm.reloadNotificationsFromCache(); @@ -365,9 +367,71 @@ describe('clearAllNotifications — cross-tab broadcast gate', () => { expect(sendMessageMock).not.toHaveBeenCalled(); }); - test('no notification container => early return, no broadcast', () => { + test('no notification container => no broadcast (the tab channel only starts with the UI)', () => { const nm = GleapNotificationManager.getInstance(); nm.clearAllNotifications(); expect(sendMessageMock).not.toHaveBeenCalled(); }); }); + +describe('notification cache vs identity (#225880)', () => { + const plainNotification = (text, gleapId) => ({ + outbound: `outbound-${text}`, + createdAt: new Date().toISOString(), + data: { text }, + ...(gleapId ? { cachedForGleapId: gleapId } : {}), + }); + + beforeEach(() => { + loadFromGleapCache.mockReset(); + saveToGleapCache.mockReset(); + }); + + test('clearAllNotifications(false) clears the persisted cache before the UI is mounted', () => { + // A clearIdentity() queued in the snippet runs before injectNotificationUI(). + const nm = GleapNotificationManager.getInstance(); + nm.notifications = [plainNotification('previous user')]; + + nm.clearAllNotifications(false); + + expect(nm.notifications).toEqual([]); + expect(saveToGleapCache).toHaveBeenCalledWith(nm.unreadNotificationsKey, []); + }); + + test('a UI-only clear before mount leaves the cache alone', () => { + const nm = GleapNotificationManager.getInstance(); + const notification = plainNotification('keep'); + nm.notifications = [notification]; + + nm.clearAllNotifications(true); + + expect(nm.notifications).toEqual([notification]); + expect(saveToGleapCache).not.toHaveBeenCalled(); + }); + + test('showNotification stamps the receiving gleapId on the cached notification', () => { + const nm = withContainer(GleapNotificationManager.getInstance()); + jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {}); + + nm.showNotification(plainNotification('hello')); + + expect(saveToGleapCache).toHaveBeenCalledWith(nm.unreadNotificationsKey, [ + expect.objectContaining({ cachedForGleapId: 'user-1' }), + ]); + }); + + test('reloadNotificationsFromCache drops notifications cached for another (or no) gleapId', () => { + const nm = withContainer(GleapNotificationManager.getInstance()); + jest.spyOn(nm, 'renderNotifications').mockImplementation(() => {}); + const own = plainNotification('own', 'user-1'); + loadFromGleapCache.mockReturnValue([ + plainNotification('previous user', 'user-0'), + plainNotification('unstamped'), + own, + ]); + + nm.reloadNotificationsFromCache(); + + expect(nm.notifications).toEqual([own]); + }); +});