diff --git a/.gitignore b/.gitignore index 358486b..1c91dbe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ node_modules dist coverage .project +.vscode build package-lock.json diff --git a/AppExamples/CleverDeal.React/package.json b/AppExamples/CleverDeal.React/package.json index d958ab9..d9ef2ec 100644 --- a/AppExamples/CleverDeal.React/package.json +++ b/AppExamples/CleverDeal.React/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@symphony-ui/uitoolkit-components": "^3.5.0", + "@tanstack/react-table": "^8.21.3", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^13.5.0", @@ -53,6 +54,10 @@ ] }, "devDependencies": { - "@types/react-tabs": "^2.3.4" + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/react-tabs": "^2.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16" } } diff --git a/AppExamples/CleverDeal.React/postcss.config.js b/AppExamples/CleverDeal.React/postcss.config.js new file mode 100644 index 0000000..07d5d79 --- /dev/null +++ b/AppExamples/CleverDeal.React/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: [ + require('tailwindcss'), + require('autoprefixer'), + ], +} diff --git a/AppExamples/CleverDeal.React/public/index.html b/AppExamples/CleverDeal.React/public/index.html index f838e99..f0844d7 100644 --- a/AppExamples/CleverDeal.React/public/index.html +++ b/AppExamples/CleverDeal.React/public/index.html @@ -25,6 +25,32 @@ Learn how to configure a non-root public URL by running `npm run build`. --> Clever Deal 2.0 + + diff --git a/AppExamples/CleverDeal.React/public/wealth-client-chat-host.html b/AppExamples/CleverDeal.React/public/wealth-client-chat-host.html new file mode 100644 index 0000000..675b31a --- /dev/null +++ b/AppExamples/CleverDeal.React/public/wealth-client-chat-host.html @@ -0,0 +1,318 @@ + + + + + + Wealth Client Chat Host + + + +
+ + + \ No newline at end of file diff --git a/AppExamples/CleverDeal.React/src/Components/CleverResearch/CleverResearch.scss b/AppExamples/CleverDeal.React/src/Components/CleverResearch/CleverResearch.scss index 8d8b055..e0fb0dd 100644 --- a/AppExamples/CleverDeal.React/src/Components/CleverResearch/CleverResearch.scss +++ b/AppExamples/CleverDeal.React/src/Components/CleverResearch/CleverResearch.scss @@ -17,62 +17,68 @@ background-color: var(--surface-color); > * { padding: 1.2rem } } -.header { - display: flex; - justify-content: space-between; - align-items: center; +.research-modal { + > .header { + display: flex; + justify-content: space-between; + align-items: center; + + .company-name { + font-size: 1.8rem; + font-weight: 700; + } - .company-name { - font-size: 1.8rem; - font-weight: 700; + .company-details { + font-size: 1.1rem; + display: grid; + grid-template-columns: 3.4rem 1fr 4.1rem 1fr; + gap: .2rem 1rem; + > *:nth-child(2n+1) { font-weight: 600 } + } } - .company-details { - font-size: 1.1rem; + > .grid { + flex: 1 1 1px; display: grid; - grid-template-columns: 3.4rem 1fr 4.1rem 1fr; - gap: .2rem 1rem; - > *:nth-child(2n+1) { font-weight: 600 } - } -} -.grid { - flex: 1 1 1px; - display: grid; - grid-template-rows: 1fr 2fr; - grid-template-columns: 1fr 1.2fr; - gap: 1rem; - > * { - display: flex; - flex-direction: column; - } -} -.table { - > * { - padding: .3rem; - > svg { - font-size: 1.3rem; - cursor: pointer; + grid-template-rows: 1fr 2fr; + grid-template-columns: 1fr 1.2fr; + gap: 1rem; + > * { + display: flex; + flex-direction: column; } } - gap: .5rem 0; - display: grid; - > *:nth-child(-n+4) { - background-color: rgba(150, 150, 150, .5); - font-weight: 700; - } - .badge { - color: black; - padding: .2rem; - border-radius: .2rem; - &.active { background-color: #B8E0D2 } - &.inactive { background-color: #EAC4D4 } + + .table { + > * { + padding: .3rem; + > svg { + font-size: 1.3rem; + cursor: pointer; + } + } + gap: .5rem 0; + display: grid; + > *:nth-child(-n+4) { + background-color: rgba(150, 150, 150, .5); + font-weight: 700; + } + .badge { + color: black; + padding: .2rem; + border-radius: .2rem; + &.active { background-color: #B8E0D2 } + &.inactive { background-color: #EAC4D4 } + } } + + .research-list .table { grid-template-columns: 3fr 1fr 1fr 2rem } + .client-list .table { grid-template-columns: 1.5fr 2fr 1fr 2rem } } -.research-list .table { grid-template-columns: 3fr 1fr 1fr 2rem } -.client-list .table { grid-template-columns: 1.5fr 2fr 1fr 2rem } + .research-root .ecp { display: flex; flex: 1 1 1px; outline: 1px solid grey; border-radius: 0.5rem; -} \ No newline at end of file +} diff --git a/AppExamples/CleverDeal.React/src/Components/LandingPage/LandingPage.tsx b/AppExamples/CleverDeal.React/src/Components/LandingPage/LandingPage.tsx index e78e2a6..e2b1266 100644 --- a/AppExamples/CleverDeal.React/src/Components/LandingPage/LandingPage.tsx +++ b/AppExamples/CleverDeal.React/src/Components/LandingPage/LandingPage.tsx @@ -6,10 +6,10 @@ export const LandingPage = () => { window.location.href = path; }; - const AppTile = ({ label, path, component } : AppEntry) => ( -
goto(path)}> + const AppTile = ({ label, path, component, enabled } : AppEntry) => ( +
goto(path)}>

{ label }

- { !component &&
Coming Soon
} + { !(enabled ?? !!component) &&
Coming Soon
}
); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/WealthManagement.test.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/WealthManagement.test.tsx new file mode 100644 index 0000000..daa6541 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/WealthManagement.test.tsx @@ -0,0 +1,533 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ThemeContext, type ThemeState } from '../../Theme/ThemeProvider'; +import { wealthManagementData } from './data/wealthManagement'; +import { WEALTH_SYMPHONY_THEME } from './chat/wealthSymphonyTheme'; +import WealthManagement from './WealthManagement'; + +type CountChangeCallback = (count: number) => void; +type StreamUnreadChangeCallback = (counts: Record) => void; + +const defaultNotificationDebugSnapshot = { + totalCount: 0, + globalUnreadCount: 0, + fallbackUnreadCount: 0, + origins: [], + subscriptions: [], + recentNotifications: [], + lastEventSummary: null, + lastEventAt: null, +}; + +const mockWindowSymphonyOpenStream = jest.fn(); +const mockWindowSymphonySendMessage = jest.fn(() => Promise.resolve()); +const mockSdkInit = jest.fn(() => Promise.resolve()) as unknown as jest.Mock< + Promise, + [string, string | undefined] +>; +const mockRenderChat = jest.fn(() => Promise.resolve()) as unknown as jest.Mock< + Promise, + [string, Record] +>; +const mockOpenStream = jest.fn(() => Promise.resolve()) as unknown as jest.Mock< + Promise, + [string, string, Record | undefined] +>; +const mockNotificationsInit = jest.fn(); +const mockApplyWealthSymphonyTheme = jest.fn(); +const mockRefreshWealthSymphonyThemeAfterLayoutChange = jest.fn(() => Promise.resolve()); +const mockReleaseWealthSymphonyThemeOwnership = jest.fn(); +const mockAcquireWealthSymphonyThemeOwnership = jest.fn(() => mockReleaseWealthSymphonyThemeOwnership); +const notificationListeners = new Set(); +const streamUnreadListeners = new Set(); +const notificationEventListeners = new Set<(event: { type: string; summary: string; receivedAt: number; payload: Record }) => void>(); +const mockOnCountChange = jest.fn((callback: CountChangeCallback) => { + notificationListeners.add(callback); + callback(mockNotificationCount); + return () => { + notificationListeners.delete(callback); + }; +}) as unknown as jest.Mock<() => void, [CountChangeCallback]>; +const mockOnNotificationEvent = jest.fn((callback: (event: { type: string; summary: string; receivedAt: number; payload: Record }) => void) => { + notificationEventListeners.add(callback); + return () => { + notificationEventListeners.delete(callback); + }; +}) as unknown as jest.Mock<() => void, [(event: { type: string; summary: string; receivedAt: number; payload: Record }) => void]>; +const mockOnStreamUnreadChange = jest.fn((callback: StreamUnreadChangeCallback) => { + streamUnreadListeners.add(callback); + callback(mockStreamUnreadCounts); + return () => { + streamUnreadListeners.delete(callback); + }; +}) as unknown as jest.Mock<() => void, [StreamUnreadChangeCallback]>; +const mockOnDebugChange = jest.fn((callback: (snapshot: typeof defaultNotificationDebugSnapshot) => void) => { + callback(defaultNotificationDebugSnapshot); + return () => {}; +}) as unknown as jest.Mock<() => void, [(snapshot: typeof defaultNotificationDebugSnapshot) => void]>; +const mockUseEcpSlot = jest.fn(); +const mockUseSharedWealthChatController = jest.fn(); +const mockUseSharedChatPresentationTransition = jest.fn(); +let mockNotificationCount = 0; +let mockStreamUnreadCounts: Record = {}; + +jest.mock('recharts', () => { + const Wrapper = () =>
; + const Leaf = () => null; + return { + ResponsiveContainer: Wrapper, + AreaChart: Wrapper, + PieChart: Wrapper, + Area: Leaf, + Pie: Wrapper, + Cell: Leaf, + CartesianGrid: Leaf, + XAxis: Leaf, + YAxis: Leaf, + Tooltip: Leaf, + }; +}); + +jest.mock('./chat/useSharedWealthChatController', () => ({ + useSharedWealthChatController: (options: Record) => mockUseSharedWealthChatController(options), +})); + +jest.mock('./chat/useSharedChatPresentationTransition', () => ({ + useSharedChatPresentationTransition: (options: Record) => + mockUseSharedChatPresentationTransition(options), +})); + +jest.mock('./chat/symphonyNotifications', () => ({ + symphonyNotifications: { + init: (ecpOrigin: string) => mockNotificationsInit(ecpOrigin), + onCountChange: (callback: CountChangeCallback) => mockOnCountChange(callback), + onNotificationEvent: (callback: (event: { type: string; summary: string; receivedAt: number; payload: Record }) => void) => mockOnNotificationEvent(callback), + onStreamUnreadChange: (callback: StreamUnreadChangeCallback) => mockOnStreamUnreadChange(callback), + onDebugChange: (callback: (snapshot: typeof defaultNotificationDebugSnapshot) => void) => mockOnDebugChange(callback), + get debugSnapshot() { + return defaultNotificationDebugSnapshot; + }, + get streamUnreadSnapshot() { + return mockStreamUnreadCounts; + }, + markMessagesViewed: jest.fn(), + get count() { + return mockNotificationCount; + }, + }, +})); + +jest.mock('./chat/useEcpSlot', () => ({ + useEcpSlot: (options: { slotName?: string }) => mockUseEcpSlot(options), +})); + +jest.mock('./chat/symphonySdk', () => ({ + symphonySdk: { + init: (ecpOrigin: string, partnerId?: string) => mockSdkInit(ecpOrigin, partnerId), + renderChat: (containerSelector: string, options: Record) => + mockRenderChat(containerSelector, options), + openStream: (streamId: string, containerSelector: string, renderOptions?: Record) => + mockOpenStream(streamId, containerSelector, renderOptions), + get isReady() { + return true; + }, + get error() { + return null; + }, + onStatusChange: (callback: (snapshot: { isReady: boolean; error: Error | null }) => void) => { + callback({ isReady: true, error: null }); + return () => {}; + }, + }, +})); + +jest.mock('./chat/wealthSymphonyTheme', () => { + const actual = jest.requireActual('./chat/wealthSymphonyTheme'); + return { + ...actual, + acquireWealthSymphonyThemeOwnership: () => mockAcquireWealthSymphonyThemeOwnership(), + applyWealthSymphonyTheme: () => mockApplyWealthSymphonyTheme(), + applyWealthSymphonyThemeWithSettle: () => { mockApplyWealthSymphonyTheme(); return Promise.resolve(); }, + refreshWealthSymphonyThemeAfterLayoutChange: () => mockRefreshWealthSymphonyThemeAfterLayoutChange(), + }; +}); + +const themeValue: ThemeState = { + theme: { + id: 'light', + name: 'Light', + colors: { + primary: '#0a2c63', + secondary: '#123b7a', + error: '#dc2626', + background: '#ffffff', + surface: '#f8fafc', + onPrimary: '#ffffff', + onSecondary: '#ffffff', + onBackground: '#0f172a', + onSurface: '#0f172a', + onError: '#ffffff', + symphonyMode: 'light', + }, + }, + themes: {}, + setTheme: jest.fn(), + applyTheme: jest.fn(), +}; + +function renderWealth(path: string) { + return render( + + + + } /> + Root Home
} /> + Legacy Wealth Home
} /> + + + , + ); +} + +beforeEach(() => { + mockNotificationCount = 0; + mockStreamUnreadCounts = {}; + notificationListeners.clear(); + streamUnreadListeners.clear(); + notificationEventListeners.clear(); + mockSdkInit.mockClear(); + mockRenderChat.mockClear(); + mockOpenStream.mockClear(); + mockWindowSymphonyOpenStream.mockClear(); + mockWindowSymphonySendMessage.mockClear(); + (window as any).symphony = { + openStream: mockWindowSymphonyOpenStream, + sendMessage: mockWindowSymphonySendMessage, + }; + mockNotificationsInit.mockClear(); + mockOnNotificationEvent.mockClear(); + mockOnStreamUnreadChange.mockClear(); + mockOnDebugChange.mockClear(); + mockApplyWealthSymphonyTheme.mockClear(); + mockRefreshWealthSymphonyThemeAfterLayoutChange.mockClear(); + mockReleaseWealthSymphonyThemeOwnership.mockClear(); + mockAcquireWealthSymphonyThemeOwnership.mockClear(); + mockAcquireWealthSymphonyThemeOwnership.mockImplementation(() => mockReleaseWealthSymphonyThemeOwnership); + mockUseSharedWealthChatController.mockReset(); + mockUseSharedWealthChatController.mockReturnValue({ + bootstrapError: null, + isBootstrapping: false, + isReady: true, + isSwitchingStream: false, + slotClassName: 'wealth-symphony-shared', + streamError: null, + }); + mockUseSharedChatPresentationTransition.mockReset(); + mockUseSharedChatPresentationTransition.mockImplementation(() => ({ + shellRef: { current: null }, + maskFrame: false, + })); + mockUseEcpSlot.mockReset(); + mockUseEcpSlot.mockImplementation((options: { slotName?: string }) => ({ + slotClassName: options?.slotName ?? 'wealth-symphony-client-contact', + slotRef: { current: null }, + ecpReady: true, + ecpError: null, + containerSelector: options?.slotName ? `.${options.slotName}` : '.wealth-symphony-client-contact', + })); +}); + +afterEach(() => { + jest.useRealTimers(); + delete (window as any).symphony; +}); + +test('renders the wealth dashboard shell and key KPI tiles', async () => { + renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + expect(screen.getByText('Hans Gruber')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Hans Gruber' })).toBeInTheDocument(); + expect(screen.getByText('Open Conversations')).toBeInTheDocument(); + expect(screen.queryByText('Unread Messages:')).not.toBeInTheDocument(); + expect(screen.getAllByText('E. Reed').length).toBeGreaterThanOrEqual(1); + expect(screen.getByRole('button', { name: 'Open Symphony chat' })).toBeInTheDocument(); + expect(screen.getByTestId('wealth-shared-chat-shell')).toBeInTheDocument(); + expect(screen.queryByText('Connecting to Symphony...')).not.toBeInTheDocument(); +}); + +test('shows the workspace loading overlay while Symphony bootstraps', async () => { + mockUseSharedWealthChatController.mockReturnValue({ + bootstrapError: null, + isBootstrapping: true, + isReady: false, + isSwitchingStream: false, + slotClassName: 'wealth-symphony-shared', + streamError: null, + }); + const { container } = renderWealth('/wealth-management'); + + expect(screen.getByLabelText('Loading wealth workspace')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="wealth-shared-chat-shell"]')).toBeInTheDocument(); + expect(container.querySelector('.wealth-symphony-shared')).toBeInTheDocument(); + expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument(); +}); + +test('routes to the shared chat page from the advisor menu and hides the floating launcher', async () => { + mockUseSharedChatPresentationTransition.mockImplementation(({ mode }: { mode: 'page' | 'drawer' }) => ({ + shellRef: { current: null }, + maskFrame: mode === 'page', + })); + renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Hans Gruber/i })); + await userEvent.click(screen.getByText('Open Symphony chat')); + + expect(await screen.findByRole('heading', { name: 'Wealth Chat' })).toBeInTheDocument(); + expect(screen.getByTestId('wealth-chat-frame-mask')).toBeInTheDocument(); + expect(screen.getByText('Loading chat')).toBeInTheDocument(); + expect(screen.queryByText('Active Clients')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open Symphony chat' })).not.toBeInTheDocument(); +}); + +test('shows the mounted drawer instantly without the first-open loader when shared chat is ready', async () => { + renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + expect(screen.getByTestId('wealth-shared-chat-shell')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Open Symphony chat' })); + + expect(await screen.findByText('Wealth Chat')).toBeInTheDocument(); + expect(screen.getByText('Symphony')).toBeInTheDocument(); + expect(screen.queryByText('Connecting to Symphony...')).not.toBeInTheDocument(); + const shell = screen.getByTestId('wealth-shared-chat-shell'); + expect(shell).toHaveStyle({ transform: 'translateX(0)' }); +}); + +test('opens the embedded client chat drawer when launching from the client list', async () => { + const evelynStreamId = (wealthManagementData.contacts ?? []).find((contact) => contact.name === 'Evelyn Reed')?.streamId; + + renderWealth('/wealth-management/clients'); + + expect(await screen.findByText(/Advisor coverage, relationship health/i)).toBeInTheDocument(); + await userEvent.type(screen.getByPlaceholderText('Search clients…'), 'Evelyn'); + const clientRowLabel = screen.getAllByText('Evelyn Reed').find((element) => element.closest('tr')); + expect(clientRowLabel).toBeTruthy(); + const clientRow = clientRowLabel?.closest('tr'); + expect(clientRow).not.toBeNull(); + + await userEvent.click(within(clientRow as HTMLElement).getByRole('button', { name: /chat/i })); + + expect(await screen.findByRole('heading', { name: 'Wealth Chat' })).toBeInTheDocument(); + const iframe = screen.getByTitle('Wealth client chat') as HTMLIFrameElement; + const iframeUrl = new URL(iframe.src); + expect(iframe).toBeInTheDocument(); + expect(iframe.src).toContain('/wealth-client-chat-host.html'); + expect(iframe.src).toContain(`streamId=${encodeURIComponent(evelynStreamId ?? '')}`); + expect(iframeUrl.searchParams.get('mode')).toBe('light'); + expect(JSON.parse(iframeUrl.searchParams.get('theme') ?? '{}')).toEqual(WEALTH_SYMPHONY_THEME); + expect(document.querySelector('.wealth-symphony-shared')).toBeInTheDocument(); + const latestControllerArgs = mockUseSharedWealthChatController.mock.calls.at(-1)?.[0] as { + requestedStreamId?: string; + }; + expect(latestControllerArgs.requestedStreamId).toBeUndefined(); + expect(screen.getAllByRole('button', { name: 'Close Symphony drawer' }).length).toBeGreaterThanOrEqual(2); + expect(screen.getByText(/Advisor coverage, relationship health/i)).toBeInTheDocument(); +}); + +test('renders the client detail page and opens the dedicated client slot stream', async () => { + const evelynStreamId = (wealthManagementData.contacts ?? []).find((contact) => contact.name === 'Evelyn Reed')?.streamId; + renderWealth('/wealth-management/clients/1'); + + expect(await screen.findByRole('heading', { name: 'Evelyn Reed' })).toBeInTheDocument(); + expect(screen.getByText('Embedded Communication Panel')).toBeInTheDocument(); + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + const iframe = screen.getByTitle('Wealth client chat') as HTMLIFrameElement; + const iframeUrl = new URL(iframe.src); + expect(iframe).toBeInTheDocument(); + expect(iframe.src).toContain('/wealth-client-chat-host.html'); + expect(iframe.src).toContain('ecpOrigin=corporate.symphony.com'); + expect(iframe.src).toContain(`streamId=${encodeURIComponent(evelynStreamId ?? '')}`); + expect(iframeUrl.searchParams.get('mode')).toBe('light'); + expect(JSON.parse(iframeUrl.searchParams.get('theme') ?? '{}')).toEqual(WEALTH_SYMPHONY_THEME); + expect(mockRenderChat).not.toHaveBeenCalledWith( + '.wealth-symphony-client-contact', + expect.objectContaining({ + streamId: evelynStreamId, + }), + ); + expect(screen.getByTestId('wealth-shared-chat-shell')).toBeInTheDocument(); +}); + +test('shares client documents into the shared Symphony chat as PDF attachments', async () => { + const evelynStreamId = (wealthManagementData.contacts ?? []).find((contact) => contact.name === 'Evelyn Reed')?.streamId; + renderWealth('/wealth-management/clients/1'); + + expect(await screen.findByRole('heading', { name: 'Evelyn Reed' })).toBeInTheDocument(); + + const iframe = screen.getByTitle('Wealth client chat') as HTMLIFrameElement; + const postMessage = jest.fn(); + Object.defineProperty(iframe, 'contentWindow', { + configurable: true, + value: { postMessage }, + }); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + data: { + source: 'wealth-client-chat-host', + type: 'ready', + payload: { + streamId: evelynStreamId, + }, + }, + }), + ); + }); + + await userEvent.click(screen.getByRole('button', { name: 'Share Q1 Allocation Memo.pdf to chat' })); + + await waitFor(() => { + expect(postMessage).toHaveBeenCalledWith( + { + source: 'wealth-client-chat-parent', + type: 'send-message', + payload: { + requestId: 'client-share-1', + documentName: 'Q1 Allocation Memo.pdf', + streamId: evelynStreamId, + message: { + text: { + 'text/markdown': 'Shared *Q1 Allocation Memo.pdf* with Evelyn Reed.\n\nType: Investment Memo\nUpdated: Mar 11', + }, + entities: { + report: { + type: 'fdc3.fileAttachment', + data: { + name: 'Q1 Allocation Memo.pdf', + dataUri: wealthManagementData.pdfFile, + }, + }, + }, + }, + }, + }, + window.location.origin, + ); + }); +}); + +test('surfaces the shared chat bootstrap error as a workspace overlay', async () => { + mockUseSharedWealthChatController.mockReturnValue({ + bootstrapError: new Error('Shared collaboration render failed.'), + isBootstrapping: false, + isReady: false, + isSwitchingStream: false, + slotClassName: 'wealth-symphony-shared', + streamError: null, + }); + + renderWealth('/wealth-management/chat'); + + expect(await screen.findByRole('heading', { name: 'Wealth Chat' })).toBeInTheDocument(); + expect(screen.queryByText('Active Clients')).not.toBeInTheDocument(); + expect(await screen.findByText('Unable to load Symphony chat')).toBeInTheDocument(); + expect(screen.getByText('Shared collaboration render failed.')).toBeInTheDocument(); +}); + +test('shows a red unread count badge on the top notifications bell', async () => { + mockNotificationCount = 3; + renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + const notificationsButton = await screen.findByLabelText('Notifications (3 unread)'); + + expect(notificationsButton).toBeInTheDocument(); + expect(notificationsButton.parentElement).toHaveTextContent('3'); + expect(screen.getByText('Unread Messages')).toBeInTheDocument(); + expect(screen.getByText('Unread Messages').parentElement).toHaveTextContent('3Unread Messages'); +}); + +test('shows the Symphony notification menu under the bell icon', async () => { + mockNotificationCount = 2; + renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + await waitFor(() => expect(mockOnNotificationEvent).toHaveBeenCalled()); + + act(() => { + notificationEventListeners.forEach((callback) => { + callback({ + type: 'MessageNotifications', + summary: 'Reed Family Office: Hans Gruber', + receivedAt: 1711111111111, + payload: { + streamName: 'Reed Family Office', + fromWhomName: 'Hans Gruber', + message: 'Quarterly review deck is ready.', + }, + }); + }); + }); + + await userEvent.click(screen.getByLabelText('Notifications (2 unread)')); + + expect(screen.getByText('Recent Notifications')).toBeInTheDocument(); + expect(screen.getByText('2 unread in Symphony')).toBeInTheDocument(); + expect(screen.getByText('Hans Gruber')).toBeInTheDocument(); +}); + +test('shows unread client indicators on the client list page', async () => { + const evelynStreamId = (wealthManagementData.contacts ?? []).find((contact) => contact.name === 'Evelyn Reed')?.streamId; + mockStreamUnreadCounts = { + [evelynStreamId ?? '']: 2, + }; + + renderWealth('/wealth-management/clients'); + + expect(await screen.findByText(/Advisor coverage, relationship health/i)).toBeInTheDocument(); + + const evelynLabel = screen.getAllByText('Evelyn Reed').find((element) => element.closest('tr')); + expect(evelynLabel).toBeTruthy(); + const evelynRow = evelynLabel?.closest('tr') as HTMLElement; + + await waitFor(() => { + expect(within(evelynRow).getByText(/2 unread/i)).toBeInTheDocument(); + expect(within(evelynRow).getByRole('button', { name: /chat/i })).toHaveTextContent('2'); + }); +}); + +test('returns to the root page from the header home button', async () => { + renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Return to wealth home' })); + + expect(await screen.findByText('Root Home')).toBeInTheDocument(); +}); + +test('leaves no visible drawer shell when navigating from chat back to the homepage', async () => { + renderWealth('/wealth-management/chat'); + + expect(await screen.findByRole('heading', { name: 'Wealth Chat' })).toBeInTheDocument(); + expect(screen.queryByText('Active Clients')).not.toBeInTheDocument(); + await userEvent.click(screen.getAllByRole('button', { name: 'Dashboard' })[0]); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close Symphony drawer' })).not.toBeInTheDocument(); +}); + +test('releases wealth Symphony theme ownership when the mini app unmounts', async () => { + const { unmount } = renderWealth('/wealth-management'); + + expect(await screen.findByText('Active Clients')).toBeInTheDocument(); + + unmount(); + + expect(mockReleaseWealthSymphonyThemeOwnership).toHaveBeenCalledTimes(1); +}); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/WealthManagement.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/WealthManagement.tsx new file mode 100644 index 0000000..b37a73c --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/WealthManagement.tsx @@ -0,0 +1,622 @@ +import { Suspense, lazy, useCallback, useEffect, useState } from 'react'; +import { AlertCircle, Bell, House, Menu, Search } from 'lucide-react'; +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { getEcpParam } from '../../Utils/utils'; +import Loading from '../Loading'; +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; +import { Button } from './ui/button'; +import { + DropdownMenu, + DropdownMenuChevron, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; +import { cn } from './ui/utils'; +import SymphonyChatShell from './components/SymphonyChatShell'; +import SymphonyMark from './components/SymphonyMark'; +import { symphonyNotifications } from './chat/symphonyNotifications'; +import { acquireWealthSymphonyThemeOwnership, applyWealthSymphonyTheme } from './chat/wealthSymphonyTheme'; +import { useSharedChatPresentationTransition } from './chat/useSharedChatPresentationTransition'; +import { wealthManagementShellData } from './data/wealthManagementShell'; +import { useSharedWealthChatController } from './chat/useSharedWealthChatController'; +import ModulePlaceholderPage from './pages/ModulePlaceholderPage'; +import './styles/wealthManagement.css'; + +const DashboardPage = lazy(() => import('./pages/DashboardPage')); +const ContactsPage = lazy(() => import('./pages/ContactsPage')); +const ClientDetailPage = lazy(() => import('./pages/ClientDetailPage')); + +const ecpOrigin = getEcpParam('ecpOrigin') || 'corporate.symphony.com'; +const partnerId = getEcpParam('partnerId') ?? undefined; + +const NAV_ITEMS = [ + { id: 'dashboard', label: 'Dashboard', href: '/wealth-management' }, + { id: 'clients', label: 'Clients', href: '/wealth-management/clients' }, + { id: 'accounts', label: 'Accounts', href: '/wealth-management/accounts' }, + { id: 'reporting', label: 'Reporting', href: '/wealth-management/reporting' }, + { id: 'tools', label: 'Tools', href: '/wealth-management/tools' }, + { id: 'chat', label: 'Chat', href: '/wealth-management/chat' }, +]; + +const RESIZE_OBSERVER_MESSAGES = new Set([ + 'ResizeObserver loop completed with undelivered notifications.', + 'ResizeObserver loop limit exceeded', +]); + +type NotificationToast = { + id: string; + type: 'message' | 'count'; + senderName: string; + roomName: string; + preview: string; + receivedAt: number; + avatarUrl?: string; +}; + +function LargeLoading() { + return ( +
+
+ +
+
+ ); +} + +function LargeErrorState({ message }: { message: string }) { + return ( +
+
+
+ + Unable to load Symphony chat +
+
{message}
+
+
+ ); +} + +function WealthPageFallback() { + return
; +} + +function withWealthPageSuspense(element: React.ReactElement) { + return ( + }> + {element} + + ); +} + +function getInitials(name: string) { + return name + .trim() + .split(/\s+/) + .map((part) => part[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + .toUpperCase(); +} + +function getActiveItem(pathname: string) { + if (pathname.includes('/clients')) return 'clients'; + if (pathname.includes('/contacts')) return 'clients'; + if (pathname.includes('/accounts')) return 'accounts'; + if (pathname.includes('/reporting')) return 'reporting'; + if (pathname.includes('/tools')) return 'tools'; + if (pathname.includes('/chat')) return 'chat'; + return 'dashboard'; +} + +function resolveNotificationAvatar(senderName: string) { + if (senderName === wealthManagementShellData.customer.name) { + return wealthManagementShellData.customer.avatarUrl; + } + + const contact = (wealthManagementShellData.contacts ?? []).find((item) => item.name === senderName); + return contact?.avatarUrl; +} + +function formatNotification(event: { type: 'GlobalUnreadCountNotifications' | 'MessageNotifications'; summary: string; receivedAt: number; payload: Record }): NotificationToast { + if (event.type === 'MessageNotifications') { + const senderName = typeof event.payload.fromWhomName === 'string' ? event.payload.fromWhomName : 'Unknown sender'; + const roomName = typeof event.payload.streamName === 'string' ? event.payload.streamName : 'Unknown conversation'; + const preview = typeof event.payload.message === 'string' + ? event.payload.message + : `New message in ${roomName}`; + + return { + id: `${event.type}-${event.receivedAt}`, + type: 'message', + senderName, + roomName, + preview, + receivedAt: event.receivedAt, + avatarUrl: resolveNotificationAvatar(senderName), + }; + } + + return { + id: `${event.type}-${event.receivedAt}`, + type: 'count', + senderName: 'Symphony', + roomName: 'All conversations', + preview: event.summary, + receivedAt: event.receivedAt, + }; +} + +const WealthManagement = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [isSymphonyDrawerOpen, setIsSymphonyDrawerOpen] = useState(false); + const [unreadCount, setUnreadCount] = useState(symphonyNotifications.count); + const [recentNotifications, setRecentNotifications] = useState([]); + const activeItem = getActiveItem(location.pathname); + const isChatRoute = activeItem === 'chat'; + const chatContactId = new URLSearchParams(location.search).get('contactId'); + const chatContact = (wealthManagementShellData.contacts ?? []).find((item) => item.id === chatContactId); + const usesEmbeddedClientDrawer = activeItem === 'clients' && Boolean(chatContactId) && !isChatRoute; + const activeChatStreamId = chatContact?.streamId; + const activeChatMode = isChatRoute ? 'page' : 'drawer'; + const { + bootstrapError, + isBootstrapping, + isSwitchingStream, + slotClassName, + streamError, + } = useSharedWealthChatController({ + ecpOrigin, + partnerId, + requestedStreamId: usesEmbeddedClientDrawer ? undefined : activeChatStreamId, + }); + const isSharedChatVisible = !isBootstrapping && (isChatRoute || isSymphonyDrawerOpen); + const { shellRef, maskFrame } = useSharedChatPresentationTransition({ + mode: activeChatMode, + isReady: !isBootstrapping && !bootstrapError, + isVisible: isSharedChatVisible, + }); + + useEffect(() => symphonyNotifications.onCountChange(setUnreadCount), []); + + useEffect(() => { + const unsubscribe = symphonyNotifications.onNotificationEvent?.((event) => { + if (event.type !== 'MessageNotifications') { + return; + } + + const notification = formatNotification(event); + setRecentNotifications((current) => [notification, ...current].slice(0, 8)); + }); + + return typeof unsubscribe === 'function' ? unsubscribe : undefined; + }, []); + + useEffect(() => { + const releaseThemeOwnership = acquireWealthSymphonyThemeOwnership(); + return typeof releaseThemeOwnership === 'function' ? releaseThemeOwnership : undefined; + }, []); + + useEffect(() => { + if (isSymphonyDrawerOpen && activeChatMode === 'drawer') { + applyWealthSymphonyTheme(); + } + }, [isSymphonyDrawerOpen, activeChatMode]); + + useEffect(() => { + if (!isBootstrapping && !bootstrapError) { + applyWealthSymphonyTheme(); + } + }, [location.pathname, isBootstrapping, bootstrapError]); + + useEffect(() => { + if (process.env.NODE_ENV === 'production') { + return; + } + + const suppressResizeObserverError = (event: ErrorEvent) => { + if (!RESIZE_OBSERVER_MESSAGES.has(event.message)) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + window.addEventListener('error', suppressResizeObserverError, true); + + return () => { + window.removeEventListener('error', suppressResizeObserverError, true); + }; + }, []); + + useEffect(() => { + if (!isSymphonyDrawerOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsSymphonyDrawerOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isSymphonyDrawerOpen]); + + useEffect(() => { + if (isChatRoute && isSymphonyDrawerOpen) { + setIsSymphonyDrawerOpen(false); + } + }, [isChatRoute, isSymphonyDrawerOpen]); + + useEffect(() => { + if (isChatRoute) { + return; + } + + if (activeItem === 'clients' && chatContactId) { + setIsSymphonyDrawerOpen(true); + return; + } + + setIsSymphonyDrawerOpen(false); + }, [activeItem, chatContactId, isChatRoute]); + + useEffect(() => { + if (!isSharedChatVisible) { + return; + } + + symphonyNotifications.markMessagesViewed?.(activeChatStreamId); + }, [activeChatStreamId, isSharedChatVisible]); + + const openClientQuickChat = useCallback((contactId: string) => { + navigate(`/wealth-management/clients?contactId=${contactId}`); + }, [navigate]); + + const closeSharedChat = useCallback(() => { + if (activeChatMode === 'page') { + return; + } + + if (activeItem === 'clients' && chatContactId) { + navigate('/wealth-management/clients', { replace: true }); + return; + } + + setIsSymphonyDrawerOpen(false); + }, [activeChatMode, activeItem, chatContactId, navigate]); + + return ( +
+
+ {isSymphonyDrawerOpen && !isChatRoute && ( +
+ + <> +
+
+
+ + + + + + +
+
Nakatomi Wealth CRM
+
Enterprise wealth workflow powered by Symphony
+
+
+ + {NAV_ITEMS.map((item) => ( + + ))} +
+
+
+ +
+ +
+ +
+ +
+ + + +
+ + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} +
+
+ +
+
Recent Notifications
+
+ {unreadCount > 0 ? `${unreadCount > 99 ? '99+' : unreadCount} unread in Symphony` : 'All conversations are up to date'} +
+
+
+ {recentNotifications.length > 0 ? recentNotifications.map((notification) => ( +
+
+ + {notification.avatarUrl ? : null} + + {getInitials(notification.senderName)} + + +
+
+
+
{notification.senderName}
+
+ + {notification.roomName} + +
+
+
{new Date(notification.receivedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
+
+
{notification.preview}
+
+
+
+ )) : ( +
+ No recent Symphony notifications. +
+ )} +
+
+
+ + + + + + + Advisor Profile + navigate('/wealth-management/chat')}>Open Symphony chat + + Settings + + +
+
+
+ +
+
+
+ + + +
+ )} + /> + )} /> + )} /> + } /> + )} /> + } /> + + )} + /> + + )} + /> + + )} + /> + } /> + } /> + +
+
+ + + {!isChatRoute && activeItem !== 'clients' && ( + + )} + + {isBootstrapping && } + {bootstrapError && } +
+ ); +}; + +export default WealthManagement; +export { WealthManagement }; diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Amelia_Chen.png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Amelia_Chen.png new file mode 100644 index 0000000..e7caefa Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Amelia_Chen.png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Evelyn_Reed.png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Evelyn_Reed.png new file mode 100644 index 0000000..5997bb9 Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Evelyn_Reed.png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Faye_Zhang.png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Faye_Zhang.png new file mode 100644 index 0000000..e7b5adb Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Faye_Zhang.png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Hans_Gruber.png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Hans_Gruber.png new file mode 100644 index 0000000..2815ac7 Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Hans_Gruber.png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Jonathan_Smith.png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Jonathan_Smith.png new file mode 100644 index 0000000..58817c6 Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Jonathan_Smith.png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Raj_Patel.png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Raj_Patel.png new file mode 100644 index 0000000..9d0a975 Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/Raj_Patel.png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/SYM-S-logo-640 (1).png b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/SYM-S-logo-640 (1).png new file mode 100644 index 0000000..6f258dc Binary files /dev/null and b/AppExamples/CleverDeal.React/src/Components/WealthManagement/assets/SYM-S-logo-640 (1).png differ diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonyNotifications.test.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonyNotifications.test.ts new file mode 100644 index 0000000..6b93939 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonyNotifications.test.ts @@ -0,0 +1,254 @@ +import { SymphonyNotificationsService } from './symphonyNotifications'; + +type NotificationCallback = (notification: Record) => void; + +describe('SymphonyNotificationsService', () => { + beforeEach(() => { + delete (window as any).symphony; + }); + + afterEach(() => { + delete (window as any).symphony; + }); + + test('subscribes once to the global unread and message notification channels', () => { + const listenMock = jest.fn(); + const service = new SymphonyNotificationsService(); + + (window as any).symphony = { + listen: listenMock, + }; + + service.init('corporate.symphony.com'); + service.init('corporate.symphony.com'); + + expect(listenMock).toHaveBeenCalledTimes(2); + expect(listenMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: 'GlobalUnreadCountNotifications', + }), + ); + expect(listenMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'MessageNotifications', + }), + ); + }); + + test('uses the global unread count subscription for the total unread metric', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + const listener = jest.fn(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.onCountChange(listener); + service.init('corporate.symphony.com'); + + callbacks.get('GlobalUnreadCountNotifications')?.({ count: 3 }); + callbacks.get('GlobalUnreadCountNotifications')?.({ count: 5 }); + + expect(listener).toHaveBeenNthCalledWith(1, 0); + expect(listener).toHaveBeenNthCalledWith(2, 3); + expect(listener).toHaveBeenNthCalledWith(3, 5); + expect(service.count).toBe(5); + }); + + test('falls back to message notifications before the first global unread baseline arrives', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + const listener = jest.fn(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.onCountChange(listener); + service.init('corporate.symphony.com'); + + callbacks.get('MessageNotifications')?.({ streamId: 'stream-1' }); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-2' }); + + expect(service.count).toBe(2); + expect(service.debugSnapshot.globalUnreadCount).toBe(0); + expect(service.debugSnapshot.fallbackUnreadCount).toBe(2); + expect(listener).toHaveBeenLastCalledWith(2); + }); + + test('treats the global unread count as authoritative once the baseline arrives', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.init('corporate.symphony.com'); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-1' }); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-2' }); + + expect(service.count).toBe(2); + + callbacks.get('GlobalUnreadCountNotifications')?.({ count: 0 }); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-3' }); + + expect(service.count).toBe(0); + expect(service.debugSnapshot.globalUnreadCount).toBe(0); + expect(service.debugSnapshot.fallbackUnreadCount).toBe(1); + expect(service.streamUnreadSnapshot).toEqual({ 'stream-3': 1 }); + }); + + test('emits unread events only after the initial global unread baseline callback', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + const unreadListener = jest.fn(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.onUnreadEvent(unreadListener); + service.init('corporate.symphony.com'); + + callbacks.get('GlobalUnreadCountNotifications')?.({ count: 2 }); + callbacks.get('GlobalUnreadCountNotifications')?.({ count: 5 }); + + expect(unreadListener).toHaveBeenCalledTimes(1); + expect(unreadListener).toHaveBeenCalledWith( + expect.objectContaining({ + streamId: 'global', + previousCount: 2, + count: 5, + delta: 3, + }), + ); + }); + + test('publishes debug snapshots for listener registration and unread updates', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + const debugListener = jest.fn(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.onDebugChange(debugListener); + service.init('corporate.symphony.com'); + callbacks.get('GlobalUnreadCountNotifications')?.({ count: 4 }); + callbacks.get('MessageNotifications')?.({ streamName: 'Platform Room', fromWhomName: 'System Bot' }); + + expect(debugListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + totalCount: 4, + globalUnreadCount: 4, + fallbackUnreadCount: 0, + lastEventSummary: expect.stringContaining('Platform Room'), + origins: expect.arrayContaining([ + expect.objectContaining({ + ecpOrigin: 'corporate.symphony.com', + status: 'listening', + }), + ]), + subscriptions: expect.arrayContaining([ + expect.objectContaining({ + id: 'global-unread-count', + lastPayloadSummary: 'count=4', + }), + expect.objectContaining({ + id: 'global-message-notifications', + lastPayloadSummary: expect.stringContaining('Platform Room'), + }), + ]), + recentNotifications: expect.arrayContaining([ + expect.objectContaining({ + type: 'GlobalUnreadCountNotifications', + }), + expect.objectContaining({ + type: 'MessageNotifications', + }), + ]), + }), + ); + }); + + test('retries initialization until the Symphony unread listener becomes available', () => { + jest.useFakeTimers(); + const listenMock = jest.fn(); + const service = new SymphonyNotificationsService(); + + (window as any).symphony = {}; + service.init('corporate.symphony.com'); + + expect(listenMock).not.toHaveBeenCalled(); + + (window as any).symphony = { + listen: listenMock, + }; + + jest.runOnlyPendingTimers(); + + expect(listenMock).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + test('clears the fallback unread count after the user reviews chat', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.init('corporate.symphony.com'); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-1' }); + + expect(service.count).toBe(1); + + service.markMessagesViewed(); + + expect(service.count).toBe(0); + expect(service.debugSnapshot.fallbackUnreadCount).toBe(0); + }); + + test('tracks unread counts per stream and clears a viewed stream independently', () => { + const callbacks = new Map(); + const service = new SymphonyNotificationsService(); + const streamListener = jest.fn(); + + (window as any).symphony = { + listen: jest.fn(({ type, callback }: { type: string; callback: NotificationCallback }) => { + callbacks.set(type, callback); + }), + }; + + service.onStreamUnreadChange(streamListener); + service.init('corporate.symphony.com'); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-1' }); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-1' }); + callbacks.get('MessageNotifications')?.({ streamId: 'stream-2' }); + + expect(service.streamUnreadSnapshot).toEqual({ 'stream-1': 2, 'stream-2': 1 }); + + service.markMessagesViewed('stream-1'); + + expect(service.streamUnreadSnapshot).toEqual({ 'stream-2': 1 }); + expect(streamListener).toHaveBeenLastCalledWith({ 'stream-2': 1 }); + }); +}); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonyNotifications.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonyNotifications.ts new file mode 100644 index 0000000..ad69bac --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonyNotifications.ts @@ -0,0 +1,462 @@ +type CountListener = (count: number) => void; +type DebugListener = (snapshot: SymphonyNotificationDebugSnapshot) => void; +type UnreadEventListener = (event: SymphonyUnreadNotificationEvent) => void; +type NotificationEventListener = (event: SymphonyNotificationEvent) => void; +type StreamUnreadListener = (counts: SymphonyStreamUnreadCounts) => void; + +type NotificationOriginStatus = 'idle' | 'retrying' | 'listening' | 'error'; +type NotificationSubscriptionStatus = 'pending' | 'listening' | 'ready' | 'error'; + +interface NotificationOriginDebugState { + ecpOrigin: string; + status: NotificationOriginStatus; + attempts: number; + lastError: string | null; +} + +interface NotificationSubscriptionDebugState { + id: string; + type: string; + label: string; + status: NotificationSubscriptionStatus; + updates: number; + lastUpdatedAt: number | null; + lastPayloadSummary: string | null; + lastError: string | null; +} + +interface NotificationRecord { + id: string; + type: string; + summary: string; + receivedAt: number; +} + +export interface SymphonyNotificationDebugSnapshot { + totalCount: number; + globalUnreadCount: number; + fallbackUnreadCount: number; + origins: Array; + subscriptions: Array; + recentNotifications: Array; + lastEventSummary: string | null; + lastEventAt: number | null; +} + +export interface SymphonyUnreadNotificationEvent { + streamId: string; + label: string; + count: number; + previousCount: number; + delta: number; + receivedAt: number; +} + +export interface SymphonyNotificationEvent { + type: 'GlobalUnreadCountNotifications' | 'MessageNotifications'; + summary: string; + receivedAt: number; + payload: Record; +} + +export type SymphonyStreamUnreadCounts = Record; + +export class SymphonyNotificationsService { + private _count = 0; + private _globalUnreadCount = 0; + private _fallbackUnreadCount = 0; + private _hasGlobalUnreadBaseline = false; + private _listeners = new Set(); + private _debugListeners = new Set(); + private _unreadEventListeners = new Set(); + private _notificationEventListeners = new Set(); + private _streamUnreadListeners = new Set(); + private _initializedOrigins = new Set(); + private _originDebug = new Map(); + private _subscriptionDebug = new Map(); + private _retryCounts = new Map(); + private _retryTimers = new Map(); + private _streamUnreadCounts = new Map(); + private _recentNotifications: NotificationRecord[] = []; + private _lastEventSummary: string | null = null; + private _lastEventAt: number | null = null; + + private _debug(message: string, context?: Record) { + if (process.env.NODE_ENV === 'production') { + return; + } + + if (context) { + console.info(`[Wealth Symphony] ${message}`, context); + return; + } + + console.info(`[Wealth Symphony] ${message}`); + } + + private _recomputeCount() { + this._count = this._hasGlobalUnreadBaseline ? this._globalUnreadCount : this._fallbackUnreadCount; + } + + private _emitCount() { + this._listeners.forEach((listener) => listener(this._count)); + } + + private _emitDebug() { + const snapshot = this.debugSnapshot; + this._debugListeners.forEach((listener) => listener(snapshot)); + } + + private _emitUnreadEvent(event: SymphonyUnreadNotificationEvent) { + this._unreadEventListeners.forEach((listener) => listener(event)); + } + + private _emitNotificationEvent(event: SymphonyNotificationEvent) { + this._notificationEventListeners.forEach((listener) => listener(event)); + } + + private _emitStreamUnreadCounts() { + const snapshot = this.streamUnreadSnapshot; + this._streamUnreadListeners.forEach((listener) => listener(snapshot)); + } + + private _recomputeFallbackUnreadCount() { + this._fallbackUnreadCount = Array.from(this._streamUnreadCounts.values()).reduce((total, count) => total + count, 0); + } + + private _emitUnreadState() { + this._recomputeFallbackUnreadCount(); + this._recomputeCount(); + this._emitCount(); + this._emitStreamUnreadCounts(); + this._emitDebug(); + } + + private _incrementStreamUnread(streamId: string) { + const current = this._streamUnreadCounts.get(streamId) ?? 0; + this._streamUnreadCounts.set(streamId, current + 1); + this._emitUnreadState(); + } + + private _clearStreamUnread(streamId: string) { + if (!this._streamUnreadCounts.has(streamId)) { + return; + } + + this._streamUnreadCounts.delete(streamId); + this._emitUnreadState(); + } + + private _clearAllStreamUnread() { + if (this._streamUnreadCounts.size === 0) { + return; + } + + this._streamUnreadCounts.clear(); + this._emitUnreadState(); + } + + private _updateOriginDebug(ecpOrigin: string, updates: Partial) { + const current = this._originDebug.get(ecpOrigin) ?? { + ecpOrigin, + status: 'idle', + attempts: 0, + lastError: null, + }; + + this._originDebug.set(ecpOrigin, { ...current, ...updates }); + this._emitDebug(); + } + + private _updateSubscriptionDebug(id: string, type: string, label: string, updates: Partial) { + const current = this._subscriptionDebug.get(id) ?? { + id, + type, + label, + status: 'pending', + updates: 0, + lastUpdatedAt: null, + lastPayloadSummary: null, + lastError: null, + }; + + this._subscriptionDebug.set(id, { ...current, type, label, ...updates }); + this._emitDebug(); + } + + private _recordNotification(type: SymphonyNotificationEvent['type'], summary: string, receivedAt: number) { + this._recentNotifications = [ + { + id: `${type}-${receivedAt}`, + type, + summary, + receivedAt, + }, + ...this._recentNotifications, + ].slice(0, 12); + this._emitDebug(); + } + + private _applyCountUpdate(nextGlobalUnreadCount: number) { + const hadGlobalBaseline = this._hasGlobalUnreadBaseline; + this._hasGlobalUnreadBaseline = true; + this._globalUnreadCount = nextGlobalUnreadCount; + + if (!hadGlobalBaseline || nextGlobalUnreadCount === 0) { + this._streamUnreadCounts.clear(); + } + + this._emitUnreadState(); + } + + init(ecpOrigin: string) { + if (this._initializedOrigins.has(ecpOrigin)) { + this._emitDebug(); + return; + } + + const symphony = window.symphony; + const listen = symphony?.listen; + if (!listen) { + const attempts = (this._retryCounts.get(ecpOrigin) ?? 0) + 1; + this._retryCounts.set(ecpOrigin, attempts); + this._updateOriginDebug(ecpOrigin, { + attempts, + status: 'retrying', + lastError: 'window.symphony.listen is not available yet.', + }); + + if (attempts <= 10) { + const existingTimer = this._retryTimers.get(ecpOrigin); + if (existingTimer) { + window.clearTimeout(existingTimer); + } + + this._debug('Unread listener not ready yet, retrying.', { ecpOrigin, attempts }); + const timer = window.setTimeout(() => { + this._retryTimers.delete(ecpOrigin); + this.init(ecpOrigin); + }, 250); + this._retryTimers.set(ecpOrigin, timer); + } + + return; + } + + const existingTimer = this._retryTimers.get(ecpOrigin); + if (existingTimer) { + window.clearTimeout(existingTimer); + this._retryTimers.delete(ecpOrigin); + } + this._retryCounts.delete(ecpOrigin); + this._initializedOrigins.add(ecpOrigin); + this._updateOriginDebug(ecpOrigin, { + attempts: 0, + status: 'listening', + lastError: null, + }); + + this._debug('Initializing global notification listeners.', { ecpOrigin }); + + const subscriptions = [ + { + id: 'global-unread-count', + type: 'GlobalUnreadCountNotifications' as const, + label: 'All unread counts', + }, + { + id: 'global-message-notifications', + type: 'MessageNotifications' as const, + label: 'All message notifications', + }, + ]; + + subscriptions.forEach((subscription) => { + this._updateSubscriptionDebug(subscription.id, subscription.type, subscription.label, { + status: 'listening', + lastError: null, + }); + + try { + listen({ + type: subscription.type, + callback: (payload) => { + const receivedAt = Date.now(); + const current = this._subscriptionDebug.get(subscription.id); + const updates = (current?.updates ?? 0) + 1; + + if (subscription.type === 'GlobalUnreadCountNotifications') { + const previousCount = this._count; + const nextCount = Number(payload.count ?? 0); + this._lastEventSummary = `All conversations: ${nextCount} unread`; + this._lastEventAt = receivedAt; + this._debug('Received global unread count update.', { + count: nextCount, + previousCount, + }); + this._recordNotification(subscription.type, this._lastEventSummary, receivedAt); + this._updateSubscriptionDebug(subscription.id, subscription.type, subscription.label, { + status: 'ready', + updates, + lastUpdatedAt: receivedAt, + lastPayloadSummary: `count=${nextCount}`, + lastError: null, + }); + this._applyCountUpdate(nextCount); + this._emitNotificationEvent({ + type: subscription.type, + summary: this._lastEventSummary, + receivedAt, + payload, + }); + + if ((current?.updates ?? 0) > 0 && nextCount > previousCount) { + this._emitUnreadEvent({ + streamId: 'global', + label: 'All conversations', + count: nextCount, + previousCount, + delta: nextCount - previousCount, + receivedAt, + }); + } + + return; + } + + const streamName = typeof payload.streamName === 'string' ? payload.streamName : 'Unknown conversation'; + const fromWhomName = typeof payload.fromWhomName === 'string' ? payload.fromWhomName : 'Unknown sender'; + const summary = `${streamName}: ${fromWhomName}`; + const previousCount = this._count; + const streamId = typeof payload.streamId === 'string' ? payload.streamId : undefined; + this._lastEventSummary = summary; + this._lastEventAt = receivedAt; + this._debug('Received message notification.', payload); + this._recordNotification(subscription.type, summary, receivedAt); + this._updateSubscriptionDebug(subscription.id, subscription.type, subscription.label, { + status: 'ready', + updates, + lastUpdatedAt: receivedAt, + lastPayloadSummary: summary, + lastError: null, + }); + if (streamId) { + this._incrementStreamUnread(streamId); + } + this._emitNotificationEvent({ + type: subscription.type, + summary, + receivedAt, + payload, + }); + if (this._count > previousCount) { + this._emitUnreadEvent({ + streamId: streamId ?? 'global-fallback', + label: streamName, + count: this._count, + previousCount, + delta: this._count - previousCount, + receivedAt, + }); + } + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown notification listener error.'; + this._updateOriginDebug(ecpOrigin, { + status: 'error', + lastError: message, + }); + this._updateSubscriptionDebug(subscription.id, subscription.type, subscription.label, { + status: 'error', + lastError: message, + }); + } + }); + } + + onCountChange(listener: CountListener) { + this._listeners.add(listener); + listener(this._count); + return () => { + this._listeners.delete(listener); + }; + } + + onDebugChange(listener: DebugListener) { + this._debugListeners.add(listener); + listener(this.debugSnapshot); + return () => { + this._debugListeners.delete(listener); + }; + } + + onUnreadEvent(listener: UnreadEventListener) { + this._unreadEventListeners.add(listener); + return () => { + this._unreadEventListeners.delete(listener); + }; + } + + onNotificationEvent(listener: NotificationEventListener) { + this._notificationEventListeners.add(listener); + return () => { + this._notificationEventListeners.delete(listener); + }; + } + + onStreamUnreadChange(listener: StreamUnreadListener) { + this._streamUnreadListeners.add(listener); + listener(this.streamUnreadSnapshot); + return () => { + this._streamUnreadListeners.delete(listener); + }; + } + + markMessagesViewed(streamId?: string) { + if (streamId) { + if (!this._streamUnreadCounts.has(streamId)) { + return; + } + + this._debug('Clearing unread count for viewed chat stream.', { + streamId, + unreadCount: this._streamUnreadCounts.get(streamId), + }); + this._clearStreamUnread(streamId); + return; + } + + if (this._streamUnreadCounts.size === 0) { + return; + } + + this._debug('Clearing unread counts after chat review.', { + fallbackUnreadCount: this._fallbackUnreadCount, + }); + this._clearAllStreamUnread(); + } + + get count() { + return this._count; + } + + get streamUnreadSnapshot(): SymphonyStreamUnreadCounts { + return Object.fromEntries(Array.from(this._streamUnreadCounts.entries()).sort(([left], [right]) => left.localeCompare(right))); + } + + get debugSnapshot(): SymphonyNotificationDebugSnapshot { + return { + totalCount: this._count, + globalUnreadCount: this._globalUnreadCount, + fallbackUnreadCount: this._fallbackUnreadCount, + origins: Array.from(this._originDebug.values()), + subscriptions: Array.from(this._subscriptionDebug.values()).sort((left, right) => left.label.localeCompare(right.label)), + recentNotifications: this._recentNotifications, + lastEventSummary: this._lastEventSummary, + lastEventAt: this._lastEventAt, + }; + } +} + +export const symphonyNotifications = new SymphonyNotificationsService(); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonySdk.test.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonySdk.test.ts new file mode 100644 index 0000000..2939969 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonySdk.test.ts @@ -0,0 +1,262 @@ +import { SymphonySdkService } from './symphonySdk'; + +describe('SymphonySdkService', () => { + function createRenderMock({ + onRender, + }: { + onRender?: (iframe: HTMLIFrameElement, container: HTMLDivElement) => void; + } = {}) { + return jest.fn((containerId: string) => { + const container = document.querySelector(`.${containerId}`) as HTMLDivElement | null; + if (!container) { + return Promise.reject(new Error('missing container')); + } + + const iframe = document.createElement('iframe'); + iframe.src = 'https://corporate.symphony.com/apps/client2/default'; + container.appendChild(iframe); + if (onRender) { + onRender(iframe, container); + } else { + window.setTimeout(() => { + iframe.dispatchEvent(new Event('load')); + }, 0); + } + + return Promise.resolve(); + }); + } + + beforeEach(() => { + document.body.innerHTML = '
'; + delete (window as Window & { symphony?: unknown }).symphony; + delete (window as Window & { __wealthManagementRenderEcp?: unknown }).__wealthManagementRenderEcp; + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete (window as Window & { symphony?: unknown }).symphony; + delete (window as Window & { __wealthManagementRenderEcp?: unknown }).__wealthManagementRenderEcp; + }); + + test('injects the SDK script once without performing a hidden bootstrap render', async () => { + const service = new SymphonySdkService(); + const renderMock = createRenderMock(); + + const firstInit = service.init('corporate.symphony.com'); + const secondInit = service.init('corporate.symphony.com'); + + expect(document.querySelectorAll('#symphony-ecm-sdk')).toHaveLength(1); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + const script = document.getElementById('symphony-ecm-sdk') as HTMLScriptElement; + script.onload?.(new Event('load') as any); + + await Promise.all([firstInit, secondInit]); + + expect(renderMock).not.toHaveBeenCalled(); + expect(service.status).toBe('ready'); + }); + + test('renders the collaboration chat once into the real visible slot', async () => { + const service = new SymphonySdkService(); + const renderMock = createRenderMock(); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + await service.renderChat('.slot-a', { mode: 'light' }); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(renderMock).toHaveBeenCalledWith('slot-a', { mode: 'light' }); + expect(service.getRenderedStreamId('.slot-a')).toBeUndefined(); + }); + + test('does not render the same collaboration container twice when the iframe is already mounted', async () => { + const service = new SymphonySdkService(); + const renderMock = createRenderMock(); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + await service.renderChat('.slot-a', { mode: 'light' }); + + await service.renderChat('.slot-a', { mode: 'light' }); + + expect(renderMock).toHaveBeenCalledTimes(1); + }); + + test('waits for the rendered iframe to load before resolving the visible-slot render', async () => { + const service = new SymphonySdkService(); + let releaseFrameLoad: (() => void) | undefined; + const renderMock = createRenderMock({ + onRender: (iframe) => { + releaseFrameLoad = () => iframe.dispatchEvent(new Event('load')); + }, + }); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + + let resolved = false; + const renderPromise = service.renderChat('.slot-a', { mode: 'light' }).then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + + releaseFrameLoad?.(); + await renderPromise; + + expect(resolved).toBe(true); + }); + + test('ignores the initial about:blank iframe load and waits for the real Symphony client navigation', async () => { + const service = new SymphonySdkService(); + let iframeRef: HTMLIFrameElement | undefined; + const renderMock = createRenderMock({ + onRender: (iframe) => { + iframeRef = iframe; + iframe.removeAttribute('src'); + window.setTimeout(() => { + iframe.dispatchEvent(new Event('load')); + iframe.src = 'https://corporate.symphony.com/apps/client2/default'; + iframe.dispatchEvent(new Event('load')); + }, 0); + }, + }); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + + let resolved = false; + const renderPromise = service.renderChat('.slot-a', { mode: 'light' }).then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(iframeRef?.dataset.wealthReady).toBeUndefined(); + expect(resolved).toBe(false); + + await renderPromise; + + expect(iframeRef?.dataset.wealthReady).toBe('true'); + expect(resolved).toBe(true); + }); + + test('opens a requested stream inside an existing collaboration iframe without re-rendering', async () => { + const service = new SymphonySdkService(); + const renderMock = createRenderMock(); + const openStreamMock = jest.fn(() => Promise.resolve()); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: openStreamMock, + } as any; + + await service.init('corporate.symphony.com'); + await service.renderChat('.slot-a', { mode: 'light' }); + + await service.openStream('stream-1', '.slot-a', { mode: 'light' }); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(openStreamMock).toHaveBeenCalledWith('stream-1', '.slot-a'); + expect(service.getRenderedStreamId('.slot-a')).toBe('stream-1'); + }); + + test('falls back to a single render when a stream is requested before the slot has rendered', async () => { + const service = new SymphonySdkService(); + const renderMock = createRenderMock(); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + await service.openStream('stream-1', '.slot-a', { mode: 'light' }); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(renderMock).toHaveBeenCalledWith('slot-a', { mode: 'light', streamId: 'stream-1' }); + }); + + test('moves to error when visible-slot render fails', async () => { + const service = new SymphonySdkService(); + const renderMock = jest.fn().mockRejectedValueOnce(new Error('render failed')); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + + await expect(service.renderChat('.slot-a', { mode: 'light' })).rejects.toThrow( + 'Unable to render Symphony chat. render failed', + ); + expect(service.status).toBe('error'); + }); + + test('does not reset the SDK when no error state is active', async () => { + const service = new SymphonySdkService(); + const renderMock = createRenderMock(); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + + expect(service.resetIfError()).toBe(false); + expect(service.status).toBe('ready'); + }); + + test('can recover from a transient render failure after resetting the error state', async () => { + const service = new SymphonySdkService(); + const renderMock = jest.fn() + .mockRejectedValueOnce(new Error('render failed')) + .mockImplementation(createRenderMock()); + + (window as Window & { symphony?: unknown }).symphony = { + render: renderMock, + openStream: jest.fn(), + } as any; + + await service.init('corporate.symphony.com'); + + await expect(service.renderChat('.slot-a', { mode: 'light' })).rejects.toThrow( + 'Unable to render Symphony chat. render failed', + ); + + expect(service.status).toBe('error'); + expect(service.resetIfError()).toBe(true); + expect(service.status).toBe('idle'); + expect((document.querySelector('.slot-a') as HTMLDivElement).children).toHaveLength(0); + + await service.init('corporate.symphony.com'); + await service.renderChat('.slot-a', { mode: 'light' }); + + expect(renderMock).toHaveBeenCalledTimes(2); + expect(service.status).toBe('ready'); + }); +}); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonySdk.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonySdk.ts new file mode 100644 index 0000000..7573c29 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/symphonySdk.ts @@ -0,0 +1,515 @@ +const SDK_SCRIPT_ID = 'symphony-ecm-sdk'; +const SDK_ONLOAD_CALLBACK = '__wealthManagementRenderEcp'; +const DEFAULT_PARTNER_ID = 'symphony_internal_BYC-XXX'; +const DEFAULT_SDK_PATH = '/embed/sdk.js'; + +export type SymphonySdkStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface SymphonyStatusSnapshot { + status: SymphonySdkStatus; + error: Error | null; + isReady: boolean; +} + +type StatusListener = (snapshot: SymphonyStatusSnapshot) => void; + +type RenderedContainerState = { + streamId?: string; +}; + +type InflightOperation = { + key: string; + promise: Promise; +}; + +type FrameWaitHandle = { + promise: Promise; + cancel: () => void; +}; + +const FRAME_READY_TIMEOUT_MS = 15000; + +declare global { + interface Window { + symphony?: { + render: (container: string, options: Record) => Promise; + openStream: (streamId: string, containerSelector: string) => Promise | unknown; + listen?: (config: { + type: string; + params?: Record; + callback: (notification: Record) => void; + }) => void; + updateSettings?: (settings: Record) => void; + updateTheme?: (theme: Record) => void; + }; + __wealthManagementRenderEcp?: () => void; + } +} + +export class SymphonySdkService { + private _status: SymphonySdkStatus = 'idle'; + private _error: Error | null = null; + private _listeners = new Set(); + private _initPromise: Promise | null = null; + private _renderedContainers = new Map(); + private _inflightOperations = new Map(); + + private _clearTrackedContainers() { + const trackedSelectors = new Set(); + + this._renderedContainers.forEach((_, containerSelector) => { + trackedSelectors.add(containerSelector); + }); + + this._inflightOperations.forEach((_, containerSelector) => { + trackedSelectors.add(containerSelector); + }); + + trackedSelectors.forEach((containerSelector) => { + const container = this._getContainer(containerSelector); + if (container) { + container.innerHTML = ''; + } + }); + } + + private _resetState() { + this._initPromise = null; + this._clearTrackedContainers(); + this._renderedContainers.clear(); + this._inflightOperations.clear(); + + if (!window.symphony) { + const script = document.getElementById(SDK_SCRIPT_ID); + script?.remove(); + } + + delete window[SDK_ONLOAD_CALLBACK]; + this._setStatus('idle'); + } + + private _emitStatus() { + const snapshot = this.snapshot; + this._listeners.forEach((listener) => listener(snapshot)); + } + + private _setStatus(status: SymphonySdkStatus, error: Error | null = null) { + this._status = status; + this._error = error; + this._emitStatus(); + } + + private _createError(message: string, cause?: unknown) { + const detail = + cause instanceof Error + ? cause.message + : typeof cause === 'string' + ? cause + : undefined; + + return new Error(detail ? `${message} ${detail}` : message); + } + + private _getContainer(containerSelector: string) { + return document.querySelector(containerSelector) as HTMLElement | null; + } + + private _getRenderTarget(containerSelector: string) { + if (!containerSelector.startsWith('.')) { + throw this._createError(`Symphony slot "${containerSelector}" must be a class selector.`); + } + + return containerSelector.slice(1); + } + + private _trackInflight(containerSelector: string, key: string, promise: Promise) { + const trackedPromise = promise.finally(() => { + const inflight = this._inflightOperations.get(containerSelector); + if (inflight?.promise === trackedPromise) { + this._inflightOperations.delete(containerSelector); + } + }); + + this._inflightOperations.set(containerSelector, { key, promise: trackedPromise }); + return trackedPromise; + } + + private _waitForNextPaint() { + return new Promise((resolve) => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => resolve()); + return; + } + + window.setTimeout(resolve, 0); + }); + } + + private _getFrameSrc(frame: HTMLIFrameElement) { + return frame.getAttribute('src') ?? frame.src ?? ''; + } + + private _hasMeaningfulFrameSrc(frame: HTMLIFrameElement) { + const frameSrc = this._getFrameSrc(frame).trim().toLowerCase(); + return Boolean(frameSrc) && frameSrc !== 'about:blank' && frameSrc !== 'about:srcdoc'; + } + + private _isFrameReady(frame: HTMLIFrameElement) { + if (!this._hasMeaningfulFrameSrc(frame)) { + return false; + } + + if (frame.dataset.wealthReady === 'true') { + return true; + } + + try { + return frame.contentDocument?.readyState === 'complete'; + } catch { + return false; + } + } + + private _observeRenderedFrame(containerSelector: string): FrameWaitHandle { + const container = this._getContainer(containerSelector); + if (!container) { + return { + promise: Promise.reject(this._createError(`Missing Symphony slot "${containerSelector}".`)), + cancel: () => {}, + }; + } + + let settled = false; + let timeoutId: number | null = null; + let observer: MutationObserver | null = null; + let currentFrame: HTMLIFrameElement | null = null; + let cleanup = () => {}; + let handleFrameLoad = () => {}; + + const promise = new Promise((resolve, reject) => { + cleanup = () => { + observer?.disconnect(); + observer = null; + if (currentFrame) { + currentFrame.removeEventListener('load', handleFrameLoad); + } + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const settle = () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + void this._waitForNextPaint().then(resolve); + }; + + const fail = (message: string) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(this._createError(message)); + }; + + handleFrameLoad = () => { + if (currentFrame && this._hasMeaningfulFrameSrc(currentFrame)) { + currentFrame.dataset.wealthReady = 'true'; + settle(); + } + }; + + const watchFrame = (frame: HTMLIFrameElement) => { + if (currentFrame !== frame) { + if (currentFrame) { + currentFrame.removeEventListener('load', handleFrameLoad); + } + currentFrame = frame; + currentFrame.addEventListener('load', handleFrameLoad); + } + + if (this._isFrameReady(frame)) { + settle(); + } + }; + + const syncFrame = () => { + const frame = container.querySelector('iframe') as HTMLIFrameElement | null; + if (!frame) { + return; + } + + watchFrame(frame); + }; + + observer = new MutationObserver(syncFrame); + observer.observe(container, { childList: true, subtree: true }); + syncFrame(); + + timeoutId = window.setTimeout(() => { + fail(`Timed out waiting for Symphony chat to render in "${containerSelector}".`); + }, FRAME_READY_TIMEOUT_MS); + }); + + return { + promise, + cancel: () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + }, + }; + } + + init(ecpOrigin: string, partnerId?: string): Promise { + if (this._status === 'ready') { + return Promise.resolve(); + } + + if (this._initPromise) { + return this._initPromise; + } + + this._setStatus('loading'); + + this._initPromise = new Promise((resolve, reject) => { + let completed = false; + + const complete = () => { + if (completed) { + return; + } + + completed = true; + this._setStatus('ready'); + resolve(); + }; + + const fail = (cause: unknown) => { + if (completed) { + return; + } + + completed = true; + const error = this._createError('Unable to initialize Symphony chat.', cause); + this._setStatus('error', error); + reject(error); + }; + + window[SDK_ONLOAD_CALLBACK] = complete; + + if (window.symphony) { + complete(); + return; + } + + const existingScript = document.getElementById(SDK_SCRIPT_ID) as HTMLScriptElement | null; + if (existingScript) { + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener('error', fail, { once: true }); + return; + } + + const script = document.createElement('script'); + script.src = `https://${ecpOrigin}${DEFAULT_SDK_PATH}`; + script.id = SDK_SCRIPT_ID; + script.setAttribute('render', 'explicit'); + script.setAttribute('data-mode', 'full'); + script.setAttribute('data-onload', SDK_ONLOAD_CALLBACK); + script.onload = complete; + script.onerror = (event) => fail(event); + + if (partnerId) { + script.setAttribute('data-partner-id', partnerId); + } else if (ecpOrigin !== 'st3.dev.symphony.com') { + script.setAttribute('data-partner-id', DEFAULT_PARTNER_ID); + } + + document.body.appendChild(script); + }); + + return this._initPromise; + } + + renderChat(containerSelector: string, options: Record): Promise { + if (!containerSelector) { + return Promise.resolve(); + } + + if (this._status === 'error') { + return Promise.reject(this._error ?? this._createError('Symphony is in an error state.')); + } + + const requestedStreamId = typeof options.streamId === 'string' ? options.streamId : undefined; + const requestKey = `render:${requestedStreamId ?? 'workspace'}`; + const inflight = this._inflightOperations.get(containerSelector); + if (inflight?.key === requestKey) { + return inflight.promise; + } + + const run = async () => { + const container = this._getContainer(containerSelector); + if (!container) { + throw this._createError(`Missing Symphony slot "${containerSelector}".`); + } + + if (!window.symphony) { + throw this._createError('Symphony SDK is not available on window.'); + } + + const existingRender = this._renderedContainers.get(containerSelector); + const hasIframe = Boolean(container.querySelector('iframe')); + + if (hasIframe && existingRender?.streamId === requestedStreamId) { + return; + } + + container.innerHTML = ''; + const frameWaitHandle = this._observeRenderedFrame(containerSelector); + + try { + await window.symphony.render(this._getRenderTarget(containerSelector), options); + await frameWaitHandle.promise; + this._renderedContainers.set(containerSelector, { streamId: requestedStreamId }); + } catch (cause) { + frameWaitHandle.cancel(); + throw cause; + } + }; + + const runWithErrorHandling = () => + run().catch((cause) => { + const error = this._createError('Unable to render Symphony chat.', cause); + this._setStatus('error', error); + throw error; + }); + + const promise = + this._status === 'ready' + ? runWithErrorHandling() + : !this._initPromise + ? Promise.reject(this._createError('Symphony has not been initialized yet.')) + : this._initPromise.then(runWithErrorHandling); + + return this._trackInflight(containerSelector, requestKey, promise); + } + + openStream(streamId: string, containerSelector: string, renderOptions?: Record): Promise { + if (!streamId || !containerSelector) { + return Promise.resolve(); + } + + if (this._status === 'error') { + return Promise.reject(this._error ?? this._createError('Symphony is in an error state.')); + } + + const requestKey = `open:${streamId}`; + const inflight = this._inflightOperations.get(containerSelector); + if (inflight?.key === requestKey) { + return inflight.promise; + } + + const run = async () => { + const container = this._getContainer(containerSelector); + if (!container) { + throw this._createError(`Missing Symphony slot "${containerSelector}".`); + } + + if (!window.symphony) { + throw this._createError('Symphony SDK is not available on window.'); + } + + const existingRender = this._renderedContainers.get(containerSelector); + const hasIframe = Boolean(container.querySelector('iframe')); + + if (!hasIframe) { + await this.renderChat(containerSelector, { ...(renderOptions ?? {}), streamId }); + return; + } + + if (existingRender?.streamId === streamId) { + return; + } + + await Promise.resolve(window.symphony.openStream(streamId, containerSelector)); + this._renderedContainers.set(containerSelector, { streamId }); + }; + + const runWithErrorHandling = () => + run().catch((cause) => { + const error = this._createError('Unable to open Symphony stream.', cause); + this._setStatus('error', error); + throw error; + }); + + const promise = + this._status === 'ready' + ? runWithErrorHandling() + : !this._initPromise + ? Promise.reject(this._createError('Symphony has not been initialized yet.')) + : this._initPromise.then(runWithErrorHandling); + + return this._trackInflight(containerSelector, requestKey, promise); + } + + hasRendered(containerSelector: string) { + return this._renderedContainers.has(containerSelector); + } + + getRenderedStreamId(containerSelector: string) { + return this._renderedContainers.get(containerSelector)?.streamId; + } + + markWorkspace(containerSelector: string) { + this._renderedContainers.set(containerSelector, { streamId: undefined }); + } + + resetIfError() { + if (this._status !== 'error') { + return false; + } + + this._resetState(); + return true; + } + + onStatusChange(listener: StatusListener): () => void { + this._listeners.add(listener); + listener(this.snapshot); + return () => { + this._listeners.delete(listener); + }; + } + + get snapshot(): SymphonyStatusSnapshot { + return { + status: this._status, + error: this._error, + isReady: this._status === 'ready', + }; + } + + get status(): SymphonySdkStatus { + return this._status; + } + + get error(): Error | null { + return this._error; + } + + get isReady(): boolean { + return this._status === 'ready'; + } +} + +export const symphonySdk = new SymphonySdkService(); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEcpSlot.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEcpSlot.ts new file mode 100644 index 0000000..1982cdd --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEcpSlot.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from 'react'; +import { symphonySdk } from './symphonySdk'; + +interface UseEcpSlotOptions { + slotName: string; +} + +export function useEcpSlot({ slotName }: UseEcpSlotOptions) { + const slotNameRef = useRef(slotName); + const slotRef = useRef(null); + const [ecpReady, setEcpReady] = useState(symphonySdk.isReady); + const [ecpError, setEcpError] = useState(symphonySdk.error); + + useEffect(() => { + const slotEl = slotRef.current; + const unsubscribe = symphonySdk.onStatusChange(({ isReady, error }) => { + setEcpReady(isReady); + setEcpError(error); + }); + + return () => { + unsubscribe(); + if (slotEl) { + slotEl.innerHTML = ''; + } + }; + }, []); + + return { + slotClassName: slotNameRef.current, + slotRef, + ecpReady, + ecpError, + containerSelector: `.${slotNameRef.current}`, + }; +} diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEmbeddedClientChatHost.test.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEmbeddedClientChatHost.test.tsx new file mode 100644 index 0000000..11f296d --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEmbeddedClientChatHost.test.tsx @@ -0,0 +1,140 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { useEmbeddedClientChatHost } from './useEmbeddedClientChatHost'; + +const mockMarkMessagesViewed = jest.fn(); +const mockOnHostMessage = jest.fn(); + +jest.mock('./symphonyNotifications', () => ({ + symphonyNotifications: { + markMessagesViewed: (streamId?: string) => mockMarkMessagesViewed(streamId), + }, +})); + +function HookHarness({ + contactId, + ecpOrigin, + partnerId, +}: { + contactId?: string | null; + ecpOrigin?: string; + partnerId?: string; +}) { + const state = useEmbeddedClientChatHost({ + contactId, + ecpOrigin, + partnerId, + onHostMessage: mockOnHostMessage, + }); + + return ( +
+
{state.isChatReady ? 'ready' : 'waiting'}
+
{state.chatError ?? ''}
+
{state.streamId ?? ''}
+
{state.contact?.name ?? ''}
+
{state.chatHostUrl}
+
+ ); +} + +async function dispatchHostMessage(data: Record, origin = window.location.origin) { + await act(async () => { + window.dispatchEvent( + new MessageEvent('message', { + origin, + data, + }), + ); + }); +} + +beforeEach(() => { + mockMarkMessagesViewed.mockClear(); + mockOnHostMessage.mockClear(); +}); + +test('builds the client chat host url from the selected contact and theme payload', () => { + render(); + + const url = new URL(screen.getByTestId('chat-host-url').textContent ?? ''); + + expect(screen.getByTestId('contact-name')).toHaveTextContent('Evelyn Reed'); + expect(url.pathname).toBe('/wealth-client-chat-host.html'); + expect(url.searchParams.get('ecpOrigin')).toBe('corporate.symphony.com'); + expect(url.searchParams.get('partnerId')).toBe('partner-123'); + expect(url.searchParams.get('streamId')).toBe(screen.getByTestId('stream-id').textContent); + expect(url.searchParams.get('mode')).toBe('light'); + expect(url.searchParams.get('theme')).toContain('55b7ff'); +}); + +test('marks the chat ready only for a matching host ready message and clears unread state for that stream', async () => { + render(); + const streamId = screen.getByTestId('stream-id').textContent ?? ''; + + await dispatchHostMessage({ + source: 'wealth-client-chat-host', + type: 'ready', + payload: { streamId: 'different-stream' }, + }); + + expect(screen.getByTestId('chat-ready')).toHaveTextContent('waiting'); + + await dispatchHostMessage({ + source: 'wealth-client-chat-host', + type: 'ready', + payload: { streamId }, + }); + + await waitFor(() => { + expect(screen.getByTestId('chat-ready')).toHaveTextContent('ready'); + }); + + expect(mockMarkMessagesViewed).toHaveBeenCalledWith(streamId); + expect(mockOnHostMessage).toHaveBeenCalledTimes(2); +}); + +test('ignores host messages from another origin or another message source', async () => { + render(); + + await dispatchHostMessage({ + source: 'wealth-client-chat-host', + type: 'error', + payload: { message: 'Wrong origin' }, + }, 'https://example.com'); + + await dispatchHostMessage({ + source: 'other-host', + type: 'error', + payload: { message: 'Wrong source' }, + }); + + expect(screen.getByTestId('chat-error')).toHaveTextContent(''); + expect(mockOnHostMessage).not.toHaveBeenCalled(); +}); + +test('ignores a stale error from a previous stream after the contact changes', async () => { + const { rerender } = render(); + const firstStreamId = screen.getByTestId('stream-id').textContent ?? ''; + + await dispatchHostMessage({ + source: 'wealth-client-chat-host', + type: 'ready', + payload: { streamId: firstStreamId }, + }); + + await waitFor(() => { + expect(screen.getByTestId('chat-ready')).toHaveTextContent('ready'); + }); + + rerender(); + + expect(screen.getByTestId('chat-ready')).toHaveTextContent('waiting'); + + await dispatchHostMessage({ + source: 'wealth-client-chat-host', + type: 'error', + payload: { streamId: firstStreamId, message: 'Stale iframe error' }, + }); + + expect(screen.getByTestId('chat-error')).toBeEmptyDOMElement(); +}); \ No newline at end of file diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEmbeddedClientChatHost.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEmbeddedClientChatHost.ts new file mode 100644 index 0000000..42152c9 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useEmbeddedClientChatHost.ts @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { wealthManagementShellData, type ShellContact } from '../data/wealthManagementShell'; +import { symphonyNotifications } from './symphonyNotifications'; +import { getWealthSymphonyThemeUrlParams } from './wealthSymphonyTheme'; + +const CLIENT_CHAT_HOST_PATH = '/wealth-client-chat-host.html'; + +export interface ClientChatHostMessage { + source?: string; + type?: string; + payload?: { + requestId?: string; + documentName?: string; + message?: string; + streamId?: string; + }; +} + +interface UseEmbeddedClientChatHostOptions { + contactId?: string | null; + ecpOrigin?: string; + partnerId?: string; + onHostMessage?: (message: ClientChatHostMessage) => void; +} + +export function useEmbeddedClientChatHost({ + contactId, + ecpOrigin = 'corporate.symphony.com', + partnerId, + onHostMessage, +}: UseEmbeddedClientChatHostOptions) { + const iframeRef = useRef(null); + const [isChatReady, setIsChatReady] = useState(false); + const [chatError, setChatError] = useState(null); + + const roomMap = wealthManagementShellData.wealthRoom as Record; + const contact = useMemo( + () => (wealthManagementShellData.contacts ?? []).find((item) => item.id === contactId), + [contactId], + ); + const defaultStreamId = roomMap[ecpOrigin] ?? roomMap['corporate.symphony.com']; + const streamId = contact?.streamId ?? defaultStreamId; + const themeUrlParams = useMemo(() => getWealthSymphonyThemeUrlParams(), []); + const chatHostUrl = useMemo(() => { + if (!streamId) { + return ''; + } + + const url = new URL(CLIENT_CHAT_HOST_PATH, window.location.origin); + url.searchParams.set('ecpOrigin', ecpOrigin); + url.searchParams.set('streamId', streamId); + url.searchParams.set('mode', themeUrlParams.mode); + url.searchParams.set('theme', themeUrlParams.theme); + if (partnerId) { + url.searchParams.set('partnerId', partnerId); + } + return url.toString(); + }, [ecpOrigin, partnerId, streamId, themeUrlParams]); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return; + } + + const data = event.data as ClientChatHostMessage | undefined; + if (data?.source !== 'wealth-client-chat-host') { + return; + } + + onHostMessage?.(data); + + if (data.type === 'ready') { + if (data.payload?.streamId !== streamId) { + return; + } + + setChatError(null); + setIsChatReady(true); + return; + } + + if (data.type === 'error') { + if (data.payload?.streamId && data.payload.streamId !== streamId) { + return; + } + + setChatError(data.payload?.message ?? 'Unable to load client chat.'); + setIsChatReady(false); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [onHostMessage, streamId]); + + useEffect(() => { + setChatError(null); + setIsChatReady(false); + }, [chatHostUrl]); + + useEffect(() => { + if (!isChatReady || !streamId) { + return; + } + + symphonyNotifications.markMessagesViewed?.(streamId); + }, [isChatReady, streamId]); + + return { + chatError, + chatHostUrl, + contact, + iframeRef, + isChatReady, + streamId, + }; +} \ No newline at end of file diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedChatPresentationTransition.test.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedChatPresentationTransition.test.tsx new file mode 100644 index 0000000..a251b3d --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedChatPresentationTransition.test.tsx @@ -0,0 +1,135 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { useSharedChatPresentationTransition } from './useSharedChatPresentationTransition'; + +const mockRefreshWealthSymphonyThemeAfterLayoutChange = jest.fn(() => Promise.resolve()); + +jest.mock('./wealthSymphonyTheme', () => ({ + refreshWealthSymphonyThemeAfterLayoutChange: () => mockRefreshWealthSymphonyThemeAfterLayoutChange(), +})); + +class ResizeObserverMock { + observe = jest.fn(); + disconnect = jest.fn(); +} + +function HookHarness({ mode, isReady, isVisible = true }: { mode: 'page' | 'drawer'; isReady: boolean; isVisible?: boolean }) { + const { shellRef, maskFrame } = useSharedChatPresentationTransition({ mode, isReady, isVisible }); + + return ( +
+ {maskFrame ? 'masked' : 'clear'} +
+ ); +} + +describe('useSharedChatPresentationTransition', () => { + const originalResizeObserver = global.ResizeObserver; + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + + beforeEach(() => { + jest.useFakeTimers(); + global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + window.requestAnimationFrame = ((callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0)) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame; + mockRefreshWealthSymphonyThemeAfterLayoutChange.mockClear(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + global.ResizeObserver = originalResizeObserver; + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + test('masks and refreshes the shared frame when switching from drawer to page mode', async () => { + let resolveRefresh: (() => void) | undefined; + mockRefreshWealthSymphonyThemeAfterLayoutChange.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + const { rerender } = render(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + + rerender(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('masked'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(mockRefreshWealthSymphonyThemeAfterLayoutChange).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('mask-state')).toHaveTextContent('masked'); + + await act(async () => { + resolveRefresh?.(); + }); + + await act(async () => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + }); + }); + + test('repeats the hidden refresh path when switching back from page to drawer mode', async () => { + const { rerender } = render(); + + rerender(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('masked'); + + await act(async () => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(mockRefreshWealthSymphonyThemeAfterLayoutChange).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + }); + }); + + test('does not mask when the drawer chat becomes visible after being hidden', async () => { + const { rerender } = render(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + + rerender(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(mockRefreshWealthSymphonyThemeAfterLayoutChange).not.toHaveBeenCalled(); + }); + + test('masks and refreshes when a page chat becomes visible after being hidden', async () => { + const { rerender } = render(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + + rerender(); + + expect(screen.getByTestId('mask-state')).toHaveTextContent('masked'); + + await act(async () => { + jest.runAllTimers(); + }); + + await waitFor(() => { + expect(mockRefreshWealthSymphonyThemeAfterLayoutChange).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('mask-state')).toHaveTextContent('clear'); + }); + }); +}); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedChatPresentationTransition.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedChatPresentationTransition.ts new file mode 100644 index 0000000..2ed6864 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedChatPresentationTransition.ts @@ -0,0 +1,204 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { refreshWealthSymphonyThemeAfterLayoutChange } from './wealthSymphonyTheme'; + +interface UseSharedChatPresentationTransitionOptions { + mode: 'page' | 'drawer'; + isReady: boolean; + isVisible: boolean; +} + +interface LayoutWaitHandle { + promise: Promise; + cancel: () => void; +} + +function requestFrame(callback: FrameRequestCallback) { + if (typeof window.requestAnimationFrame === 'function') { + return window.requestAnimationFrame(callback); + } + + return window.setTimeout(() => callback(Date.now()), 0); +} + +function cancelFrame(frameId: number) { + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(frameId); + return; + } + + window.clearTimeout(frameId); +} + +function waitForElementLayoutToSettle(element: HTMLElement): LayoutWaitHandle { + let frameId: number | null = null; + let observer: ResizeObserver | null = null; + let cancelled = false; + let lastMeasurement = ''; + + const cleanup = () => { + if (frameId !== null) { + cancelFrame(frameId); + frameId = null; + } + + observer?.disconnect(); + observer = null; + }; + + const measure = () => { + const { width, height } = element.getBoundingClientRect(); + return `${Math.round(width)}x${Math.round(height)}`; + }; + + const promise = new Promise((resolve) => { + const tick = () => { + if (cancelled) { + return; + } + + const currentMeasurement = measure(); + if (currentMeasurement === lastMeasurement) { + cleanup(); + resolve(); + return; + } + + lastMeasurement = currentMeasurement; + frameId = requestFrame(tick); + }; + + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(() => { + lastMeasurement = ''; + if (frameId !== null) { + cancelFrame(frameId); + } + frameId = requestFrame(tick); + }); + observer.observe(element); + } + + frameId = requestFrame(tick); + }); + + return { + promise, + cancel: () => { + cancelled = true; + cleanup(); + }, + }; +} + +export function useSharedChatPresentationTransition({ + mode, + isReady, + isVisible, +}: UseSharedChatPresentationTransitionOptions) { + const shellRef = useRef(null); + const previousModeRef = useRef(mode); + const previousVisibleRef = useRef(isVisible); + const hasReadyRef = useRef(false); + const [maskFrame, setMaskFrame] = useState(false); + + useLayoutEffect(() => { + if (!isReady) { + return; + } + + if (!hasReadyRef.current) { + hasReadyRef.current = true; + previousModeRef.current = mode; + previousVisibleRef.current = isVisible; + setMaskFrame(false); + return; + } + + if (previousModeRef.current === mode) { + return; + } + + previousModeRef.current = mode; + setMaskFrame(true); + }, [isReady, isVisible, mode]); + + useLayoutEffect(() => { + if (!isReady || !hasReadyRef.current) { + return; + } + + if (previousVisibleRef.current === isVisible) { + return; + } + + previousVisibleRef.current = isVisible; + if (isVisible && mode === 'page') { + setMaskFrame(true); + } + }, [isReady, isVisible, mode]); + + useEffect(() => { + if (!isReady || !isVisible) { + return; + } + + let frameId: number | null = null; + + const requestMaskedRefresh = () => { + if (frameId !== null) { + cancelFrame(frameId); + } + + frameId = requestFrame(() => { + setMaskFrame((current) => (current ? current : true)); + }); + }; + + const viewport = window.visualViewport; + window.addEventListener('resize', requestMaskedRefresh); + window.addEventListener('orientationchange', requestMaskedRefresh); + viewport?.addEventListener('resize', requestMaskedRefresh); + + return () => { + if (frameId !== null) { + cancelFrame(frameId); + } + window.removeEventListener('resize', requestMaskedRefresh); + window.removeEventListener('orientationchange', requestMaskedRefresh); + viewport?.removeEventListener('resize', requestMaskedRefresh); + }; + }, [isReady, isVisible]); + + useEffect(() => { + if (!isReady || !isVisible || !maskFrame) { + return; + } + + const shell = shellRef.current; + if (!shell) { + setMaskFrame(false); + return; + } + + let cancelled = false; + const waitHandle = waitForElementLayoutToSettle(shell); + + void waitHandle.promise + .then(() => refreshWealthSymphonyThemeAfterLayoutChange()) + .finally(() => { + if (!cancelled) { + setMaskFrame(false); + } + }); + + return () => { + cancelled = true; + waitHandle.cancel(); + }; + }, [isReady, isVisible, maskFrame, mode]); + + return { + shellRef, + maskFrame, + }; +} diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedWealthChatController.test.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedWealthChatController.test.tsx new file mode 100644 index 0000000..b640faa --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedWealthChatController.test.tsx @@ -0,0 +1,207 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { useSharedWealthChatController } from './useSharedWealthChatController'; + +const mockInit = jest.fn(() => Promise.resolve()) as unknown as jest.Mock< + Promise, + [string, string | undefined] +>; +const mockRenderChat = jest.fn(() => Promise.resolve()) as unknown as jest.Mock< + Promise, + [string, Record] +>; +const mockOpenStream = jest.fn(() => Promise.resolve()) as unknown as jest.Mock< + Promise, + [string, string, Record | undefined] +>; +const mockNotificationsInit = jest.fn(); +const mockApplyWealthSymphonyTheme = jest.fn(); +const mockApplyWealthSymphonyThemeWithSettle = jest.fn(() => Promise.resolve()); +const mockResetIfError = jest.fn(() => true) as unknown as jest.Mock; + +jest.mock('./symphonySdk', () => ({ + symphonySdk: { + init: (ecpOrigin: string, partnerId?: string) => mockInit(ecpOrigin, partnerId), + renderChat: (containerSelector: string, options: Record) => + mockRenderChat(containerSelector, options), + openStream: (streamId: string, containerSelector: string, options?: Record) => + mockOpenStream(streamId, containerSelector, options), + resetIfError: () => mockResetIfError(), + }, +})); + +jest.mock('./symphonyNotifications', () => ({ + symphonyNotifications: { + init: (ecpOrigin: string) => mockNotificationsInit(ecpOrigin), + }, +})); + +jest.mock('./wealthSymphonyTheme', () => { + const actual = jest.requireActual('./wealthSymphonyTheme'); + return { + ...actual, + applyWealthSymphonyTheme: () => mockApplyWealthSymphonyTheme(), + applyWealthSymphonyThemeWithSettle: () => mockApplyWealthSymphonyThemeWithSettle(), + }; +}); + +function HookHarness({ + requestedStreamId, +}: { + requestedStreamId?: string; +}) { + const state = useSharedWealthChatController({ + ecpOrigin: 'corporate.symphony.com', + partnerId: undefined, + requestedStreamId, + }); + + return ( +
+
{state.isBootstrapping ? 'bootstrapping' : 'ready'}
+
{state.isSwitchingStream ? 'switching' : 'idle'}
+
{state.slotClassName}
+
{state.bootstrapError?.message ?? state.streamError?.message ?? ''}
+
+ ); +} + +beforeEach(() => { + mockInit.mockClear(); + mockRenderChat.mockClear(); + mockOpenStream.mockClear(); + mockResetIfError.mockClear(); + mockNotificationsInit.mockClear(); + mockApplyWealthSymphonyTheme.mockClear(); + mockApplyWealthSymphonyThemeWithSettle.mockClear(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +test('bootstraps the shared chat by rendering the real collaboration slot once', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + }); + + expect(mockInit).toHaveBeenCalledWith('corporate.symphony.com', undefined); + expect(mockRenderChat).toHaveBeenCalledWith( + '.wealth-symphony-shared', + expect.objectContaining({ mode: 'light' }), + ); + expect(mockNotificationsInit).toHaveBeenCalledWith('corporate.symphony.com'); + expect(mockApplyWealthSymphonyTheme).toHaveBeenCalled(); + expect(screen.getByTestId('slot')).toHaveTextContent('wealth-symphony-shared'); +}); + +test('opens a requested contact stream after the shared chat has bootstrapped without re-rendering', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + }); + mockRenderChat.mockClear(); + + rerender(); + + await waitFor(() => { + expect(mockOpenStream).toHaveBeenCalledWith( + 'stream-1', + '.wealth-symphony-shared', + expect.objectContaining({ mode: 'light' }), + ); + }); + expect(mockApplyWealthSymphonyThemeWithSettle).toHaveBeenCalledTimes(1); + expect(mockRenderChat).not.toHaveBeenCalled(); +}); + +test('does not reopen the same shared stream twice', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockOpenStream).toHaveBeenCalledTimes(1); + }); + + rerender(); + + await waitFor(() => { + expect(mockOpenStream).toHaveBeenCalledTimes(1); + }); +}); + +test('retries a transient bootstrap failure before surfacing an error', async () => { + jest.useFakeTimers(); + mockRenderChat + .mockRejectedValueOnce(new Error('render failed')) + .mockResolvedValueOnce(undefined); + + render(); + + await waitFor(() => { + expect(mockRenderChat).toHaveBeenCalledTimes(1); + }); + + expect(screen.getByTestId('status')).toHaveTextContent('bootstrapping'); + expect(screen.getByTestId('error')).toHaveTextContent(''); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + }); + + expect(mockResetIfError).toHaveBeenCalledTimes(1); + expect(mockRenderChat).toHaveBeenCalledTimes(2); + expect(mockNotificationsInit).toHaveBeenCalledTimes(1); +}); + +test('surfaces a non-retryable bootstrap failure immediately', async () => { + mockRenderChat.mockRejectedValueOnce(new Error('403 forbidden')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + expect(screen.getByTestId('error')).toHaveTextContent('403 forbidden'); + }); + + expect(mockResetIfError).not.toHaveBeenCalled(); + expect(mockRenderChat).toHaveBeenCalledTimes(1); +}); + +test('retries a transient stream switch failure before surfacing an error', async () => { + jest.useFakeTimers(); + mockOpenStream + .mockRejectedValueOnce(new Error('stream failed')) + .mockResolvedValueOnce(undefined); + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + }); + + rerender(); + + await waitFor(() => { + expect(mockOpenStream).toHaveBeenCalledTimes(1); + }); + + expect(screen.getByTestId('switching')).toHaveTextContent('switching'); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + await waitFor(() => { + expect(mockOpenStream).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('switching')).toHaveTextContent('idle'); + }); + + expect(mockResetIfError).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('error')).toHaveTextContent(''); +}); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedWealthChatController.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedWealthChatController.ts new file mode 100644 index 0000000..61732e4 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/useSharedWealthChatController.ts @@ -0,0 +1,282 @@ +import { useEffect, useRef, useState } from 'react'; +import { symphonyNotifications } from './symphonyNotifications'; +import { symphonySdk } from './symphonySdk'; +import { + applyWealthSymphonyTheme, + applyWealthSymphonyThemeWithSettle, + getWealthSymphonyRenderOptions, + WEALTH_SHARED_CHAT_SELECTOR, +} from './wealthSymphonyTheme'; +import { debugWealth, isWealthDebugFlagEnabled } from './wealthDebug'; + +function debugWealthChat(message: string, context?: Record) { + if (!isWealthDebugFlagEnabled()) { + return; + } + debugWealth('WealthChat', message, context); +} + +const BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000]; +const STREAM_RETRY_DELAYS_MS = [500, 1000]; + +function getNow() { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + + return Date.now(); +} + +function getElapsedMs(startedAt: number) { + return Math.round(getNow() - startedAt); +} + +function toError(cause: unknown, fallbackMessage: string) { + return cause instanceof Error ? cause : new Error(fallbackMessage); +} + +function getRetryDelayMs(delays: number[], attempt: number) { + return attempt < delays.length ? delays[attempt] : null; +} + +function isRetryableSymphonyError(error: Error) { + const normalizedMessage = error.message.toLowerCase(); + return !/(401|403|auth|unauthor|forbidden|permission)/.test(normalizedMessage); +} + +function applySharedThemeSafely(context: Record) { + try { + applyWealthSymphonyTheme(); + } catch (cause) { + debugWealthChat('Applying the shared Symphony theme failed.', { + ...context, + error: cause instanceof Error ? cause.message : cause, + }); + } +} + +async function applySharedThemeWithSettleSafely(context: Record) { + try { + await applyWealthSymphonyThemeWithSettle(); + } catch (cause) { + debugWealthChat('Reapplying the shared Symphony theme after a stream switch failed.', { + ...context, + error: cause instanceof Error ? cause.message : cause, + }); + } +} + +interface UseSharedWealthChatControllerOptions { + ecpOrigin: string; + partnerId?: string; + requestedStreamId?: string; +} + +export function useSharedWealthChatController({ + ecpOrigin, + partnerId, + requestedStreamId, +}: UseSharedWealthChatControllerOptions) { + const [isBootstrapping, setIsBootstrapping] = useState(true); + const [isSwitchingStream, setIsSwitchingStream] = useState(false); + const [bootstrapError, setBootstrapError] = useState(null); + const [streamError, setStreamError] = useState(null); + const renderedSharedStreamIdRef = useRef(undefined); + + useEffect(() => { + let cancelled = false; + let retryTimerId: number | null = null; + + renderedSharedStreamIdRef.current = undefined; + setIsBootstrapping(true); + setBootstrapError(null); + setStreamError(null); + + const bootstrap = async (attempt: number) => { + const startedAt = getNow(); + + try { + debugWealthChat('Bootstrapping shared Symphony chat.', { + attempt: attempt + 1, + ecpOrigin, + partnerId, + }); + await symphonySdk.init(ecpOrigin, partnerId); + await symphonySdk.renderChat( + WEALTH_SHARED_CHAT_SELECTOR, + getWealthSymphonyRenderOptions(), + ); + if (cancelled) { + return; + } + + renderedSharedStreamIdRef.current = undefined; + applySharedThemeSafely({ + attempt: attempt + 1, + ecpOrigin, + phase: 'bootstrap', + }); + symphonyNotifications.init(ecpOrigin); + debugWealthChat('Shared Symphony chat bootstrapped.', { + attempt: attempt + 1, + ecpOrigin, + elapsedMs: getElapsedMs(startedAt), + renderedStreamId: renderedSharedStreamIdRef.current, + }); + setBootstrapError(null); + setStreamError(null); + setIsBootstrapping(false); + } catch (cause) { + if (cancelled) { + return; + } + + const error = toError(cause, 'Unable to initialize Symphony chat.'); + const retryDelayMs = isRetryableSymphonyError(error) + ? getRetryDelayMs(BOOTSTRAP_RETRY_DELAYS_MS, attempt) + : null; + + debugWealthChat('Shared Symphony chat bootstrap failed.', { + attempt: attempt + 1, + ecpOrigin, + elapsedMs: getElapsedMs(startedAt), + error: error.message, + retryDelayMs, + }); + + if (retryDelayMs !== null) { + symphonySdk.resetIfError(); + retryTimerId = window.setTimeout(() => { + if (!cancelled) { + void bootstrap(attempt + 1); + } + }, retryDelayMs); + return; + } + + setBootstrapError(error); + setIsBootstrapping(false); + } + }; + + void bootstrap(0); + + return () => { + cancelled = true; + if (retryTimerId !== null) { + window.clearTimeout(retryTimerId); + } + }; + }, [ecpOrigin, partnerId]); + + useEffect(() => { + if (isBootstrapping || bootstrapError || !requestedStreamId) { + if (!requestedStreamId) { + setStreamError(null); + } + return; + } + + if (renderedSharedStreamIdRef.current === requestedStreamId) { + debugWealthChat('Skipped shared stream switch because requested stream is already active.', { + requestedStreamId, + }); + return; + } + + let cancelled = false; + let retryTimerId: number | null = null; + setIsSwitchingStream(true); + setStreamError(null); + + const openRequestedStream = async (attempt: number) => { + const startedAt = getNow(); + + try { + await Promise.resolve( + symphonySdk.openStream( + requestedStreamId, + WEALTH_SHARED_CHAT_SELECTOR, + getWealthSymphonyRenderOptions(), + ), + ); + + if (cancelled) { + return; + } + + debugWealthChat('Shared Symphony stream opened, waiting to reapply theme.', { + attempt: attempt + 1, + previousStreamId: renderedSharedStreamIdRef.current, + requestedStreamId, + elapsedMs: getElapsedMs(startedAt), + }); + renderedSharedStreamIdRef.current = requestedStreamId; + await applySharedThemeWithSettleSafely({ + attempt: attempt + 1, + requestedStreamId, + phase: 'stream-switch', + }); + + if (cancelled) { + return; + } + + debugWealthChat('Shared Symphony theme reapplied after stream switch settled.', { + attempt: attempt + 1, + requestedStreamId, + }); + setStreamError(null); + setIsSwitchingStream(false); + } catch (cause) { + if (cancelled) { + return; + } + + const error = toError(cause, 'Unable to open Symphony stream.'); + const retryDelayMs = isRetryableSymphonyError(error) + ? getRetryDelayMs(STREAM_RETRY_DELAYS_MS, attempt) + : null; + + debugWealthChat('Shared Symphony stream switch failed.', { + attempt: attempt + 1, + requestedStreamId, + elapsedMs: getElapsedMs(startedAt), + error: error.message, + retryDelayMs, + }); + + if (retryDelayMs !== null) { + symphonySdk.resetIfError(); + retryTimerId = window.setTimeout(() => { + if (!cancelled) { + void openRequestedStream(attempt + 1); + } + }, retryDelayMs); + return; + } + + setStreamError(error); + setIsSwitchingStream(false); + } + }; + + void openRequestedStream(0); + + return () => { + cancelled = true; + if (retryTimerId !== null) { + window.clearTimeout(retryTimerId); + } + }; + }, [bootstrapError, isBootstrapping, requestedStreamId]); + + return { + bootstrapError, + streamError, + isBootstrapping, + isReady: !isBootstrapping && !bootstrapError, + isSwitchingStream, + slotClassName: WEALTH_SHARED_CHAT_SELECTOR.slice(1), + }; +} diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthDebug.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthDebug.ts new file mode 100644 index 0000000..07e5e15 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthDebug.ts @@ -0,0 +1,27 @@ +export const WEALTH_DEBUG_STORAGE_KEY = 'wealthDebugTheme'; + +export function isWealthDebugFlagEnabled() { + try { + const query = new URLSearchParams(window.location.search); + return query.get(WEALTH_DEBUG_STORAGE_KEY) === '1' || window.localStorage.getItem(WEALTH_DEBUG_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +export function shouldLogWealthDebug() { + return process.env.NODE_ENV !== 'production' || isWealthDebugFlagEnabled(); +} + +export function debugWealth(scope: string, message: string, context?: Record) { + if (!shouldLogWealthDebug()) { + return; + } + + if (context) { + console.debug(`[${scope}] ${message}`, context); + return; + } + + console.debug(`[${scope}] ${message}`); +} \ No newline at end of file diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthSymphonyTheme.test.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthSymphonyTheme.test.ts new file mode 100644 index 0000000..b916707 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthSymphonyTheme.test.ts @@ -0,0 +1,87 @@ +import { + acquireWealthSymphonyThemeOwnership, + getWealthSymphonyRenderOptions, + getWealthSymphonyThemeUrlParams, + refreshWealthSymphonyThemeAfterLayoutChange, + WEALTH_SYMPHONY_THEME, +} from './wealthSymphonyTheme'; + +describe('WEALTH_SYMPHONY_THEME', () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + + beforeEach(() => { + window.requestAnimationFrame = ((callback: FrameRequestCallback) => + window.setTimeout(() => callback(Date.now()), 0)) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame; + }); + + afterEach(() => { + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + delete (window as Window & { symphony?: unknown }).symphony; + }); + + test('keeps the primary Symphony text tokens dark for list readability', () => { + expect(WEALTH_SYMPHONY_THEME.text).toBe('#111827'); + expect(WEALTH_SYMPHONY_THEME.textSecondary).toBe('#111827'); + expect(WEALTH_SYMPHONY_THEME.textPrimary).toBe('#111827'); + expect(WEALTH_SYMPHONY_THEME.textAccent).toBe('#111827'); + }); + + test('preserves white only for tokens that sit on filled status surfaces', () => { + expect(WEALTH_SYMPHONY_THEME.textSuccess).toBe('#ffffff'); + expect(WEALTH_SYMPHONY_THEME.textError).toBe('#ffffff'); + }); + + test('uses red for unread attention styling while keeping a blue accent available', () => { + expect(WEALTH_SYMPHONY_THEME.primary).toBe('#55b7ff'); + expect(WEALTH_SYMPHONY_THEME.secondary).toBe('#55b7ff'); + expect(WEALTH_SYMPHONY_THEME.accent).toBe('#dc2626'); + expect(WEALTH_SYMPHONY_THEME.error).toBe('#dc2626'); + }); + + test('uses a stronger wealth-blue mention highlight while keeping pale-blue secondary shades', () => { + expect(WEALTH_SYMPHONY_THEME.mention).toBe('#8dcbff'); + expect(WEALTH_SYMPHONY_THEME.secondaryShades[10]).toBe('#e8f3ff'); + expect(WEALTH_SYMPHONY_THEME.secondaryShades[20]).toBe('#d6ebff'); + expect(WEALTH_SYMPHONY_THEME.secondaryShades[90]).toBe('#0d5d96'); + }); + + test('injects the wealth collaboration theme directly into render options', () => { + expect(getWealthSymphonyRenderOptions()).toEqual( + expect.objectContaining({ + mode: 'light', + theme: WEALTH_SYMPHONY_THEME, + showTitle: false, + condensed: true, + }), + ); + }); + + test('serializes the shared wealth theme for focused-mode URL rendering', () => { + expect(getWealthSymphonyThemeUrlParams()).toEqual({ + mode: 'light', + theme: JSON.stringify(WEALTH_SYMPHONY_THEME), + }); + }); + + test('refreshes the Wealth theme after a layout change by waiting for Symphony to settle and reapplying the owned theme', async () => { + const updateSettings = jest.fn(); + const updateTheme = jest.fn(); + const releaseOwnership = acquireWealthSymphonyThemeOwnership(); + + (window as Window & { symphony?: unknown }).symphony = { + updateSettings, + updateTheme, + } as any; + + await refreshWealthSymphonyThemeAfterLayoutChange(); + + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ mode: 'light', theme: expect.objectContaining({ primary: '#55b7ff' }) })); + expect(updateTheme).toHaveBeenCalledWith(expect.objectContaining({ primary: '#55b7ff' })); + expect(updateSettings.mock.calls.length).toBeGreaterThanOrEqual(2); + + releaseOwnership(); + }); +}); diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthSymphonyTheme.ts b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthSymphonyTheme.ts new file mode 100644 index 0000000..0f9d2ac --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/chat/wealthSymphonyTheme.ts @@ -0,0 +1,133 @@ +import { symphonyThemeBridge } from '../../../Theme/symphonyThemeBridge'; + +const WEALTH_ALLOWED_APPS = + 'com.symphony.zoom,com.symphony.teams,salesforce2-app,com.symphony.sfs.admin-app'; + +export const WEALTH_SHARED_CHAT_SELECTOR = '.wealth-symphony-shared'; +export const WEALTH_CLIENT_CHAT_SELECTOR = '.wealth-symphony-client-contact'; +export const WEALTH_SYMPHONY_THEME_OWNER = 'wealth-management'; +export const WEALTH_SYMPHONY_MODE = 'light'; + +export const WEALTH_SYMPHONY_THEME = { + primary: '#55b7ff', + secondary: '#55b7ff', + accent: '#dc2626', + success: '#0f3d83', + error: '#dc2626', + background: '#ffffff', + surface: '#ffffff', + mention: '#8dcbff', + text: '#111827', + textPrimary: '#111827', + textSecondary: '#111827', + textAccent: '#111827', + textSuccess: '#ffffff', + textError: '#ffffff', + secondaryShades: { + 10: '#e8f3ff', + 20: '#d6ebff', + 30: '#b6ddff', + 40: '#8dcbff', + 60: '#3eaefc', + 70: '#2498f0', + 80: '#147dcb', + 90: '#0d5d96', + }, + accentShades: { + 10: '#fff0f0', + 20: '#ffdada', + 30: '#ffbaba', + 40: '#ff9696', + 60: '#ef4444', + 70: '#dc2626', + 80: '#b91c1c', + 90: '#991b1b', + }, + errorShades: { + 10: '#fff0f0', + 20: '#ffdada', + 30: '#ffbaba', + 40: '#ff9696', + 60: '#ef4444', + 70: '#dc2626', + 80: '#b91c1c', + 90: '#991b1b', + }, +} as const; + +export function getWealthSymphonyThemePayload() { + return { + mode: WEALTH_SYMPHONY_MODE, + theme: WEALTH_SYMPHONY_THEME, + }; +} + +export function getWealthSymphonyThemeUrlParams() { + const payload = getWealthSymphonyThemePayload(); + return { + mode: payload.mode, + theme: JSON.stringify(payload.theme), + }; +} + +export function getWealthSymphonyRenderOptions(overrides: Record = {}) { + const payload = getWealthSymphonyThemePayload(); + return { + showTitle: false, + ecpLoginPopup: true, + canAddPeople: true, + condensed: true, + allowedApps: WEALTH_ALLOWED_APPS, + sound: false, + ...payload, + ...overrides, + }; +} + +export function acquireWealthSymphonyThemeOwnership() { + return symphonyThemeBridge.acquireOwnership(WEALTH_SYMPHONY_THEME_OWNER); +} + +export function applyWealthSymphonyTheme() { + const payload = getWealthSymphonyThemePayload(); + symphonyThemeBridge.applyOwnedTheme(WEALTH_SYMPHONY_THEME_OWNER, { + mode: payload.mode, + theme: { ...payload.theme }, + }); +} + +function waitForNextAnimationFrame() { + return new Promise((resolve) => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => resolve()); + return; + } + + window.setTimeout(resolve, 0); + }); +} + +function delay(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +const SYMPHONY_RESIZE_SETTLE_MS = 600; +const SYMPHONY_THEME_RECONFIRM_MS = 400; + +const SYMPHONY_THEME_SETTLE_MS = 250; + +export async function applyWealthSymphonyThemeWithSettle() { + applyWealthSymphonyTheme(); + await delay(SYMPHONY_THEME_SETTLE_MS); + applyWealthSymphonyTheme(); + await waitForNextAnimationFrame(); +} + +export async function refreshWealthSymphonyThemeAfterLayoutChange() { + await waitForNextAnimationFrame(); + await delay(SYMPHONY_RESIZE_SETTLE_MS); + applyWealthSymphonyTheme(); + await delay(SYMPHONY_THEME_RECONFIRM_MS); + applyWealthSymphonyTheme(); + await waitForNextAnimationFrame(); +} diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/components/ChatLoadingOverlay.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/components/ChatLoadingOverlay.tsx new file mode 100644 index 0000000..0f375c9 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/components/ChatLoadingOverlay.tsx @@ -0,0 +1,38 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '../ui/utils'; + +interface ChatLoadingOverlayProps { + title?: string; + message?: string; + testId?: string; + className?: string; +} + +export default function ChatLoadingOverlay({ + title = 'Loading chat', + message = 'Connecting your conversation and syncing recent messages.', + testId, + className, +}: ChatLoadingOverlayProps) { + return ( +
+
+
{title}
+
{message}
+
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/AppExamples/CleverDeal.React/src/Components/WealthManagement/components/SymphonyChatShell.tsx b/AppExamples/CleverDeal.React/src/Components/WealthManagement/components/SymphonyChatShell.tsx new file mode 100644 index 0000000..f37c3d1 --- /dev/null +++ b/AppExamples/CleverDeal.React/src/Components/WealthManagement/components/SymphonyChatShell.tsx @@ -0,0 +1,137 @@ +import { AlertCircle, X } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '../ui/button'; +import { useEmbeddedClientChatHost } from '../chat/useEmbeddedClientChatHost'; +import ChatLoadingOverlay from './ChatLoadingOverlay'; +import SymphonyMark from './SymphonyMark'; + +function EcpErrorState({ message }: { message: string }) { + return ( +
+
+
+ + Unable to load Symphony chat +
+
{message}
+
+
+ ); +} + +interface SymphonyChatShellProps { + slotClassName: string; + contactId?: string | null; + ecpOrigin?: string; + partnerId?: string; + mode?: 'page' | 'drawer'; + isLoading?: boolean; + maskFrame?: boolean; + error?: Error | null; + onClose?: () => void; +} + +export default function SymphonyChatShell({ + slotClassName, + contactId, + ecpOrigin, + partnerId, + mode = 'page', + isLoading = false, + maskFrame = false, + error = null, + onClose, +}: SymphonyChatShellProps) { + const navigate = useNavigate(); + const { chatError, chatHostUrl, contact, iframeRef, isChatReady } = useEmbeddedClientChatHost({ + contactId, + ecpOrigin, + partnerId, + }); + const subheading = contact ? contact.name : 'Symphony'; + const showBlockingLoader = isLoading; + const useEmbeddedClientChat = mode === 'drawer' && Boolean(contactId); + const hideSharedFrame = !useEmbeddedClientChat && (showBlockingLoader || maskFrame || Boolean(error)); + + const frameOverlay = error + ? + : showBlockingLoader + ? + : maskFrame + ? + : null; + + return ( +
+
+
+
+ +
+
+

Wealth Chat

+
{subheading}
+
+
+
+ {contact && mode === 'page' && ( + + )} + {onClose && ( + + )} +
+
+ +
+ {useEmbeddedClientChat ? ( +
+