From 05e8d81d446417dd04b47a65535cbf3a1f5b6dd8 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 26 Feb 2026 13:34:52 +0000 Subject: [PATCH 01/10] Replace standard new message notification title with one that doesn't include the users identifier when masking IDs --- plugin-hrm-form/src/maskIdentifiers/index.ts | 21 ++++++++++++++------ plugin-hrm-form/src/translations/en.json | 3 ++- plugin-hrm-form/src/translations/index.ts | 7 +++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/plugin-hrm-form/src/maskIdentifiers/index.ts b/plugin-hrm-form/src/maskIdentifiers/index.ts index 69d1b14072..c1446c33ce 100644 --- a/plugin-hrm-form/src/maskIdentifiers/index.ts +++ b/plugin-hrm-form/src/maskIdentifiers/index.ts @@ -15,21 +15,23 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ import { - Strings, - TaskChannelDefinition, - MessageList, - StateHelper, + AppState, ConversationHelper, - TaskHelper, DefaultTaskChannels, Manager, - AppState, + MessageList, + NotificationIds, + StateHelper, + Strings, + TaskChannelDefinition, + TaskHelper, } from '@twilio/flex-ui'; // Weird type to pull in, but I can't see how it can be inferred from the public API, so it's this or 'any' xD import type { ChatProperties } from '@twilio/flex-ui/src/internal-flex-commons/src'; import { getInitializedCan } from '../permissions/rules'; import { PermissionActions } from '../permissions/actions'; +import { lookupTranslation } from '../translations'; // Mask identifiers in the channel strings export const maskChannelStringsWithIdentifiers = (channelType: TaskChannelDefinition) => { @@ -80,6 +82,13 @@ export const maskChannelStringsWithIdentifiers = (channelType: TaskChannelDefini TaskCard.firstLine = 'MaskIdentifiers'; Supervisor.TaskOverviewCanvas.firstLine = 'MaskIdentifiers'; + maskNotifications(channelType); +}; + +export const maskNotifications = (channelType: TaskChannelDefinition) => { + channelType.notifications.override[NotificationIds.NewChatMessage] = notification => { + notification.options.browser.title = lookupTranslation('BrowserNotification-ChatMessage-MaskedTitle'); + }; }; // Mask identifiers in the manager strings & messaging canvas diff --git a/plugin-hrm-form/src/translations/en.json b/plugin-hrm-form/src/translations/en.json index 9ea2830a19..c861e52987 100644 --- a/plugin-hrm-form/src/translations/en.json +++ b/plugin-hrm-form/src/translations/en.json @@ -660,5 +660,6 @@ "Modals-ConfirmDialog-ConfirmButton": "OK", "Modals-CloseDialog-CancelButton": "Cancel", - "Modals-CloseDialog-DiscardButton": "Discard" + "Modals-CloseDialog-DiscardButton": "Discard", + "BrowserNotification-ChatMessage-MaskedTitle": "New message" } diff --git a/plugin-hrm-form/src/translations/index.ts b/plugin-hrm-form/src/translations/index.ts index 4406762754..42540dafb8 100644 --- a/plugin-hrm-form/src/translations/index.ts +++ b/plugin-hrm-form/src/translations/index.ts @@ -14,6 +14,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ +import { Manager } from '@twilio/flex-ui'; + import { getDefinitionVersions } from '../hrmConfig'; // default language to initialize plugin @@ -117,3 +119,8 @@ export const initLocalization = (localizationConfig: LocalizationConfig, helplin return { translateUI, getMessage }; }; + +export const lookupTranslation = (code: string, parameters: Record = {}): string => { + const { strings } = Manager.getInstance(); + return Handlebars.compile(strings[code] ?? code)(parameters); +}; From 24a2281c814be4293aa53f93d1bf61917fff5038 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 26 Feb 2026 13:40:19 +0000 Subject: [PATCH 02/10] Remove redundant export --- plugin-hrm-form/src/maskIdentifiers/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugin-hrm-form/src/maskIdentifiers/index.ts b/plugin-hrm-form/src/maskIdentifiers/index.ts index c1446c33ce..1e5e3dec14 100644 --- a/plugin-hrm-form/src/maskIdentifiers/index.ts +++ b/plugin-hrm-form/src/maskIdentifiers/index.ts @@ -33,6 +33,12 @@ import { getInitializedCan } from '../permissions/rules'; import { PermissionActions } from '../permissions/actions'; import { lookupTranslation } from '../translations'; +const maskNotifications = (channelType: TaskChannelDefinition) => { + channelType.notifications.override[NotificationIds.NewChatMessage] = notification => { + notification.options.browser.title = lookupTranslation('BrowserNotification-ChatMessage-MaskedTitle'); + }; +}; + // Mask identifiers in the channel strings export const maskChannelStringsWithIdentifiers = (channelType: TaskChannelDefinition) => { const can = getInitializedCan(); @@ -85,12 +91,6 @@ export const maskChannelStringsWithIdentifiers = (channelType: TaskChannelDefini maskNotifications(channelType); }; -export const maskNotifications = (channelType: TaskChannelDefinition) => { - channelType.notifications.override[NotificationIds.NewChatMessage] = notification => { - notification.options.browser.title = lookupTranslation('BrowserNotification-ChatMessage-MaskedTitle'); - }; -}; - // Mask identifiers in the manager strings & messaging canvas export const maskManagerStringsWithIdentifiers = & { MaskIdentifiers?: string }>( newStrings: T, From b7a5c641a6c74e11622839799995dc780ebe2c61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:42:26 +0000 Subject: [PATCH 03/10] Initial plan From 87df86f11fae225bfee07e503741f0a4e5156b3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:58:01 +0000 Subject: [PATCH 04/10] Add unit tests for lookupTranslation and maskNotifications Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../___tests__/maskIdentifiers/index.test.ts | 127 +++++++++++++++++- .../src/___tests__/translations/index.test.ts | 48 ++++++- 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts index 72ab68a3b9..388131e392 100644 --- a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts +++ b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts @@ -19,15 +19,42 @@ import { Manager } from '@twilio/flex-ui'; import each from 'jest-each'; -import { maskConversationServiceUserNames } from '../../maskIdentifiers'; +import { + maskConversationServiceUserNames, + maskNotifications, + maskChannelStringsWithIdentifiers, +} from '../../maskIdentifiers'; import { getInitializedCan } from '../../permissions/rules'; import { PermissionActions } from '../../permissions/actions'; +import { lookupTranslation } from '../../translations'; jest.mock('../../permissions/rules', () => ({ getInitializedCan: jest.fn(), })); +jest.mock('../../translations', () => ({ + lookupTranslation: jest.fn(), +})); + +jest.mock('@twilio/flex-ui', () => ({ + Manager: { + getInstance: jest.fn(), + }, + NotificationIds: { + NewChatMessage: 'NewChatMessage', + }, + DefaultTaskChannels: { + ChatSms: { name: 'ChatSms' }, + }, + MessageList: { + Content: { remove: jest.fn() }, + }, +})); + +const mockLookupTranslation = lookupTranslation as jest.MockedFunction; + const mockGetInitializedCan = getInitializedCan as jest.MockedFunction; +const mockManagerGetInstance = Manager.getInstance as jest.MockedFunction; describe('maskConversationServiceUserNames', () => { let mockManager: any; @@ -391,3 +418,101 @@ describe('maskConversationServiceUserNames', () => { }); }); }); + +describe('maskNotifications', () => { + const createChannelType = () => ({ + notifications: { + override: {} as Record void>, + }, + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('sets an override handler for NewChatMessage notifications', () => { + const channelType = createChannelType(); + maskNotifications(channelType as any); + expect(typeof channelType.notifications.override.NewChatMessage).toBe('function'); + }); + + test('override handler sets browser notification title using lookupTranslation', () => { + mockLookupTranslation.mockReturnValue('Masked Chat Message'); + const channelType = createChannelType(); + maskNotifications(channelType as any); + + const notification = { options: { browser: { title: '' } } }; + channelType.notifications.override.NewChatMessage(notification); + + expect(mockLookupTranslation).toHaveBeenCalledWith('BrowserNotification-ChatMessage-MaskedTitle'); + expect(notification.options.browser.title).toBe('Masked Chat Message'); + }); +}); + +describe('maskChannelStringsWithIdentifiers', () => { + let mockCan: jest.Mock; + + const createChannelType = (name = 'default') => ({ + name, + templates: { + IncomingTaskCanvas: { firstLine: '' }, + TaskListItem: { firstLine: '', secondLine: '' }, + CallCanvas: { firstLine: '' }, + TaskCanvasHeader: { title: '' }, + Supervisor: { + TaskCanvasHeader: { title: '' }, + TaskOverviewCanvas: { firstLine: '' }, + }, + TaskCard: { firstLine: '' }, + }, + notifications: { + override: {} as Record void>, + }, + }); + + beforeEach(() => { + mockCan = jest.fn(); + mockGetInitializedCan.mockReturnValue(mockCan); + mockManagerGetInstance.mockReturnValue({ strings: { MaskIdentifiers: 'MASKED' } } as any); + mockLookupTranslation.mockReturnValue('Masked Title'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when VIEW_IDENTIFIERS permission is denied', () => { + beforeEach(() => { + mockCan.mockImplementation((action: string) => action !== PermissionActions.VIEW_IDENTIFIERS); + }); + + test('sets notification override for NewChatMessage', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(typeof channelType.notifications.override.NewChatMessage).toBe('function'); + }); + + test('notification override handler sets browser title using lookupTranslation', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + + const notification = { options: { browser: { title: '' } } }; + channelType.notifications.override.NewChatMessage(notification); + + expect(mockLookupTranslation).toHaveBeenCalledWith('BrowserNotification-ChatMessage-MaskedTitle'); + expect(notification.options.browser.title).toBe('Masked Title'); + }); + }); + + describe('when VIEW_IDENTIFIERS permission is granted', () => { + beforeEach(() => { + mockCan.mockReturnValue(true); + }); + + test('does not set notification override', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(channelType.notifications.override.NewChatMessage).toBeUndefined(); + }); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/translations/index.test.ts b/plugin-hrm-form/src/___tests__/translations/index.test.ts index 849363e8fc..5339f7b512 100644 --- a/plugin-hrm-form/src/___tests__/translations/index.test.ts +++ b/plugin-hrm-form/src/___tests__/translations/index.test.ts @@ -14,13 +14,20 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { loadTranslations, initLocalization } from '../../translations'; +import { Manager } from '@twilio/flex-ui'; + +import { loadTranslations, initLocalization, lookupTranslation } from '../../translations'; -// Mock translation files at the top level jest.mock('../../translations/en.json', () => ({}), { virtual: true }); jest.mock('../../translations/en-US.json', () => ({}), { virtual: true }); jest.mock('../../translations/en-GB.json', () => ({}), { virtual: true }); +jest.mock('@twilio/flex-ui', () => ({ + Manager: { + getInstance: jest.fn(), + }, +})); + const mockGetAseloFeatureFlags = jest.fn(); const mockGetHrmConfig = jest.fn(); const mockGetDefinitionVersions = jest.fn(); @@ -160,3 +167,40 @@ describe('Hierarchical Translations', () => { }); }); }); + +describe('lookupTranslation', () => { + const mockManagerGetInstance = Manager.getInstance as jest.MockedFunction; + + beforeAll(() => { + // eslint-disable-next-line global-require + (global as any).Handlebars = require('handlebars'); + }); + + afterAll(() => { + delete (global as any).Handlebars; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('returns compiled string for a key that exists in Manager strings', () => { + mockManagerGetInstance.mockReturnValue({ strings: { MyKey: 'Hello World' } } as any); + expect(lookupTranslation('MyKey')).toBe('Hello World'); + }); + + test('falls back to using the code itself as template when key does not exist in Manager strings', () => { + mockManagerGetInstance.mockReturnValue({ strings: {} } as any); + expect(lookupTranslation('FallbackKey')).toBe('FallbackKey'); + }); + + test('passes parameters to the Handlebars template', () => { + mockManagerGetInstance.mockReturnValue({ strings: { Greeting: 'Hello {{name}}!' } } as any); + expect(lookupTranslation('Greeting', { name: 'World' })).toBe('Hello World!'); + }); + + test('uses empty parameters object by default', () => { + mockManagerGetInstance.mockReturnValue({ strings: { SimpleMsg: 'No params here' } } as any); + expect(lookupTranslation('SimpleMsg')).toBe('No params here'); + }); +}); From 85f01a7d00cde6dd6ddfb9457f7b49c7c326e97a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:25:15 +0000 Subject: [PATCH 05/10] Move maskNotifications tests into maskChannelStringsWithIdentifiers describe block Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../___tests__/maskIdentifiers/index.test.ts | 36 +------------------ 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts index 388131e392..2044270c95 100644 --- a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts +++ b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts @@ -19,11 +19,7 @@ import { Manager } from '@twilio/flex-ui'; import each from 'jest-each'; -import { - maskConversationServiceUserNames, - maskNotifications, - maskChannelStringsWithIdentifiers, -} from '../../maskIdentifiers'; +import { maskConversationServiceUserNames, maskChannelStringsWithIdentifiers } from '../../maskIdentifiers'; import { getInitializedCan } from '../../permissions/rules'; import { PermissionActions } from '../../permissions/actions'; import { lookupTranslation } from '../../translations'; @@ -419,36 +415,6 @@ describe('maskConversationServiceUserNames', () => { }); }); -describe('maskNotifications', () => { - const createChannelType = () => ({ - notifications: { - override: {} as Record void>, - }, - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('sets an override handler for NewChatMessage notifications', () => { - const channelType = createChannelType(); - maskNotifications(channelType as any); - expect(typeof channelType.notifications.override.NewChatMessage).toBe('function'); - }); - - test('override handler sets browser notification title using lookupTranslation', () => { - mockLookupTranslation.mockReturnValue('Masked Chat Message'); - const channelType = createChannelType(); - maskNotifications(channelType as any); - - const notification = { options: { browser: { title: '' } } }; - channelType.notifications.override.NewChatMessage(notification); - - expect(mockLookupTranslation).toHaveBeenCalledWith('BrowserNotification-ChatMessage-MaskedTitle'); - expect(notification.options.browser.title).toBe('Masked Chat Message'); - }); -}); - describe('maskChannelStringsWithIdentifiers', () => { let mockCan: jest.Mock; From 5416bb8ff5623fe9c13a1014d3888c989c349db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:44:36 +0000 Subject: [PATCH 06/10] Initial plan From 12766104423beb2c9b480fc9f7fa9a0f938ab4d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:48:48 +0000 Subject: [PATCH 07/10] Explicitly import Handlebars in translations/index.ts instead of relying on global Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/___tests__/translations/index.test.ts | 9 --------- plugin-hrm-form/src/translations/index.ts | 1 + 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/plugin-hrm-form/src/___tests__/translations/index.test.ts b/plugin-hrm-form/src/___tests__/translations/index.test.ts index 5339f7b512..9f5552a70f 100644 --- a/plugin-hrm-form/src/___tests__/translations/index.test.ts +++ b/plugin-hrm-form/src/___tests__/translations/index.test.ts @@ -171,15 +171,6 @@ describe('Hierarchical Translations', () => { describe('lookupTranslation', () => { const mockManagerGetInstance = Manager.getInstance as jest.MockedFunction; - beforeAll(() => { - // eslint-disable-next-line global-require - (global as any).Handlebars = require('handlebars'); - }); - - afterAll(() => { - delete (global as any).Handlebars; - }); - afterEach(() => { jest.clearAllMocks(); }); diff --git a/plugin-hrm-form/src/translations/index.ts b/plugin-hrm-form/src/translations/index.ts index 42540dafb8..0423eb2852 100644 --- a/plugin-hrm-form/src/translations/index.ts +++ b/plugin-hrm-form/src/translations/index.ts @@ -14,6 +14,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ +import Handlebars from 'handlebars'; import { Manager } from '@twilio/flex-ui'; import { getDefinitionVersions } from '../hrmConfig'; From ab750c6b69db0c75e83069eb9ce0b474cf2a7e03 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 27 Feb 2026 17:48:44 +0000 Subject: [PATCH 08/10] Disable incoming chat notifications when identifiers are masked --- plugin-hrm-form/src/maskIdentifiers/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin-hrm-form/src/maskIdentifiers/index.ts b/plugin-hrm-form/src/maskIdentifiers/index.ts index 1e5e3dec14..a2a9d17783 100644 --- a/plugin-hrm-form/src/maskIdentifiers/index.ts +++ b/plugin-hrm-form/src/maskIdentifiers/index.ts @@ -20,6 +20,7 @@ import { DefaultTaskChannels, Manager, MessageList, + Notifications, NotificationIds, StateHelper, Strings, @@ -37,6 +38,8 @@ const maskNotifications = (channelType: TaskChannelDefinition) => { channelType.notifications.override[NotificationIds.NewChatMessage] = notification => { notification.options.browser.title = lookupTranslation('BrowserNotification-ChatMessage-MaskedTitle'); }; + // Trying to modify this notification doesn't appear to work so remove it + Notifications.registeredNotifications.delete(NotificationIds.IncomingTask); }; // Mask identifiers in the channel strings From 285b272bf4e7c11e7a3cd169c2d6a80764d1e7a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:58:35 +0000 Subject: [PATCH 09/10] Initial plan From 5377f319d2d79b9f749878e18df507e6feef1173 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:07:46 +0000 Subject: [PATCH 10/10] Fix broken unit tests and add coverage for IncomingTask notification deletion Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../___tests__/maskIdentifiers/index.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts index 2044270c95..bec37dcf3c 100644 --- a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts +++ b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts @@ -16,7 +16,7 @@ /* eslint-disable camelcase */ -import { Manager } from '@twilio/flex-ui'; +import { Manager, Notifications } from '@twilio/flex-ui'; import each from 'jest-each'; import { maskConversationServiceUserNames, maskChannelStringsWithIdentifiers } from '../../maskIdentifiers'; @@ -36,8 +36,12 @@ jest.mock('@twilio/flex-ui', () => ({ Manager: { getInstance: jest.fn(), }, + Notifications: { + registeredNotifications: new Map(), + }, NotificationIds: { NewChatMessage: 'NewChatMessage', + IncomingTask: 'IncomingTask', }, DefaultTaskChannels: { ChatSms: { name: 'ChatSms' }, @@ -47,6 +51,8 @@ jest.mock('@twilio/flex-ui', () => ({ }, })); +const mockRegisteredNotifications = Notifications.registeredNotifications as Map; + const mockLookupTranslation = lookupTranslation as jest.MockedFunction; const mockGetInitializedCan = getInitializedCan as jest.MockedFunction; @@ -441,6 +447,8 @@ describe('maskChannelStringsWithIdentifiers', () => { mockGetInitializedCan.mockReturnValue(mockCan); mockManagerGetInstance.mockReturnValue({ strings: { MaskIdentifiers: 'MASKED' } } as any); mockLookupTranslation.mockReturnValue('Masked Title'); + mockRegisteredNotifications.clear(); + mockRegisteredNotifications.set('IncomingTask', {}); }); afterEach(() => { @@ -468,6 +476,12 @@ describe('maskChannelStringsWithIdentifiers', () => { expect(mockLookupTranslation).toHaveBeenCalledWith('BrowserNotification-ChatMessage-MaskedTitle'); expect(notification.options.browser.title).toBe('Masked Title'); }); + + test('deletes IncomingTask notification', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(mockRegisteredNotifications.has('IncomingTask')).toBe(false); + }); }); describe('when VIEW_IDENTIFIERS permission is granted', () => { @@ -480,5 +494,11 @@ describe('maskChannelStringsWithIdentifiers', () => { maskChannelStringsWithIdentifiers(channelType as any); expect(channelType.notifications.override.NewChatMessage).toBeUndefined(); }); + + test('does not delete IncomingTask notification', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(mockRegisteredNotifications.has('IncomingTask')).toBe(true); + }); }); });