diff --git a/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-01-thread-chromium-darwin.png b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-01-thread-chromium-darwin.png new file mode 100644 index 000000000..579080b7d Binary files /dev/null and b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-01-thread-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-02-attachment-chromium-darwin.png b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-02-attachment-chromium-darwin.png new file mode 100644 index 000000000..1a987fde5 Binary files /dev/null and b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-02-attachment-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-03-status-chromium-darwin.png b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-03-status-chromium-darwin.png new file mode 100644 index 000000000..ed8be7a0c Binary files /dev/null and b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-03-status-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-04-relative-days-chromium-darwin.png b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-04-relative-days-chromium-darwin.png new file mode 100644 index 000000000..1f656bbee Binary files /dev/null and b/e2e/screenshots/baseline/support-chat.spec.ts-support-chat-04-relative-days-chromium-darwin.png differ diff --git a/e2e/support-chat.spec.ts b/e2e/support-chat.spec.ts new file mode 100644 index 000000000..dab3e137c --- /dev/null +++ b/e2e/support-chat.spec.ts @@ -0,0 +1,267 @@ +import { test, expect, Page, Route } from '@playwright/test'; +import { getCachedAuth } from './helpers/auth-cache'; + +/** + * E2E Visual Regression Tests: customer support chat + * + * Route: + * - /support/chat/:id (customer chat; file src/screens/chat.screen.tsx) + * + * Auth is REAL customer auth via getCachedAuth (same pattern as e2e/user-flows.spec.ts and + * e2e/support-issue-receiver-iban.spec.ts): the api must be reachable for the session token. + * + * Feature data is MOCKED with synthetic fixtures via page.route(...), so the baselines are + * deterministic and contain no production data. Only the support-issue endpoints used by the + * chat screen are intercepted; everything else (auth/user/settings) is passed through. + * + * Intercepted endpoints (base `/v1/` is prepended by useApi): + * - GET support/issue/:uid (loadSupportIssue) + * - GET support/issue/:uid?fromMessageId=… (sync poller — same body) + * - POST support/issue/:uid/message (submitMessage; not exercised in screenshots) + * + * Synthetic fixtures: fixed uid, fake names — no production data. Most message timestamps are + * fixed ISO dates (absolute date-separator labels). The relative-days case builds “yesterday” / + * “today” at request time so the Today/Yesterday separators stay correct without rotting. + * + * Baselines are produced by the assignee on macOS against a local api; this spec must remain + * runnable without committed PNGs (toHaveScreenshot creates them on first update run). + */ + +const ISSUE_UID = 'chat-e2e-uid-1'; + +const CUSTOMER = 'Customer'; +const SUPPORT_AUTHOR = 'Support Agent'; + +interface ChatMessageFixture { + id: number; + author?: string; + created: string; + message?: string; + fileName?: string; + file?: { file: string; type: string; size: number; url: string }; + status?: 'Sent' | 'Received' | 'Failed'; +} + +interface SupportIssueFixture { + uid: string; + state: string; + type: string; + reason: string; + name: string; + created: string; + messages: ChatMessageFixture[]; +} + +function issueWithMessages(messages: ChatMessageFixture[]): SupportIssueFixture { + return { + uid: ISSUE_UID, + state: 'Pending', + type: 'GenericIssue', + reason: 'Other', + name: 'E2E Chat Issue', + created: '2024-07-09T08:00:00.000Z', + messages, + }; +} + +// Two calendar days so the date separator is visible (first message + day change). +const THREAD_MESSAGES: ChatMessageFixture[] = [ + { + id: 1, + author: CUSTOMER, + created: '2024-07-09T10:00:00.000Z', + message: 'Hello, I need help with my transfer.', + status: 'Received', + }, + { + id: 2, + author: SUPPORT_AUTHOR, + created: '2024-07-10T09:15:00.000Z', + message: 'Thanks for reaching out. We are looking into it.', + }, +]; + +const ATTACHMENT_MESSAGES: ChatMessageFixture[] = [ + { + id: 10, + author: SUPPORT_AUTHOR, + created: '2024-07-10T11:00:00.000Z', + fileName: 'statement.pdf', + message: undefined, + }, +]; + +const STATUS_MESSAGES: ChatMessageFixture[] = [ + { + id: 20, + author: CUSTOMER, + created: '2024-07-10T12:00:00.000Z', + message: 'Still waiting — this is being sent…', + status: 'Sent', + }, + { + id: 21, + author: CUSTOMER, + created: '2024-07-10T12:01:00.000Z', + message: 'This message failed to send.', + status: 'Failed', + }, +]; + +/** + * Local calendar day `daysAgo` ago, at a fixed wall-clock time. + * Midday/afternoon hours stay clear of the midnight boundary so relativeDayKey(today/yesterday) + * and formatSwissTime (de-CH hour:minute) stay stable for the screenshot. + */ +function localIsoDaysAgo(daysAgo: number, hour: number, minute: number): string { + const d = new Date(); + d.setDate(d.getDate() - daysAgo); + d.setHours(hour, minute, 0, 0); + return d.toISOString(); +} + +/** Built at call time (route fulfill / test body), not at module load — labels track “now”. */ +function relativeDayMessages(): ChatMessageFixture[] { + return [ + { + id: 30, + author: CUSTOMER, + // Yesterday 12:00 local — fixed hour so the bubble timestamp does not drift with wall clock. + created: localIsoDaysAgo(1, 12, 0), + message: 'I wrote this yesterday.', + status: 'Received', + }, + { + id: 31, + author: SUPPORT_AUTHOR, + // Today 14:30 local — well clear of midnight for both local day and typical UTC offsets. + created: localIsoDaysAgo(0, 14, 30), + message: 'And this is the reply from today.', + }, + ]; +} + +const ISSUE_RE = /\/v1\/support\/issue\/[^/?]+(?:\?|$)/; +const MESSAGE_RE = /\/v1\/support\/issue\/[^/]+\/message(?:\?|$)/; + +type IssueFixtureOrFactory = SupportIssueFixture | (() => SupportIssueFixture); + +async function installChatRoutes(page: Page, issue: IssueFixtureOrFactory): Promise { + await page.route('**/v1/**', async (route: Route) => { + const request = route.request(); + const url = request.url(); + + if (request.method() === 'GET' && ISSUE_RE.test(url)) { + // Resolve factories at fulfill time so relative “today/yesterday” tracks request clock. + const body = typeof issue === 'function' ? issue() : issue; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + return; + } + + if (request.method() === 'POST' && MESSAGE_RE.test(url)) { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 999, + author: CUSTOMER, + created: new Date().toISOString(), + message: 'ok', + status: 'Received', + }), + }); + return; + } + + await route.continue(); + }); +} + +function chatUrl(token: string): string { + // Force English so text selectors stay stable regardless of the test account's language preference. + return `/support/chat/${ISSUE_UID}?session=${token}&lang=en`; +} + +test.describe('Support Chat - Visual Regression Tests', () => { + let token: string; + + test.beforeAll(async ({ request }) => { + const auth = await getCachedAuth(request, 'evm'); + token = auth.token; + }); + + test('thread with customer message, support reply and date separator', async ({ page }) => { + await installChatRoutes(page, issueWithMessages(THREAD_MESSAGES)); + + await page.goto(chatUrl(token)); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('Hello, I need help with my transfer.')).toBeVisible(); + await expect(page.getByText('Thanks for reaching out. We are looking into it.')).toBeVisible(); + await expect(page.getByText(SUPPORT_AUTHOR)).toBeVisible(); + + await page.waitForTimeout(1500); + await expect(page).toHaveScreenshot('support-chat-01-thread.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); + + test('attachment message shows support author name', async ({ page }) => { + await installChatRoutes(page, issueWithMessages(ATTACHMENT_MESSAGES)); + + await page.goto(chatUrl(token)); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText(SUPPORT_AUTHOR)).toBeVisible(); + await expect(page.getByText('statement.pdf')).toBeVisible(); + + await page.waitForTimeout(1500); + await expect(page).toHaveScreenshot('support-chat-02-attachment.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); + + test('customer messages in sending and failed states', async ({ page }) => { + await installChatRoutes(page, issueWithMessages(STATUS_MESSAGES)); + + await page.goto(chatUrl(token)); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('Still waiting — this is being sent…')).toBeVisible(); + await expect(page.getByText('This message failed to send.')).toBeVisible(); + + await page.waitForTimeout(1500); + await expect(page).toHaveScreenshot('support-chat-03-status.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); + + test('date separators show Yesterday and Today for relative days', async ({ page }) => { + // Messages are dated relative to “now” so separators use the Today/Yesterday labels + // (not the absolute “Tue, Jul 9” path covered by support-chat-01-thread). + // Factory re-evaluates at each GET fulfill so labels stay aligned with render-time “now”. + await installChatRoutes(page, () => issueWithMessages(relativeDayMessages())); + + await page.goto(chatUrl(token)); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('I wrote this yesterday.')).toBeVisible(); + await expect(page.getByText('And this is the reply from today.')).toBeVisible(); + // lang=en → English keys from screens/support (Today / Yesterday). + await expect(page.getByText('Yesterday', { exact: true })).toBeVisible(); + await expect(page.getByText('Today', { exact: true })).toBeVisible(); + + await page.waitForTimeout(1500); + await expect(page).toHaveScreenshot('support-chat-04-relative-days.png', { + fullPage: true, + maxDiffPixels: 5000, + }); + }); +}); diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index ffd402b3d..32b77be9a 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -127,6 +127,10 @@ "title": "Support-Dashboard Übersicht", "description": "Support-Dashboard-Übersicht und Statistiken." }, + "support-chat": { + "title": "Kunden-Support-Chat", + "description": "Kundenchat unter /support/chat: Verlauf mit Datumstrenner, Anhang mit Absendername, Zustände „wird gesendet“ und fehlgeschlagen." + }, "subpage": { "title": "Unterseiten", "description": "Buy-, Sell-, Swap- und Transaktions-Unterseiten." diff --git a/src/__tests__/chat.screen.test.tsx b/src/__tests__/chat.screen.test.tsx new file mode 100644 index 000000000..a9b4598df --- /dev/null +++ b/src/__tests__/chat.screen.test.tsx @@ -0,0 +1,1891 @@ +// Component tests for the customer support chat screen (src/screens/chat.screen.tsx). +// Mirrors the mock pattern of support-issue-receiver-iban.test.tsx: full object-literal +// factories for @dfx.swiss/react and @dfx.swiss/react-components (no requireActual). + +const mockNavigate = jest.fn(); +const mockLoadSupportIssue = jest.fn(); +const mockSetSync = jest.fn(); +const mockSubmitMessage = jest.fn(); +const mockLoadFileData = jest.fn(); +const mockGetTransactionByUid = jest.fn(); +const mockTranslate = jest.fn((_ns: string, key: string) => key); +const mockTranslateError = jest.fn((message: string) => message); +const mockSupportIssueUidGet = jest.fn(); +const mockSupportIssueUidSet = jest.fn(); +const mockUseLayoutOptions = jest.fn(); +const mockReportClientError = jest.fn(); +const mockRetryMessage = jest.fn(); + +let mockSupportIssue: any; +let mockIsLoading = false; +let mockIsError: string | undefined; +/** When true, the context mock exposes retryMessage (SDK after packages#210). */ +let mockHasRetryMessage = false; +let mockIssueUidParam: string | undefined; +const mockChatPathname = '/support/chat'; + +jest.mock('@dfx.swiss/react', () => { + const SupportMessageStatus = { + SENT: 'Sent', + RECEIVED: 'Received', + FAILED: 'Failed', + }; + + const SupportIssueType = { + GENERIC_ISSUE: 'GenericIssue', + TRANSACTION_ISSUE: 'TransactionIssue', + VERIFICATION_CALL: 'VerificationCall', + KYC_ISSUE: 'KycIssue', + LIMIT_REQUEST: 'LimitRequest', + PARTNERSHIP_REQUEST: 'PartnershipRequest', + NOTIFICATION_OF_CHANGES: 'NotificationOfChanges', + BUG_REPORT: 'BugReport', + }; + + const TransactionState = { + UNASSIGNED: 'Unassigned', + WAITING_FOR_PAYMENT: 'WaitingForPayment', + CREATED: 'Created', + PROCESSING: 'Processing', + COMPLETED: 'Completed', + FAILED: 'Failed', + }; + + const TransactionType = { + BUY: 'Buy', + SELL: 'Sell', + SWAP: 'Swap', + }; + + return { + SupportMessageStatus, + SupportIssueType, + SupportIssueReason: {}, + SupportIssueState: { PENDING: 'Pending' }, + TransactionState, + TransactionType, + Department: { + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + COOPERATION: 'Cooperation', + }, + UserRole: { + ADMIN: 'Admin', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + CUSTODY: 'Custody', + }, + useSupportChatContext: () => ({ + supportIssue: mockSupportIssue, + isLoading: mockIsLoading, + isError: mockIsError, + loadSupportIssue: mockLoadSupportIssue, + setSync: mockSetSync, + submitMessage: mockSubmitMessage, + loadFileData: mockLoadFileData, + // Only present when tests opt in — mirrors published 1.7.x vs packages#210. + ...(mockHasRetryMessage ? { retryMessage: mockRetryMessage } : {}), + }), + useTransaction: () => ({ + getTransactionByUid: mockGetTransactionByUid, + }), + }; +}); + +jest.mock('src/util/client-error', () => ({ + reportClientError: (...args: unknown[]) => mockReportClientError(...args), +})); + +jest.mock('@dfx.swiss/react-components', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + + return { + AssetIconVariant: { BTC: 'BTC', ETH: 'ETH', USDT: 'USDT' }, + DfxAssetIcon: ({ asset }: any) => React.createElement('div', { 'data-testid': `asset-icon-${asset}` }), + DfxIcon: () => React.createElement('div', { 'data-testid': 'dfx-icon-help' }), + IconSize: { LG: 'lg' }, + IconVariant: { HELP: 'help' }, + SpinnerSize: { LG: 'lg', MD: 'md' }, + SpinnerVariant: { LIGHT_MODE: 'light' }, + StyledCollapsible: ({ titleContent, children }: any) => + React.createElement('div', { 'data-testid': 'tx-collapsible' }, titleContent, children), + StyledLoadingSpinner: () => React.createElement('div', { 'data-testid': 'loading-spinner' }), + StyledVerticalStack: ({ children }: any) => React.createElement('div', null, children), + }; +}); + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (ns: string, key: string) => mockTranslate(ns, key), + translateError: (message: string) => mockTranslateError(message), + locale: 'en-US', + }), +})); + +jest.mock('src/hooks/layout-config.hook', () => ({ + // Capture options so onBack (chat.screen.tsx) can be exercised without the layout shell. + useLayoutOptions: (options: unknown) => mockUseLayoutOptions(options), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('src/hooks/session-store.hook', () => ({ + useSessionStore: () => ({ + supportIssueUid: { + get: () => mockSupportIssueUidGet(), + set: (v: string) => mockSupportIssueUidSet(v), + remove: jest.fn(), + }, + }), +})); + +jest.mock('src/screens/transaction.screen', () => ({ + // eslint-disable-next-line @typescript-eslint/no-var-requires + TxInfo: () => require('react').createElement('div', { 'data-testid': 'tx-info' }), +})); + +jest.mock('src/config/labels', () => ({ + IssueTypeLabels: { + GenericIssue: 'Generic issue', + TransactionIssue: 'Transaction issue', + }, + toPaymentStateLabel: (state: string) => `label-${state}`, +})); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: () => ({ id: mockIssueUidParam }), + useLocation: () => ({ pathname: mockChatPathname }), + }; +}); + +import { SupportMessageStatus } from '@dfx.swiss/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import ChatScreen from 'src/screens/chat.screen'; + +function makeMessage(overrides: Record = {}) { + return { + id: 1, + author: 'Customer', + created: new Date(2024, 6, 10, 14, 30), + message: 'Hello from customer', + status: SupportMessageStatus.RECEIVED, + ...overrides, + }; +} + +function makeIssue(overrides: Record = {}) { + return { + uid: 'issue-uid-1', + state: 'Pending', + type: 'GenericIssue', + reason: 'Other', + name: 'Test', + created: new Date(2024, 6, 10), + messages: [makeMessage()], + ...overrides, + }; +} + +function renderChat() { + return render(); +} + +describe('ChatScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockIsLoading = false; + mockIsError = undefined; + mockHasRetryMessage = false; + mockIssueUidParam = undefined; + mockSupportIssue = makeIssue(); + mockSupportIssueUidGet.mockReturnValue('issue-uid-1'); + mockLoadSupportIssue.mockResolvedValue(undefined); + mockSubmitMessage.mockResolvedValue(undefined); + mockLoadFileData.mockResolvedValue(undefined); + mockGetTransactionByUid.mockReset(); + mockReportClientError.mockReset(); + mockRetryMessage.mockReset(); + mockTranslate.mockImplementation((_ns: string, key: string) => key); + mockTranslateError.mockImplementation((message: string) => message); + Element.prototype.scrollIntoView = jest.fn(); + // Default: motion allowed so later scrolls can use smooth. + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + // Image attachment previews + global.URL.createObjectURL = jest.fn(() => 'blob:mock-preview'); + global.URL.revokeObjectURL = jest.fn(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + // --- Screen shell / routing --- + + it('shows a loading spinner while the issue is loading', () => { + mockIsLoading = true; + mockSupportIssue = undefined; + renderChat(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows a loading spinner when there is no support issue yet', () => { + mockSupportIssue = undefined; + renderChat(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('loads the session issue and enables sync when a stored uid is present', async () => { + renderChat(); + await waitFor(() => { + expect(mockSetSync).toHaveBeenCalledWith(true); + expect(mockLoadSupportIssue).toHaveBeenCalledWith('issue-uid-1'); + }); + }); + + it('navigates to the issue form when no session uid is available', async () => { + mockSupportIssueUidGet.mockReturnValue(undefined); + mockSupportIssue = undefined; + renderChat(); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/support/issue', { replace: true }); + }); + }); + + it('stores the route param uid and replaces the URL', async () => { + mockIssueUidParam = 'param-uid-9'; + mockSupportIssueUidGet.mockReturnValue(undefined); + mockSupportIssue = undefined; + renderChat(); + await waitFor(() => { + expect(mockSupportIssueUidSet).toHaveBeenCalledWith('param-uid-9'); + expect(mockNavigate).toHaveBeenCalledWith('/support/chat', { replace: true }); + }); + }); + + it('redirects to the issue form when loadSupportIssue rejects', async () => { + mockLoadSupportIssue.mockRejectedValue(new Error('not found')); + mockSupportIssue = undefined; + renderChat(); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/support/issue', { replace: true }); + }); + }); + + it('reports a failed loadSupportIssue before navigating away', async () => { + // reportChatError at loadSupportIssue.catch — customer is redirected without explanation. + const loadError = new Error('issue gone'); + mockLoadSupportIssue.mockRejectedValue(loadError); + mockSupportIssue = undefined; + renderChat(); + await waitFor(() => { + expect(mockReportClientError).toHaveBeenCalledWith(loadError, mockChatPathname); + expect(mockNavigate).toHaveBeenCalledWith('/support/issue', { replace: true }); + }); + }); + + it('disables sync on unmount', async () => { + const { unmount } = renderChat(); + await waitFor(() => expect(mockSetSync).toHaveBeenCalledWith(true)); + unmount(); + expect(mockSetSync).toHaveBeenCalledWith(false); + }); + + it('registers an onBack handler that navigates to the tickets list', () => { + renderChat(); + expect(mockUseLayoutOptions).toHaveBeenCalled(); + const options = mockUseLayoutOptions.mock.calls[mockUseLayoutOptions.mock.calls.length - 1][0] as { + onBack?: () => void; + }; + expect(options.onBack).toEqual(expect.any(Function)); + options.onBack?.(); + expect(mockNavigate).toHaveBeenCalledWith('/support/tickets'); + }); + + it('scrolls to the latest message when messages are present', async () => { + renderChat(); + await waitFor(() => { + expect(Element.prototype.scrollIntoView).toHaveBeenCalled(); + }); + }); + + it('jumps without animation on the first scroll, then uses smooth for later messages', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalled(); + }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto' }); + + // Second open with a longer thread — same mount so hasScrolledToEndRef stays true. + mockSupportIssue = makeIssue({ + messages: [makeMessage({ id: 1, message: 'First' }), makeMessage({ id: 2, message: 'Second' })], + }); + rerender(); + await waitFor(() => { + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth' }); + }); + }); + + it('always uses auto scroll when the user prefers reduced motion', async () => { + (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto' }); + + mockSupportIssue = makeIssue({ + messages: [makeMessage({ id: 1, message: 'First' }), makeMessage({ id: 2, message: 'Second' })], + }); + rerender(); + await waitFor(() => { + expect(scrollIntoView.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto' }); + }); + + // --- B2 DFX colours --- + + it('styles the customer bubble with dfxBlue-800 and the support bubble with dfxGray-300', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, author: 'Customer', message: 'Customer text' }), + makeMessage({ id: 2, author: 'Support Agent', message: 'Support text', status: undefined }), + ], + }); + renderChat(); + + const customerText = screen.getByText('Customer text'); + const supportText = screen.getByText('Support text'); + const customerBubble = customerText.closest('div.flex.flex-col.max-w-xs'); + const supportBubble = supportText.closest('div.flex.flex-col.max-w-xs'); + + expect(customerBubble).toHaveClass('bg-dfxBlue-800'); + expect(customerBubble).toHaveClass('text-white'); + expect(customerBubble?.className).not.toContain('24A1DE'); + expect(supportBubble).toHaveClass('bg-dfxGray-300'); + expect(supportBubble).toHaveClass('text-dfxBlue-800'); + }); + + it('uses ground-dependent timestamp colours (light on blue, dfxGray-800 on grey)', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, author: 'Customer', message: 'Customer text', created: new Date(2024, 6, 10, 10, 15) }), + makeMessage({ + id: 2, + author: 'Support Agent', + message: 'Support text', + status: undefined, + created: new Date(2024, 6, 10, 10, 20), + }), + ], + }); + renderChat(); + + const customerBubble = screen.getByText('Customer text').closest('div.flex.flex-col.max-w-xs'); + const supportBubble = screen.getByText('Support text').closest('div.flex.flex-col.max-w-xs'); + const customerTime = customerBubble?.querySelector('.text-white\\/70, [class*="text-white"]'); + // Tailwind class is text-white/70 — check class string + const customerTimeRow = customerBubble?.querySelector('.text-xs.italic'); + const supportTimeRow = supportBubble?.querySelector('.text-xs.italic'); + expect(customerTimeRow?.className).toContain('text-white/70'); + expect(supportTimeRow?.className).toContain('text-dfxGray-800'); + expect(customerTime).toBeTruthy(); + }); + + it('renders the support author name in text-dfxBlue-400 (not red)', () => { + mockSupportIssue = makeIssue({ + messages: [makeMessage({ id: 1, author: 'Support Agent', message: 'Hi', status: undefined })], + }); + renderChat(); + const author = screen.getByText('Support Agent'); + expect(author).toHaveClass('text-dfxBlue-400'); + expect(author).not.toHaveClass('text-dfxRed-150'); + }); + + // --- B3 date separators --- + + it('renders a date separator above the first message', () => { + const now = new Date(); + mockSupportIssue = makeIssue({ + messages: [makeMessage({ created: now, message: 'First' })], + }); + renderChat(); + expect(screen.getByText('Today')).toBeInTheDocument(); + expect(mockTranslate).toHaveBeenCalledWith('screens/support', 'Today'); + }); + + it('renders a date separator when the calendar day changes, including same day-of-month across months', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, created: new Date(2024, 6, 6, 12, 0), message: 'July' }), + makeMessage({ id: 2, created: new Date(2024, 7, 6, 12, 0), message: 'August' }), + ], + }); + renderChat(); + // Two separators — one for each calendar day (first message + day change). + const separators = document.querySelectorAll('.bg-dfxGray-300.text-dfxGray-700.rounded-full'); + expect(separators.length).toBe(2); + }); + + it('does not insert a second separator for messages on the same calendar day', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, created: new Date(2024, 6, 6, 9, 0), message: 'Morning' }), + makeMessage({ id: 2, created: new Date(2024, 6, 6, 18, 0), message: 'Evening' }), + ], + }); + renderChat(); + const separators = document.querySelectorAll('.bg-dfxGray-300.text-dfxGray-700.rounded-full'); + expect(separators.length).toBe(1); + }); + + it('labels yesterday messages with Yesterday', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + mockSupportIssue = makeIssue({ + messages: [makeMessage({ created: yesterday, message: 'Y-msg' })], + }); + renderChat(); + expect(screen.getByText('Yesterday')).toBeInTheDocument(); + expect(mockTranslate).toHaveBeenCalledWith('screens/support', 'Yesterday'); + }); + + // --- B4 delivery status only on own messages --- + + it('shows delivery status icons only on customer messages', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, author: 'Customer', message: 'Mine', status: SupportMessageStatus.RECEIVED }), + makeMessage({ id: 2, author: 'Support Agent', message: 'Theirs', status: undefined }), + ], + }); + renderChat(); + expect(screen.getByTestId('msg-status-received')).toBeInTheDocument(); + const supportBubble = screen.getByText('Theirs').closest('div.flex.flex-col.max-w-xs'); + expect(within(supportBubble as HTMLElement).queryByTestId('msg-status-received')).not.toBeInTheDocument(); + expect(within(supportBubble as HTMLElement).queryByTestId('msg-status-sent')).not.toBeInTheDocument(); + expect(within(supportBubble as HTMLElement).queryByTestId('msg-status-failed')).not.toBeInTheDocument(); + }); + + it('shows the sending clock for SENT and the error icon for FAILED customer messages', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, author: 'Customer', message: 'Sending', status: SupportMessageStatus.SENT }), + makeMessage({ id: 2, author: 'Customer', message: 'Failed', status: SupportMessageStatus.FAILED }), + ], + }); + renderChat(); + expect(screen.getByTestId('msg-status-sent')).toBeInTheDocument(); + expect(screen.getByTestId('msg-status-failed')).toBeInTheDocument(); + }); + + it('renders a failed customer message with an error surface, not as a retry control', () => { + // SDK without retryMessage (published 1.7.x) — error surface only, no promised tap action. + mockHasRetryMessage = false; + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 77, author: 'Customer', message: 'Lost packet', status: SupportMessageStatus.FAILED }), + ], + }); + renderChat(); + + const failed = screen.getByTestId('msg-failed'); + expect(failed).toHaveClass('border-dfxRed-100'); + expect(failed.className).not.toMatch(/pointer-events-none/); + expect(failed.className).not.toMatch(/opacity-60/); + expect(failed.tagName).not.toBe('BUTTON'); + expect(screen.queryByRole('button', { name: 'Retry sending message' })).not.toBeInTheDocument(); + expect(screen.queryByText('Tap to retry')).not.toBeInTheDocument(); + expect(screen.queryByTestId('msg-retry-hint')).not.toBeInTheDocument(); + expect(screen.getByTestId('msg-status-failed')).toBeInTheDocument(); + }); + + it('turns a failed message into a retry control when the SDK exposes retryMessage', () => { + // SDK with retryMessage (after DFXswiss/packages#210). + mockHasRetryMessage = true; + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 88, author: 'Customer', message: 'Lost packet', status: SupportMessageStatus.FAILED }), + ], + }); + renderChat(); + + const retry = screen.getByRole('button', { name: 'Retry sending message' }); + expect(retry).toHaveAttribute('data-testid', 'msg-failed'); + expect(retry).toHaveClass('border-dfxRed-100'); + expect(screen.getByTestId('msg-retry-hint')).toHaveTextContent('Tap to retry'); + expect(mockTranslate).toHaveBeenCalledWith('screens/support', 'Tap to retry'); + expect(mockTranslate).toHaveBeenCalledWith('screens/support', 'Retry sending message'); + + fireEvent.click(retry); + expect(mockRetryMessage).toHaveBeenCalledTimes(1); + expect(mockRetryMessage).toHaveBeenCalledWith(88); + }); + + it('does not offer retry while a failed message is already re-sending (SENT)', () => { + // After retryMessage, the context marks the bubble SENT — second tap must not re-fire. + mockHasRetryMessage = true; + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 88, author: 'Customer', message: 'Re-sending', status: SupportMessageStatus.SENT }), + ], + }); + renderChat(); + + expect(screen.queryByRole('button', { name: 'Retry sending message' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('msg-retry-hint')).not.toBeInTheDocument(); + expect(screen.getByTestId('msg-status-sent')).toBeInTheDocument(); + expect(mockRetryMessage).not.toHaveBeenCalled(); + }); + + it('treats a missing author as a customer message (right-aligned, with status)', () => { + mockSupportIssue = makeIssue({ + messages: [makeMessage({ id: 1, author: undefined, message: 'No author', status: SupportMessageStatus.SENT })], + }); + renderChat(); + expect(screen.getByTestId('msg-status-sent')).toBeInTheDocument(); + const bubble = screen.getByText('No author').closest('div.flex.flex-col.max-w-xs'); + expect(bubble).toHaveClass('bg-dfxBlue-800'); + }); + + // --- B5 author visible with attachments --- + + it('shows the support author name above a file attachment', () => { + // B5: author must stay visible when a loaded DataFile is present (the old guard was `!file`). + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 1, + author: 'Support Agent', + message: undefined, + fileName: 'statement.pdf', + file: { + file: 'x', + type: 'application/pdf', + size: 1024, + url: 'https://example.com/statement.pdf', + }, + status: undefined, + }), + ], + }); + renderChat(); + expect(screen.getByText('Support Agent')).toBeInTheDocument(); + expect(screen.getByText('statement.pdf')).toBeInTheDocument(); + }); + + // --- B1 dead code gone --- + + it('does not render reply previews, reaction chips, or a bubble menu', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 1, + author: 'Customer', + message: 'With legacy fields', + replyTo: 99, + reactions: [{ emoji: '👍', users: ['a'] }], + }), + ], + }); + renderChat(); + expect(screen.queryByText('Reply')).not.toBeInTheDocument(); + expect(screen.queryByText('👍')).not.toBeInTheDocument(); + expect(document.body.textContent).not.toContain('Reply to'); + }); + + // --- InputComponent --- + + it('updates the controlled textarea value through onChange alone (onInput was removed as redundant)', () => { + // After removing the duplicate onInput handler, typing must still land in the field via onChange. + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'Typed via change' } }); + expect(textarea.value).toBe('Typed via change'); + }); + + it('submits a typed message and clears the input', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + fireEvent.change(textarea, { target: { value: 'Need help' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + await waitFor(() => { + expect(mockSubmitMessage).toHaveBeenCalledWith('Need help', []); + }); + expect(mockReportClientError).not.toHaveBeenCalled(); + }); + + it('reports when submitMessage rejects without breaking the composer', async () => { + // reportClientError at handleSend.catch — customer still sees the failed bubble from context. + const sendError = new Error('network down'); + mockSubmitMessage.mockRejectedValue(sendError); + renderChat(); + fireEvent.change(screen.getByPlaceholderText('Write a message...'), { target: { value: 'Try me' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + await waitFor(() => { + expect(mockReportClientError).toHaveBeenCalledWith(sendError, mockChatPathname); + }); + // Input cleared as before; no second error surface from the report. + expect(screen.getByPlaceholderText('Write a message...')).toHaveValue(''); + expect(screen.queryByTestId('chat-sync-error')).not.toBeInTheDocument(); + }); + + it('does not surface a failed report as a second error for the customer', async () => { + // reportClientError is fire-and-forget: after a send failure the only customer-facing + // signal stays the context's failed bubble path — the report itself never becomes UI. + const sendError = new Error('send failed'); + mockSubmitMessage.mockRejectedValue(sendError); + renderChat(); + fireEvent.change(screen.getByPlaceholderText('Write a message...'), { target: { value: 'Still here' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + await waitFor(() => { + expect(mockReportClientError).toHaveBeenCalledWith(sendError, mockChatPathname); + }); + expect(screen.getByPlaceholderText('Write a message...')).toHaveValue(''); + expect(screen.getByTestId('chat-scroll')).toBeInTheDocument(); + expect(screen.queryByTestId('chat-sync-error')).not.toBeInTheDocument(); + expect(screen.queryByText(/send failed/i)).not.toBeInTheDocument(); + }); + + it('disables the send button when the input is empty and enables it when text is present', () => { + renderChat(); + const sendButton = screen.getByRole('button', { name: 'Send message' }); + // disabled + inactive styles when empty + expect(sendButton).toBeDisabled(); + expect(sendButton).toHaveClass('bg-dfxGray-500'); + expect(sendButton).not.toHaveClass('bg-dfxBlue-800'); + expect(mockTranslate).toHaveBeenCalledWith('screens/support', 'Send message'); + expect(mockTranslate).toHaveBeenCalledWith('screens/support', 'Attach file'); + + // canSend true — active surface + fireEvent.change(screen.getByPlaceholderText('Write a message...'), { target: { value: 'Hi' } }); + expect(sendButton).not.toBeDisabled(); + expect(sendButton).toHaveClass('bg-dfxBlue-800'); + expect(sendButton).toHaveClass('text-white'); + expect(sendButton).toHaveClass('cursor-pointer'); + }); + + it('enables send for an attachment without text and submits files alone', async () => { + // Mirrors the SDK guard: hasText || hasFiles. + renderChat(); + const sendButton = screen.getByRole('button', { name: 'Send message' }); + expect(sendButton).toBeDisabled(); + + const image = new File(['png'], 'shot.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(screen.getByPlaceholderText('Write a message...'), { + clipboardData: { items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }] }, + }); + }); + + expect(sendButton).not.toBeDisabled(); + fireEvent.click(sendButton); + await waitFor(() => { + expect(mockSubmitMessage).toHaveBeenCalled(); + const [msg, files] = mockSubmitMessage.mock.calls[0]; + expect(msg === undefined || msg === '' || msg === null || !String(msg).trim()).toBe(true); + expect(files).toHaveLength(1); + expect(files[0].name).toBe('shot.png'); + }); + }); + + it('does not enable send for whitespace-only text without files', () => { + renderChat(); + fireEvent.change(screen.getByPlaceholderText('Write a message...'), { target: { value: ' ' } }); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + + it('does not submit when the input is empty', () => { + renderChat(); + const sendButton = screen.getByRole('button', { name: 'Send message' }); + expect(sendButton).toBeDisabled(); + fireEvent.click(sendButton); + expect(mockSubmitMessage).not.toHaveBeenCalled(); + }); + + it('shows a length error above 4000 characters and blocks send', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const long = 'x'.repeat(4001); + fireEvent.change(textarea, { target: { value: long } }); + expect(mockTranslateError).toHaveBeenCalledWith('message_length'); + expect(screen.getByText('message_length')).toBeInTheDocument(); + + const sendButton = screen.getByRole('button', { name: 'Send message' }); + expect(sendButton).toBeDisabled(); + fireEvent.click(sendButton); + expect(mockSubmitMessage).not.toHaveBeenCalled(); + + // Clearing the error when value shrinks again + fireEvent.change(textarea, { target: { value: 'ok' } }); + expect(screen.queryByText('message_length')).not.toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + }); + + it('styles the composer as a white pill on grey with a top separator', () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + // Pill surface on the auto-grow grid (parent of the textarea) + const pill = textarea.parentElement; + expect(pill).toHaveClass('bg-white'); + expect(pill).toHaveClass('border-dfxGray-500'); + expect(pill).toHaveClass('rounded-full'); + expect(textarea).toHaveClass('bg-transparent'); + // Composer bar — grey ground + top rule separating it from the thread + const bar = pill?.parentElement?.parentElement; + expect(bar).toHaveClass('bg-dfxGray-300'); + expect(bar).toHaveClass('border-t'); + expect(bar).toHaveClass('border-dfxGray-500'); + // Soft top corners only (scale: lg) — not a full pill like the field + expect(bar).toHaveClass('rounded-t-lg'); + // Home-indicator inset (no prior safe-area pattern in the repo) + expect(bar?.className).toMatch(/safe-area-inset-bottom/); + }); + + it('does not send when Enter is pressed on an empty field', () => { + // Covers handleSend's early return: the button is disabled when empty, but Enter still + // reaches handleSend via handleKeyDown. + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + expect(mockSubmitMessage).not.toHaveBeenCalled(); + }); + + it('sends on Enter and inserts a newline on Shift+Enter', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...') as HTMLTextAreaElement; + + // 234:4 false — non-Enter keys must not send or alter the value. + fireEvent.change(textarea, { target: { value: 'stay' } }); + fireEvent.keyDown(textarea, { key: 'a' }); + expect(textarea.value).toBe('stay'); + expect(mockSubmitMessage).not.toHaveBeenCalled(); + + // 237:38 false — Shift+Enter on an empty field keeps the empty string branch. + fireEvent.change(textarea, { target: { value: '' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); + expect(textarea.value).toBe(''); + + fireEvent.change(textarea, { target: { value: 'Line1' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); + expect(textarea.value).toContain('\n'); + + fireEvent.change(textarea, { target: { value: 'Send me' } }); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + await waitFor(() => { + expect(mockSubmitMessage).toHaveBeenCalledWith('Send me', []); + }); + }); + + it('attaches selected files and allows removing them before send', async () => { + renderChat(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['hello'], 'note.pdf', { type: 'application/pdf' }); + + // 223:4 false — empty / cancelled file pick must not add chips. + await act(async () => { + fireEvent.change(fileInput, { target: { files: [] } }); + }); + expect(screen.queryByText('note.pdf')).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + expect(screen.getByText('note.pdf')).toBeInTheDocument(); + // Chip contrast: dfxBlue-800 on dfxGray-400 + const chip = screen.getByText('note.pdf').parentElement as HTMLElement; + expect(chip).toHaveClass('text-dfxBlue-800'); + expect(chip).toHaveClass('bg-dfxGray-400'); + + // Chip layout: paperclip svg + name + close svg — click the last svg (MdOutlineClose). + const svgs = chip.querySelectorAll('svg'); + expect(svgs.length).toBeGreaterThanOrEqual(2); + fireEvent.click(svgs[svgs.length - 1]); + expect(screen.queryByText('note.pdf')).not.toBeInTheDocument(); + }); + + it('submits selected files together with the message text', async () => { + renderChat(); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['hello'], 'doc.pdf', { type: 'application/pdf' }); + await act(async () => { + fireEvent.change(fileInput, { target: { files: [file] } }); + }); + + const textarea = screen.getByPlaceholderText('Write a message...'); + fireEvent.change(textarea, { target: { value: 'With file' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send message' })); + + await waitFor(() => { + expect(mockSubmitMessage).toHaveBeenCalled(); + const [msg, files] = mockSubmitMessage.mock.calls[0]; + expect(msg).toBe('With file'); + expect(files).toHaveLength(1); + expect(files[0].name).toBe('doc.pdf'); + }); + }); + + it('pastes image files from the clipboard and shows a preview chip', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const image = new File(['png-bytes'], 'screenshot.png', { type: 'image/png' }); + const clipboardData = { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + }; + + await act(async () => { + fireEvent.paste(textarea, { clipboardData }); + }); + + expect(screen.getByText('screenshot.png')).toBeInTheDocument(); + // asBlobPreviewUrl true branch: only blob: URLs may reach img src. + expect(screen.getByTestId('attachment-preview')).toHaveAttribute('src', 'blob:mock-preview'); + expect(URL.createObjectURL).toHaveBeenCalled(); + }); + + it('falls back to the paperclip when a preview URL is not a blob: object URL', async () => { + // asBlobPreviewUrl false branch — sink guard for CodeQL js/xss-through-dom at img src. + (URL.createObjectURL as jest.Mock).mockReturnValue('https://evil.example/not-a-blob'); + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const image = new File(['png-bytes'], 'screenshot.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }] }, + }); + }); + expect(screen.getByText('screenshot.png')).toBeInTheDocument(); + expect(URL.createObjectURL).toHaveBeenCalled(); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + }); + + it('leaves text-only paste to the default browser behaviour', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const clipboardData = { + items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }], + }; + await act(async () => { + fireEvent.paste(textarea, { clipboardData }); + }); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + it('rejects disallowed file types on paste with the shared file_type error', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const bad = new File(['x'], 'payload.exe', { type: 'application/octet-stream' }); + const clipboardData = { + items: [{ kind: 'file', type: 'application/octet-stream', getAsFile: () => bad }], + }; + await act(async () => { + fireEvent.paste(textarea, { clipboardData }); + }); + expect(mockTranslateError).toHaveBeenCalledWith('file_type'); + expect(screen.getByText('file_type')).toBeInTheDocument(); + expect(screen.queryByText('payload.exe')).not.toBeInTheDocument(); + // Constructed SupportAttachmentTypeError — no browser Error object on type reject. + expect(mockReportClientError).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SupportAttachmentTypeError', + message: 'Rejected support chat attachment type', + }), + mockChatPathname, + ); + }); + + it('shows a quiet connection banner and reports when the message sync fails', async () => { + // isError banner + reportChatError for SupportSyncError (context sets isError, never cleared UI). + mockIsError = 'Error while syncing messages'; + renderChat(); + expect(screen.getByTestId('chat-sync-error')).toHaveTextContent( + 'Connection interrupted. New messages cannot be received right now.', + ); + expect(mockTranslate).toHaveBeenCalledWith( + 'screens/support', + 'Connection interrupted. New messages cannot be received right now.', + ); + expect(mockReportClientError).toHaveBeenCalledWith( + expect.objectContaining({ name: 'SupportSyncError', message: 'Error while syncing messages' }), + mockChatPathname, + ); + }); + + it('hides the connection banner once isError clears', async () => { + mockIsError = 'Error while syncing messages'; + const { rerender } = renderChat(); + expect(screen.getByTestId('chat-sync-error')).toBeInTheDocument(); + mockIsError = undefined; + rerender(); + expect(screen.queryByTestId('chat-sync-error')).not.toBeInTheDocument(); + }); + + it('leaves the bubble timestamp empty when created is not a valid date', () => { + // formatMessageTime + DateTag — never show "Invalid Date" (utils.formatSwissTime untouched). + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Broken clock', created: 'not-a-date' }), + makeMessage({ id: 2, message: 'No clock', created: undefined }), + ], + }); + renderChat(); + expect(screen.getByText('Broken clock')).toBeInTheDocument(); + expect(screen.getByText('No clock')).toBeInTheDocument(); + expect(screen.queryByText(/Invalid Date/i)).not.toBeInTheDocument(); + }); + + it('accepts dropped files on the composer and shows a drag highlight', async () => { + renderChat(); + const zone = screen.getByTestId('composer-drop-zone'); + const pdf = new File(['%PDF'], 'scan.pdf', { type: 'application/pdf' }); + + fireEvent.dragOver(zone); + expect(zone.className).toMatch(/ring-dfxBlue-400/); + + await act(async () => { + fireEvent.drop(zone, { dataTransfer: { files: [pdf] } }); + }); + expect(zone.className).not.toMatch(/ring-dfxBlue-400/); + expect(screen.getByText('scan.pdf')).toBeInTheDocument(); + // Non-image chips keep the paperclip (no preview img). + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + }); + + it('shows a thumbnail only when the MIME type is image/*, never from the file name alone', async () => { + // isImageFile uses file.type only (CodeQL: file.name must not gate img src). + renderChat(); + const zone = screen.getByTestId('composer-drop-zone'); + + const image = new File(['png-bytes'], 'shot.png', { type: 'image/png' }); + await act(async () => { + fireEvent.drop(zone, { dataTransfer: { files: [image] } }); + }); + expect(screen.getByTestId('attachment-preview')).toHaveAttribute('src', 'blob:mock-preview'); + expect(URL.createObjectURL).toHaveBeenCalled(); + + // Remove image chip (close icon is the last svg on the chip). + const imageChip = screen.getByText('shot.png').parentElement as HTMLElement; + fireEvent.click(imageChip.querySelectorAll('svg')[imageChip.querySelectorAll('svg').length - 1]); + expect(screen.queryByText('shot.png')).not.toBeInTheDocument(); + (URL.createObjectURL as jest.Mock).mockClear(); + + const pdf = new File(['%PDF'], 'doc.pdf', { type: 'application/pdf' }); + await act(async () => { + fireEvent.drop(zone, { dataTransfer: { files: [pdf] } }); + }); + expect(screen.getByText('doc.pdf')).toBeInTheDocument(); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + + // Extension looks like an image but empty MIME — attachable via name fallback, no preview. + const pdfChip = screen.getByText('doc.pdf').parentElement as HTMLElement; + fireEvent.click(pdfChip.querySelectorAll('svg')[pdfChip.querySelectorAll('svg').length - 1]); + const namedOnly = new File(['x'], 'looks-like.png', { type: '' }); + await act(async () => { + fireEvent.drop(zone, { dataTransfer: { files: [namedOnly] } }); + }); + expect(screen.getByText('looks-like.png')).toBeInTheDocument(); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + it('clears the drag highlight when the pointer leaves the composer without dropping', () => { + // Covers handleDragLeave (chat.screen.tsx ~450–453). + renderChat(); + const zone = screen.getByTestId('composer-drop-zone'); + fireEvent.dragOver(zone); + expect(zone.className).toMatch(/ring-dfxBlue-400/); + + fireEvent.dragLeave(zone); + expect(zone.className).not.toMatch(/ring-dfxBlue-400/); + }); + + it('does nothing when a drop carries an empty file list (addFiles early return)', async () => { + // 370: if (files.length === 0) return + renderChat(); + const zone = screen.getByTestId('composer-drop-zone'); + await act(async () => { + fireEvent.drop(zone, { dataTransfer: { files: [] } }); + }); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(mockTranslateError).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + + it('treats a drop with missing dataTransfer.files as an empty list', async () => { + // 460: e.dataTransfer.files ?? [] — synthetic events may omit files; keep the fallback. + renderChat(); + const zone = screen.getByTestId('composer-drop-zone'); + await act(async () => { + fireEvent.drop(zone, { dataTransfer: {} }); + }); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(mockTranslateError).not.toHaveBeenCalled(); + }); + + it('ignores paste when clipboardData has no items list', async () => { + // 426: if (!items) return + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + await act(async () => { + fireEvent.paste(textarea, { clipboardData: {} }); + }); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + it('skips clipboard file items whose getAsFile() returns null', async () => { + // 433: if (file) files.push(file) — false branch when the browser yields no File. + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => null }], + }, + }); + }); + expect(screen.queryByTestId('attachment-preview')).not.toBeInTheDocument(); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + }); + + it('clears a prior file_type error when a valid attachment is added', async () => { + // 378 true path + 380 true ternary (prev === fileTypeError → undefined). + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const bad = new File(['x'], 'payload.exe', { type: 'application/octet-stream' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'application/octet-stream', getAsFile: () => bad }] }, + }); + }); + expect(screen.getByText('file_type')).toBeInTheDocument(); + + const good = new File(['%PDF'], 'ok.pdf', { type: 'application/pdf' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'application/pdf', getAsFile: () => good }] }, + }); + }); + expect(screen.queryByText('file_type')).not.toBeInTheDocument(); + expect(screen.getByText('ok.pdf')).toBeInTheDocument(); + }); + + it('does not clear a message_length error when attaching a file over the limit', async () => { + // 378: accepted.length > 0 && length <= 4000 — false when length > 4000 (keep length error). + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: 'x'.repeat(4001) } }); + expect(screen.getByText('message_length')).toBeInTheDocument(); + + const good = new File(['%PDF'], 'late.pdf', { type: 'application/pdf' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'application/pdf', getAsFile: () => good }] }, + }); + }); + expect(screen.getByText('message_length')).toBeInTheDocument(); + expect(screen.getByText('late.pdf')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled(); + }); + + it('revokes object URLs when an image chip is removed', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText('Write a message...'); + const image = new File(['png'], 'a.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }] }, + }); + }); + expect(URL.createObjectURL).toHaveBeenCalled(); + const chip = screen.getByText('a.png').parentElement as HTMLElement; + fireEvent.click(chip.querySelectorAll('svg')[chip.querySelectorAll('svg').length - 1]); + await waitFor(() => { + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-preview'); + }); + }); + + // --- Scroll orientation --- + + function scrollThreadAwayFromBottom() { + const el = screen.getByTestId('chat-scroll'); + Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => 1000 }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 200 }); + Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: 0 }); + fireEvent.scroll(el); + } + + /** Find a React useRef object whose `.current` is `el` (walks fiber ancestors). */ + function findReactRefFor(el: Element): { current: Element | null } | null { + const fiberKey = Object.keys(el).find( + (k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'), + ); + if (!fiberKey) return null; + let fiber: { memoizedState?: { memoizedState?: unknown; next?: unknown }; return?: unknown } | null = ( + el as unknown as Record + )[fiberKey] as { + memoizedState?: { memoizedState?: unknown; next?: unknown }; + return?: unknown; + }; + while (fiber) { + let hook: { memoizedState?: unknown; next?: unknown } | null | undefined = fiber.memoizedState; + while (hook) { + const m = hook.memoizedState as { current?: unknown } | null | undefined; + if (m && typeof m === 'object' && 'current' in m && m.current === el) { + return m as { current: Element | null }; + } + hook = hook.next as typeof hook; + } + fiber = fiber.return as typeof fiber; + } + return null; + } + + it('keeps the scroll position and shows New + unread when messages arrive while scrolled up', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + const callsAfterOpen = scrollIntoView.mock.calls.length; + + scrollThreadAwayFromBottom(); + expect(screen.getByTestId('scroll-to-bottom')).toBeInTheDocument(); + expect(screen.queryByTestId('unread-count')).not.toBeInTheDocument(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'New one', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + + // No auto-scroll while the user is reading further up. + expect(scrollIntoView.mock.calls.length).toBe(callsAfterOpen); + expect(screen.getByTestId('new-messages-divider')).toBeInTheDocument(); + expect(screen.getByText('New')).toBeInTheDocument(); + expect(screen.getByTestId('unread-count')).toHaveTextContent('1'); + }); + + it('advances the message counter when the end anchor is not mounted, so that batch is not unread later', async () => { + // Covers the !messagesEndRef.current early return (chat.screen.tsx ~113–115): + // while the spinner is up the anchor is not in the tree, but prevMessageCount must still + // move forward so those messages are not treated as unread once the user scrolls up later. + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + + // 1) Open the thread once so hasScrolledToEndRef is true. + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + + // 2) Hide the thread (loading) but keep an issue with one message. + mockIsLoading = true; + mockSupportIssue = makeIssue({ + messages: [makeMessage({ id: 1, message: 'While loading start' })], + }); + rerender(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + + // 3) A second message arrives while the end anchor is still unmounted → !end branch. + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'While loading start' }), + makeMessage({ id: 2, message: 'Arrived during load', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + + // 4) Show the thread again (same length — effect does not re-fire). + mockIsLoading = false; + rerender(); + await waitFor(() => expect(screen.getByTestId('chat-scroll')).toBeInTheDocument()); + scrollThreadAwayFromBottom(); + + // 5) Only a later arrival should count as unread (counter already at 2). + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'While loading start' }), + makeMessage({ id: 2, message: 'Arrived during load', author: 'Support Agent', status: undefined }), + makeMessage({ id: 3, message: 'After reveal', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + + expect(screen.getByTestId('unread-count')).toHaveTextContent('1'); + expect(screen.getByTestId('new-messages-divider')).toBeInTheDocument(); + // “New” sits immediately before the post-reveal message, not before the in-load batch. + const afterReveal = screen.getByText('After reveal'); + const divider = screen.getByTestId('new-messages-divider'); + expect( + afterReveal.compareDocumentPosition(divider) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + const duringLoad = screen.getByText('Arrived during load'); + expect( + duringLoad.compareDocumentPosition(divider) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('scrolls to the bottom on button click and clears the New marker', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + scrollThreadAwayFromBottom(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'New one', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + expect(screen.getByTestId('new-messages-divider')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('scroll-to-bottom')); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth' }); + expect(screen.queryByTestId('new-messages-divider')).not.toBeInTheDocument(); + expect(screen.queryByTestId('scroll-to-bottom')).not.toBeInTheDocument(); + }); + + it('still auto-scrolls when new messages arrive while the user is at the bottom', async () => { + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + // Stay near bottom (default isNearBottomRef = true; ensure scroll metrics agree). + const el = screen.getByTestId('chat-scroll'); + Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => 500 }); + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => 500 }); + Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: 0 }); + fireEvent.scroll(el); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'Fresh', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + await waitFor(() => { + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'smooth' }); + }); + expect(screen.queryByTestId('new-messages-divider')).not.toBeInTheDocument(); + }); + + it('does not mark unread when the message list shrinks or stays without additions', async () => { + // 126: else if (added > 0) — false when length drops (or would for added <= 0). + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'One' }), + makeMessage({ id: 2, message: 'Two', author: 'Support Agent', status: undefined }), + ], + }); + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + const callsAfterOpen = scrollIntoView.mock.calls.length; + scrollThreadAwayFromBottom(); + + mockSupportIssue = makeIssue({ + messages: [makeMessage({ id: 1, message: 'One' })], + }); + rerender(); + + expect(scrollIntoView.mock.calls.length).toBe(callsAfterOpen); + expect(screen.queryByTestId('new-messages-divider')).not.toBeInTheDocument(); + expect(screen.queryByTestId('unread-count')).not.toBeInTheDocument(); + }); + + it('keeps the first New marker when further messages arrive while scrolled up', async () => { + // 131: firstUnread already set — do not overwrite on a second batch (else of === undefined && previousLength > 0). + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + scrollThreadAwayFromBottom(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'First new', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + expect(screen.getByTestId('unread-count')).toHaveTextContent('1'); + expect(screen.getByTestId('new-messages-divider')).toBeInTheDocument(); + const firstNew = screen.getByText('First new'); + const dividerAfterFirst = screen.getByTestId('new-messages-divider'); + expect( + firstNew.compareDocumentPosition(dividerAfterFirst) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'First new', author: 'Support Agent', status: undefined }), + makeMessage({ id: 3, message: 'Second new', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + + expect(screen.getByTestId('unread-count')).toHaveTextContent('2'); + // Divider stays anchored before the first new message, not moved to the second batch. + const dividerAfterSecond = screen.getByTestId('new-messages-divider'); + const stillFirst = screen.getByText('First new'); + const secondNew = screen.getByText('Second new'); + // compareDocumentPosition(other): PRECEDING means other precedes this node. + expect( + stillFirst.compareDocumentPosition(dividerAfterSecond) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + expect( + secondNew.compareDocumentPosition(dividerAfterSecond) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + }); + + it('ignores thread scroll events when the scroll container ref is unset', async () => { + // 151: if (!el) return in handleThreadScroll — crash guard; ref nulled via fiber. + const { rerender } = renderChat(); + await waitFor(() => expect(Element.prototype.scrollIntoView).toHaveBeenCalled()); + scrollThreadAwayFromBottom(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'New one', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + expect(screen.getByTestId('unread-count')).toHaveTextContent('1'); + + const scrollEl = screen.getByTestId('chat-scroll'); + const scrollRef = findReactRefFor(scrollEl); + if (!scrollRef) throw new Error('expected scrollContainerRef on chat-scroll fiber'); + scrollRef.current = null; + + // Metrics would clear unread if the handler ran past the guard. + Object.defineProperty(scrollEl, 'scrollHeight', { configurable: true, get: () => 200 }); + Object.defineProperty(scrollEl, 'clientHeight', { configurable: true, get: () => 200 }); + Object.defineProperty(scrollEl, 'scrollTop', { configurable: true, writable: true, value: 0 }); + fireEvent.scroll(scrollEl); + + expect(screen.getByTestId('unread-count')).toHaveTextContent('1'); + expect(screen.getByTestId('scroll-to-bottom')).toBeInTheDocument(); + + scrollRef.current = scrollEl; + }); + + it('no-ops scrollToBottom when the end anchor ref is unset', async () => { + // 160: if (!end) return in scrollToBottom. + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + scrollThreadAwayFromBottom(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'New one', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + expect(screen.getByTestId('scroll-to-bottom')).toBeInTheDocument(); + + const scrollEl = screen.getByTestId('chat-scroll'); + const endEl = scrollEl.lastElementChild as HTMLElement; + const endRef = findReactRefFor(endEl); + if (!endRef) throw new Error('expected messagesEndRef on end-anchor fiber'); + endRef.current = null; + + const callsBefore = scrollIntoView.mock.calls.length; + fireEvent.click(screen.getByTestId('scroll-to-bottom')); + + expect(scrollIntoView.mock.calls.length).toBe(callsBefore); + expect(screen.getByTestId('scroll-to-bottom')).toBeInTheDocument(); + expect(screen.getByTestId('new-messages-divider')).toBeInTheDocument(); + + endRef.current = endEl; + }); + + it('jumps without animation when scroll-to-bottom is used under reduced motion', async () => { + // 161: prefersReducedMotion() ? 'auto' : 'smooth' — auto branch on the jump button. + (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const { rerender } = renderChat(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + scrollThreadAwayFromBottom(); + + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, message: 'Old' }), + makeMessage({ id: 2, message: 'New one', author: 'Support Agent', status: undefined }), + ], + }); + rerender(); + + fireEvent.click(screen.getByTestId('scroll-to-bottom')); + expect(scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto' }); + expect(screen.queryByTestId('scroll-to-bottom')).not.toBeInTheDocument(); + }); + + // --- ChatBubbleFileEmbed --- + + it('triggers loadFileData when an unloaded attachment is clicked and shows errors', async () => { + mockLoadFileData.mockRejectedValue(new Error('boom')); + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 42, + author: 'Support Agent', + message: undefined, + fileName: 'report.pdf', + file: undefined, + status: undefined, + }), + ], + }); + renderChat(); + fireEvent.click(screen.getByText('report.pdf')); + await waitFor(() => { + expect(mockLoadFileData).toHaveBeenCalledWith(42); + expect(screen.getByText('Download failed')).toBeInTheDocument(); + }); + }); + + it('opens a loaded document attachment in a new tab', () => { + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 5, + author: 'Support Agent', + message: undefined, + fileName: 'invoice.pdf', + file: { + file: 'x', + type: 'application/pdf', + size: 2048, + url: 'https://example.com/invoice.pdf', + }, + status: undefined, + }), + ], + }); + renderChat(); + fireEvent.click(screen.getByText('invoice.pdf')); + expect(openSpy).toHaveBeenCalledWith('https://example.com/invoice.pdf', '_blank'); + openSpy.mockRestore(); + }); + + it('opens and closes an image lightbox for a loaded image attachment', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 6, + author: 'Support Agent', + message: undefined, + fileName: 'photo.png', + file: { + file: 'x', + type: 'image/png', + size: 4096, + url: 'https://example.com/photo.png', + }, + status: undefined, + }), + ], + }); + renderChat(); + const thumbs = screen.getAllByAltText('photo.png'); + fireEvent.click(thumbs[0]); + // Lightbox image appears (second img with same alt) + expect(screen.getAllByAltText('photo.png').length).toBeGreaterThanOrEqual(2); + + // Close via the top-right button + const closeButtons = screen.getAllByRole('button'); + const lightboxClose = closeButtons.find((b) => b.className.includes('absolute')); + expect(lightboxClose).toBeTruthy(); + fireEvent.click(lightboxClose as HTMLElement); + expect(screen.getAllByAltText('photo.png')).toHaveLength(1); + }); + + it('stops propagation when the lightbox backdrop is clicked', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 7, + author: 'Support Agent', + message: undefined, + fileName: 'shot.jpg', + file: { + file: 'x', + type: 'image/jpeg', + size: 1024, + url: 'https://example.com/shot.jpg', + }, + status: undefined, + }), + ], + }); + renderChat(); + fireEvent.click(screen.getAllByAltText('shot.jpg')[0]); + const backdrop = document.querySelector('.fixed.inset-0'); + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop as Element); + // Still open (stopPropagation only — no close on backdrop) + expect(document.querySelector('.fixed.inset-0')).toBeTruthy(); + }); + + // --- TransactionComponent --- + + it('renders a linked transaction collapsible with asset icon for a completed buy', async () => { + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Completed', + inputAsset: 'CHF', + outputAsset: 'BTC', + inputAmount: 100, + outputAmount: 0.01, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-1', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(mockGetTransactionByUid).toHaveBeenCalledWith('tx-1'); + expect(screen.getByTestId('tx-collapsible')).toBeInTheDocument(); + expect(screen.getByTestId('asset-icon-BTC')).toBeInTheDocument(); + expect(screen.getByTestId('tx-info')).toBeInTheDocument(); + }); + }); + + it('uses the help icon when no matching asset icon is found', async () => { + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Completed', + inputAsset: 'UNKNOWN', + outputAsset: 'UNKNOWN2', + inputAmount: 1, + outputAmount: 2, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-2', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByTestId('dfx-icon-help')).toBeInTheDocument(); + }); + }); + + it('prefers inputAsset for sell transactions and strips a leading d from asset codes', async () => { + mockGetTransactionByUid.mockResolvedValue({ + type: 'Sell', + state: 'Completed', + inputAsset: 'dBTC', + outputAsset: 'CHF', + inputAmount: 0.5, + outputAmount: 20000, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-3', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByTestId('asset-icon-BTC')).toBeInTheDocument(); + }); + }); + + it('marks unassigned transactions without an asset icon and with the red state class', async () => { + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Unassigned', + inputAsset: 'BTC', + outputAsset: 'BTC', + inputAmount: 1, + outputAmount: 1, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-4', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByTestId('dfx-icon-help')).toBeInTheDocument(); + expect(screen.getByText('label-Unassigned')).toHaveClass('text-dfxRed-100'); + }); + }); + + it('shows a transaction loading spinner while the fetch is in flight', async () => { + let resolveTx!: (value: unknown) => void; + mockGetTransactionByUid.mockImplementation( + () => + new Promise((resolve) => { + resolveTx = resolve; + }), + ); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-load', url: 'https://example.com/tx' }, + }); + renderChat(); + expect(await screen.findByText('Loading transaction...')).toBeInTheDocument(); + // Spinner next to the loading label (screen shell spinner is gone once the issue is loaded). + expect(screen.getAllByTestId('loading-spinner').length).toBeGreaterThanOrEqual(1); + + await act(async () => { + resolveTx({ + type: 'Buy', + state: 'Completed', + inputAsset: 'CHF', + outputAsset: 'BTC', + inputAmount: 1, + outputAmount: 0.001, + }); + await Promise.resolve(); + }); + await waitFor(() => { + expect(screen.queryByText('Loading transaction...')).not.toBeInTheDocument(); + expect(screen.getByTestId('tx-collapsible')).toBeInTheDocument(); + }); + }); + + it('shows a transaction loading state then an error message when the fetch fails', async () => { + mockGetTransactionByUid.mockRejectedValue({ message: 'tx gone' }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-err', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByText('tx gone')).toBeInTheDocument(); + expect(screen.getByText('tx gone')).toHaveClass('text-dfxRed-100'); + }); + }); + + it('falls back to Unknown error when the transaction fetch rejection has no message', async () => { + mockGetTransactionByUid.mockRejectedValue({}); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-err2', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByText('Unknown error')).toBeInTheDocument(); + }); + }); + + it('renders amount arrow only when both input and output assets are present', async () => { + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Completed', + inputAsset: 'CHF', + outputAsset: 'ETH', + inputAmount: 50, + outputAmount: 0.02, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-5', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByText(/50 CHF/)).toBeInTheDocument(); + expect(screen.getByText(/→/)).toBeInTheDocument(); + expect(screen.getByText(/0.02 ETH/)).toBeInTheDocument(); + }); + }); + + it('renders asset labels with empty amounts when amounts are missing', async () => { + // 164:36 inputAmount ?? '' and 166:37 outputAmount ?? '' — assets present, amounts undefined. + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Completed', + inputAsset: 'CHF', + outputAsset: 'ETH', + inputAmount: undefined, + outputAmount: undefined, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-amounts-empty', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByTestId('tx-collapsible')).toBeInTheDocument(); + }); + const title = screen.getByTestId('tx-collapsible'); + // Leading space from `${''} ${asset}` is intentional; match asset tokens and the arrow. + expect(title.textContent).toMatch(/CHF/); + expect(title.textContent).toMatch(/→/); + expect(title.textContent).toMatch(/ETH/); + expect(title.textContent).not.toMatch(/\d+\s*CHF/); + expect(title.textContent).not.toMatch(/\d+\s*ETH/); + }); + + it('renders only the available asset side when the other is missing', async () => { + // Output-only side (existing path). + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Completed', + inputAsset: undefined, + outputAsset: 'ETH', + inputAmount: undefined, + outputAmount: 1, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-6', url: 'https://example.com/tx' }, + }); + const { unmount } = renderChat(); + await waitFor(() => { + expect(screen.getByText(/1 ETH/)).toBeInTheDocument(); + expect(screen.queryByText(/→/)).not.toBeInTheDocument(); + }); + unmount(); + + // 166:17 else — inputAsset only, no outputAsset (and no arrow). + mockGetTransactionByUid.mockResolvedValue({ + type: 'Buy', + state: 'Completed', + inputAsset: 'CHF', + outputAsset: undefined, + inputAmount: 10, + outputAmount: undefined, + }); + mockSupportIssue = makeIssue({ + transaction: { uid: 'tx-6b', url: 'https://example.com/tx' }, + }); + renderChat(); + await waitFor(() => { + expect(screen.getByText(/10 CHF/)).toBeInTheDocument(); + expect(screen.queryByText(/→/)).not.toBeInTheDocument(); + }); + }); + + it('does not show a second author header when consecutive messages share the same author', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ id: 1, author: 'Support Agent', message: 'One', status: undefined }), + makeMessage({ + id: 2, + author: 'Support Agent', + message: undefined, + fileName: 'follow-up.pdf', + status: undefined, + }), + ], + }); + renderChat(); + expect(screen.getAllByText('Support Agent')).toHaveLength(1); + // 348:12 else — same author + attachment: hasHeader false and hasFile true → no pt-1.5. + const fileBubble = screen.getByText('follow-up.pdf').closest('div.flex.flex-col.max-w-xs'); + expect(fileBubble).toBeTruthy(); + expect(fileBubble?.className).not.toContain('pt-1.5'); + }); + + it('shows Downloading… while loadFileData is in flight', async () => { + let resolveLoad!: () => void; + mockLoadFileData.mockImplementation( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 8, + author: 'Support Agent', + message: undefined, + fileName: 'pending.pdf', + file: undefined, + status: undefined, + }), + ], + }); + renderChat(); + fireEvent.click(screen.getByText('pending.pdf')); + expect(await screen.findByText('Downloading...')).toBeInTheDocument(); + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + }); + + it('shows Document · size for a loaded non-image attachment', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 9, + author: 'Support Agent', + message: undefined, + fileName: 'sheet.xlsx', + file: { + file: 'x', + type: 'application/vnd.ms-excel', + size: 1024, + url: 'https://example.com/sheet.xlsx', + }, + status: undefined, + }), + ], + }); + renderChat(); + expect(screen.getByText(/Document/)).toBeInTheDocument(); + }); + + it('falls back to Document for an unknown MIME type prefix', () => { + mockSupportIssue = makeIssue({ + messages: [ + makeMessage({ + id: 10, + author: 'Support Agent', + message: undefined, + fileName: 'blob.bin', + file: { + file: 'x', + type: 'application/octet-stream', + size: 10, + url: 'https://example.com/blob.bin', + }, + status: undefined, + }), + ], + }); + renderChat(); + expect(screen.getByText(/Document/)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/support-helpers.test.ts b/src/__tests__/support-helpers.test.ts index 6d3c46660..23c46f3b6 100644 --- a/src/__tests__/support-helpers.test.ts +++ b/src/__tests__/support-helpers.test.ts @@ -1,7 +1,12 @@ // Mock the label config so support-helpers does not pull in the real @dfx.swiss/react label tables. +// Non-empty maps so typeLabel/reasonLabel hit both the mapped and the fallback branch. jest.mock('src/config/labels', () => ({ - IssueReasonLabels: {}, - IssueTypeLabels: {}, + IssueTypeLabels: { + GenericIssue: 'Generic issue', + }, + IssueReasonLabels: { + Other: 'Other reason', + }, })); // Mock @dfx.swiss/react to avoid ES module issues in jest. @@ -22,7 +27,34 @@ jest.mock('@dfx.swiss/react', () => ({ })); import { Department, UserRole } from '@dfx.swiss/react'; -import { visibleDepartmentsForRole } from 'src/util/support-helpers'; +import { + isSameCalendarDay, + reasonLabel, + relativeDayKey, + shouldShowDateSeparator, + typeLabel, + visibleDepartmentsForRole, +} from 'src/util/support-helpers'; + +describe('typeLabel', () => { + it('returns the mapped label for a known issue type', () => { + expect(typeLabel('GenericIssue')).toBe('Generic issue'); + }); + + it('falls back to the raw type string when no label is configured', () => { + expect(typeLabel('UnknownType')).toBe('UnknownType'); + }); +}); + +describe('reasonLabel', () => { + it('returns the mapped label for a known issue reason', () => { + expect(reasonLabel('Other')).toBe('Other reason'); + }); + + it('falls back to the raw reason string when no label is configured', () => { + expect(reasonLabel('UnknownReason')).toBe('UnknownReason'); + }); +}); describe('visibleDepartmentsForRole', () => { it('limits support to the support department', () => { @@ -49,3 +81,54 @@ describe('visibleDepartmentsForRole', () => { expect(visibleDepartmentsForRole(UserRole.CUSTODY)).toEqual([]); }); }); + +describe('isSameCalendarDay', () => { + it('treats two times on the same local day as equal', () => { + expect(isSameCalendarDay(new Date(2024, 6, 6, 8, 0), new Date(2024, 6, 6, 23, 59))).toBe(true); + }); + + it('does not treat the same day-of-month in different months as equal', () => { + // B3 regression: getDate()-only comparison would wrongly return true here. + expect(isSameCalendarDay(new Date(2024, 6, 6, 12, 0), new Date(2024, 7, 6, 12, 0))).toBe(false); + }); + + it('does not treat the same month/day in different years as equal', () => { + expect(isSameCalendarDay(new Date(2023, 6, 6), new Date(2024, 6, 6))).toBe(false); + }); + + it('accepts ISO string inputs', () => { + const a = new Date(2024, 0, 15, 10, 0); + const b = new Date(2024, 0, 15, 18, 0); + expect(isSameCalendarDay(a.toISOString(), b.toISOString())).toBe(true); + }); +}); + +describe('shouldShowDateSeparator', () => { + it('always shows a separator above the first message', () => { + expect(shouldShowDateSeparator(new Date(2024, 6, 6), undefined)).toBe(true); + }); + + it('hides the separator when the previous message is the same calendar day', () => { + expect(shouldShowDateSeparator(new Date(2024, 6, 6, 18, 0), new Date(2024, 6, 6, 9, 0))).toBe(false); + }); + + it('shows the separator when the calendar day changes (including same day-of-month across months)', () => { + expect(shouldShowDateSeparator(new Date(2024, 7, 6, 9, 0), new Date(2024, 6, 6, 18, 0))).toBe(true); + }); +}); + +describe('relativeDayKey', () => { + const now = new Date(2024, 6, 10, 15, 0, 0); // 10 Jul 2024 + + it('returns Today for the current calendar day', () => { + expect(relativeDayKey(new Date(2024, 6, 10, 1, 0), now)).toBe('Today'); + }); + + it('returns Yesterday for the previous calendar day', () => { + expect(relativeDayKey(new Date(2024, 6, 9, 23, 0), now)).toBe('Yesterday'); + }); + + it('returns null for older days', () => { + expect(relativeDayKey(new Date(2024, 6, 8, 12, 0), now)).toBeNull(); + }); +}); diff --git a/src/screens/chat.screen.tsx b/src/screens/chat.screen.tsx index 90b78c327..309462fce 100644 --- a/src/screens/chat.screen.tsx +++ b/src/screens/chat.screen.tsx @@ -22,36 +22,89 @@ import { StyledVerticalStack, } from '@dfx.swiss/react-components'; import { useEffect, useRef, useState } from 'react'; -import { BsReply } from 'react-icons/bs'; import { HiOutlineDownload, HiOutlinePaperClip } from 'react-icons/hi'; -import { MdAccessTime, MdErrorOutline, MdOutlineCancel, MdOutlineClose, MdSend } from 'react-icons/md'; +import { MdAccessTime, MdErrorOutline, MdKeyboardArrowDown, MdOutlineClose, MdSend } from 'react-icons/md'; import { RiCheckFill } from 'react-icons/ri'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { IssueTypeLabels, toPaymentStateLabel } from 'src/config/labels'; import { useSettingsContext } from 'src/contexts/settings.context'; import { useNavigation } from 'src/hooks/navigation.hook'; import { useSessionStore } from 'src/hooks/session-store.hook'; +import { reportClientError } from 'src/util/client-error'; +import { relativeDayKey, shouldShowDateSeparator } from 'src/util/support-helpers'; import { blankedAddress, formatBytes, formatSwissTime } from 'src/util/utils'; import { useLayoutOptions } from '../hooks/layout-config.hook'; import { TxInfo } from './transaction.screen'; -const emojiSet = ['👍', '❤️', '😂', '😮', '😢', '👏']; +/** Single source for the file picker accept list and paste/drop validation. */ +const ACCEPTED_FILE_EXTENSIONS = ['.pdf', '.jpeg', '.jpg', '.png'] as const; +const ACCEPTED_FILE_ACCEPT = ACCEPTED_FILE_EXTENSIONS.join(', '); +const ACCEPTED_MIME_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); + +function isAcceptedAttachment(file: File): boolean { + if (file.type && ACCEPTED_MIME_TYPES.has(file.type.toLowerCase())) return true; + const name = file.name.toLowerCase(); + return ACCEPTED_FILE_EXTENSIONS.some((ext) => name.endsWith(ext)); +} + +/** Preview chips use MIME only — never the filename — so a user-controlled name cannot gate img src. */ +function isImageFile(file: File): boolean { + return file.type.startsWith('image/'); +} + +/** + * Only object-URLs may become an . Values that reach this helper can be + * traced from clipboard/drop File objects (CodeQL js/xss-through-dom); createObjectURL + * always yields `blob:…`, so this is an explicit sink guard, not a behaviour change. + */ +function asBlobPreviewUrl(url: string | undefined): string | undefined { + return url && url.startsWith('blob:') ? url : undefined; +} + +/** Bubble timestamps only — leave global formatSwissTime alone (many other call sites). */ +function formatMessageTime(value: Date | string | number | undefined): string { + if (value == null) return ''; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return formatSwissTime(date); +} + +/** Pixels from the bottom that still count as “at the end of the thread”. */ +const SCROLL_BOTTOM_THRESHOLD_PX = 48; + +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +function isScrollNearBottom(el: HTMLElement): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_BOTTOM_THRESHOLD_PX; +} export default function ChatScreen(): JSX.Element { const { navigate } = useNavigation(); const { translate } = useSettingsContext(); - const { supportIssue, isLoading, loadSupportIssue, handleEmojiClick, setSync } = useSupportChatContext(); + const { supportIssue, isLoading, isError, loadSupportIssue, setSync } = useSupportChatContext(); const { supportIssueUid: supportIssueUidStore } = useSessionStore(); const { id: issueUidParam } = useParams(); + // Same route source as error.screen — memory-router safe in widget/library builds. + const { pathname } = useLocation(); const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + // First scroll jumps instantly; later arrivals animate only when the user was already at the bottom. + const hasScrolledToEndRef = useRef(false); + const isNearBottomRef = useRef(true); + const prevMessageCountRef = useRef(0); + /** Tracks the first unread id without re-running the message-length effect. */ + const firstUnreadMessageIdRef = useRef(); - const [clickedMessage, setClickedMessage] = useState(); - const [replyToMessage, setReplyToMessage] = useState(); - const [menuPosition, _setMenuPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const [sessionUid, setSessionUid] = useState(() => { return supportIssueUidStore.get() || ''; }); + const [isNearBottom, setIsNearBottom] = useState(true); + const [unreadCount, setUnreadCount] = useState(0); + /** Id of the first message that arrived while the user was scrolled up — drives the “New” line. */ + const [firstUnreadMessageId, setFirstUnreadMessageId] = useState(); useEffect(() => { if (issueUidParam) { @@ -60,7 +113,8 @@ export default function ChatScreen(): JSX.Element { navigate('/support/chat', { replace: true }); } else if (sessionUid) { setSync(true); - loadSupportIssue(sessionUid).catch(() => { + loadSupportIssue(sessionUid).catch((error: unknown) => { + reportClientError(error, pathname); navigate('/support/issue', { replace: true }); }); } else { @@ -70,28 +124,73 @@ export default function ChatScreen(): JSX.Element { return () => setSync(false); }, [issueUidParam, sessionUid]); + // Sync failure is silent in the context (isError set, never shown). Report what the customer + // is about to see as the connection banner; dedup lives inside reportClientError. useEffect(() => { - if (supportIssue?.messages && messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [supportIssue?.messages.length]); + if (!isError) return; + reportClientError(Object.assign(new Error(isError), { name: 'SupportSyncError' }), pathname); + }, [isError, pathname]); - function onChatBubbleClick(e?: React.MouseEvent, _message?: SupportMessage) { - if (!e) { - setClickedMessage(undefined); + useEffect(() => { + if (!supportIssue?.messages) return; + + const messages = supportIssue.messages; + const length = messages.length; + const previousLength = prevMessageCountRef.current; + const added = length - previousLength; + const end = messagesEndRef.current; + if (!end) { + prevMessageCountRef.current = length; return; } - e.stopPropagation(); - // TODO: Uncomment to enable replies & reactions (feature not yet available) - // setMenuPosition({ top: e.clientY, left: e.clientX }); - // setClickedMessage(message); + const reduced = prefersReducedMotion(); + + if (!hasScrolledToEndRef.current) { + // Initial open: jump to the latest message without animation. + end.scrollIntoView({ behavior: 'auto' }); + hasScrolledToEndRef.current = true; + isNearBottomRef.current = true; + setIsNearBottom(true); + } else if (added > 0) { + if (isNearBottomRef.current) { + end.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth' }); + } else { + // User is reading older messages — keep position, mark what is new. + if (firstUnreadMessageIdRef.current === undefined && previousLength > 0) { + const firstNewId = messages[previousLength].id; + firstUnreadMessageIdRef.current = firstNewId; + setFirstUnreadMessageId(firstNewId); + } + setUnreadCount((count) => count + added); + } + } + + prevMessageCountRef.current = length; + }, [supportIssue?.messages.length]); + + function clearUnreadMarkers() { + firstUnreadMessageIdRef.current = undefined; + setFirstUnreadMessageId(undefined); + setUnreadCount(0); } - function onEmojiClick(messageId: number, emoji: string, e?: React.MouseEvent) { - e?.stopPropagation(); - handleEmojiClick(messageId, emoji); - setClickedMessage(undefined); + function handleThreadScroll() { + const el = scrollContainerRef.current; + if (!el) return; + const near = isScrollNearBottom(el); + isNearBottomRef.current = near; + setIsNearBottom(near); + if (near) clearUnreadMarkers(); + } + + function scrollToBottom() { + const end = messagesEndRef.current; + if (!end) return; + end.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + isNearBottomRef.current = true; + setIsNearBottom(true); + clearUnreadMarkers(); } useLayoutOptions({ @@ -108,50 +207,82 @@ export default function ChatScreen(): JSX.Element { ) : (
-
- {!!supportIssue.transaction && } - {supportIssue.messages.map((message, index) => { - const prevSender = index > 0 ? supportIssue.messages[index - 1].author : null; - const isNewSender = prevSender !== message.author; - return ( -
- {index > 0 && - new Date(message.created).getDate() !== - new Date(supportIssue.messages[index - 1].created).getDate() && ( - +
+
+ {isError && ( +
+

+ {translate( + 'screens/support', + 'Connection interrupted. New messages cannot be received right now.', )} - m.id === message.replyTo) : undefined - } - onEmojiClick={onEmojiClick} - onClick={(e) => onChatBubbleClick(e, message)} - {...message} - /> +

- ); - })} -
+ )} + {!!supportIssue.transaction && } + {supportIssue.messages.map((message, index) => { + const prevSender = index > 0 ? supportIssue.messages[index - 1].author : null; + const isNewSender = prevSender !== message.author; + const previousCreated = index > 0 ? supportIssue.messages[index - 1].created : undefined; + return ( +
+ {shouldShowDateSeparator(message.created, previousCreated) && } + {firstUnreadMessageId !== undefined && message.id === firstUnreadMessageId && ( + + )} + +
+ ); + })} +
+
+ {!isNearBottom && ( + + )}
- - {clickedMessage?.id !== undefined && ( - { - setReplyToMessage(message); - setClickedMessage(undefined); - }} - onEmojiClick={onEmojiClick} - /> - )} +
)} ); } +function NewMessagesDivider(): JSX.Element { + const { translate } = useSettingsContext(); + return ( +
+
+ {translate('screens/support', 'New')} +
+
+ ); +} + interface TransactionComponentProps { transactionUid: string; } @@ -227,49 +358,94 @@ interface DateTagProps { } function DateTag({ date }: DateTagProps): JSX.Element { - const { locale } = useSettingsContext(); + const { locale, translate } = useSettingsContext(); + const parsed = date instanceof Date ? date : new Date(date); + // Same rule as the bubble clock: never render the browser's "Invalid Date" string. + if (Number.isNaN(parsed.getTime())) return <>; + + const relativeKey = relativeDayKey(parsed); + const label = relativeKey + ? translate('screens/support', relativeKey) + : parsed.toLocaleDateString([locale, 'en-US'], { + weekday: 'short', + month: 'short', + day: 'numeric', + }); return (
-
- {new Date(date).toLocaleDateString([locale, 'en-US'], { - weekday: 'short', - month: 'short', - day: 'numeric', - })} -
+
{label}
); } -interface InputComponentProps { - replyToMessage?: SupportMessage; - setReplyToMessage: React.Dispatch>; -} - -function InputComponent({ replyToMessage, setReplyToMessage }: InputComponentProps): JSX.Element { +function InputComponent(): JSX.Element { const { translate, translateError } = useSettingsContext(); const { submitMessage } = useSupportChatContext(); + const { pathname } = useLocation(); const [inputValue, setInputValue] = useState(); const [selectedFiles, setSelectedFiles] = useState([]); const [error, setError] = useState(); + const [isDragging, setIsDragging] = useState(false); + const [previewUrls, setPreviewUrls] = useState<(string | undefined)[]>([]); + + // Object URLs for image chips — revoke on change/unmount (same pattern as compliance previews). + useEffect(() => { + const urls = selectedFiles.map((file) => (isImageFile(file) ? URL.createObjectURL(file) : undefined)); + setPreviewUrls(urls); + return () => { + urls.forEach((url) => { + if (url) URL.revokeObjectURL(url); + }); + }; + }, [selectedFiles]); function handleSend() { - if (!inputValue || error) return; + const hasText = !!(inputValue && inputValue.trim() !== ''); + const hasFiles = selectedFiles.length > 0; + // Match the SDK guard: text and/or files, never neither — and never with a validation error. + if ((!hasText && !hasFiles) || error) return; - submitMessage(inputValue, selectedFiles, replyToMessage); + // Customer already sees a failed bubble from the context on reject — report what they saw. + // reportClientError is fire-and-forget (never throws into the UI). + void submitMessage(inputValue, selectedFiles).catch((err: unknown) => { + reportClientError(err, pathname); + }); setInputValue(''); setSelectedFiles([]); - setReplyToMessage(undefined); return; } + function addFiles(files: File[]) { + if (files.length === 0) return; + + const accepted = files.filter(isAcceptedAttachment); + const hasRejected = accepted.length < files.length; + const fileTypeError = translateError('file_type'); + + if (hasRejected) { + setError(fileTypeError); + // No Error from the browser for a type reject — still a failure the user reads in the field. + reportClientError( + Object.assign(new Error('Rejected support chat attachment type'), { name: 'SupportAttachmentTypeError' }), + pathname, + ); + } else if (accepted.length > 0 && (inputValue?.length ?? 0) <= 4000) { + // Clear a prior file-type error once a valid batch is added (keep length errors). + setError((prev) => (prev === fileTypeError ? undefined : prev)); + } + + if (accepted.length > 0) { + setSelectedFiles((prevFiles) => [...prevFiles, ...accepted]); + } + } + async function handleFileChange(e: React.ChangeEvent) { const files = e.target.files as FileList; if (files && files.length > 0) { - setSelectedFiles((prevFiles) => [...prevFiles, ...files]); + addFiles(Array.from(files)); setTimeout(() => (e.target.value = ''), 100); } } @@ -301,61 +477,115 @@ function InputComponent({ replyToMessage, setReplyToMessage }: InputComponentPro setInputValue(value); } + function handlePaste(e: React.ClipboardEvent) { + const items = e.clipboardData?.items; + if (!items) return; + + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + + // Text-only paste keeps the default behaviour. + if (files.length === 0) return; + + e.preventDefault(); + addFiles(files); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files ?? []); + addFiles(files); + } + + // Same condition handleSend uses to early-return — also drives disabled + styles. + const hasText = !!(inputValue && inputValue.trim() !== ''); + const hasFiles = selectedFiles.length > 0; + const canSend = (hasText || hasFiles) && !error; + return ( -
- {replyToMessage && ( -
-
-
- {replyToMessage.file && ( - Media - )} -
-
- -

{`Reply to ${replyToMessage.author}`}

-
-

{replyToMessage.message ?? 'Media'}

-
-
- -
- )} +
{selectedFiles.length > 0 && (
- {selectedFiles.map((file, index) => ( -
- -

{blankedAddress(file.name, { displayLength: 20 })}

- removeFile(index)} - /> -
- ))} + {selectedFiles.map((file, index) => { + const previewSrc = asBlobPreviewUrl(previewUrls[index]); + return ( +
+ {previewSrc ? ( + + ) : ( + + )} +

{blankedAddress(file.name, { displayLength: 20 })}

+ removeFile(index)} + /> +
+ ); + })}
)} -
-
@@ -408,98 +647,92 @@ function InputComponent({ replyToMessage, setReplyToMessage }: InputComponentPro interface ChatBubbleProps extends SupportMessage { hasHeader: boolean; - replyToMessage?: SupportMessage; - onClick?: (e?: React.MouseEvent) => void; - onEmojiClick?: (messageId: number, emoji: string, e?: React.MouseEvent) => void; } -function ChatBubble({ - id, - message, - fileName, - file, - created, - author, - status, - reactions, - hasHeader, - replyToMessage, - onClick, - onEmojiClick, -}: ChatBubbleProps): JSX.Element { - const { translate } = useSettingsContext(); +/** + * `retryMessage` is added in DFXswiss/packages#210. Published @dfx.swiss/react 1.7.x does not + * declare it on SupportChatInterface, so we only ever read this one optional field through a + * narrow type. Drop the cast when the SDK release ships the method on the interface. + */ +type SupportChatRetry = { + retryMessage?: (messageId: number) => void; +}; +function ChatBubble({ id, message, fileName, file, created, author, status, hasHeader }: ChatBubbleProps): JSX.Element { + const { translate } = useSettingsContext(); + const supportChat = useSupportChatContext(); + const retryMessage = (supportChat as SupportChatRetry).retryMessage; const isUser = !author || author === 'Customer'; const hasFile = !!fileName; const failedToSend = status === SupportMessageStatus.FAILED; + // Offer a control only when the SDK actually provides the function — never promise a no-op. + const canRetry = failedToSend && typeof retryMessage === 'function'; - return ( -
onClick && onClick(undefined)} - className={`flex text-left ${isUser ? 'justify-end' : 'justify-start'}`} - > -
- {replyToMessage && ( -
-
-
- {replyToMessage.file && ( - Media - )} -
-

{replyToMessage.author}

-

- {replyToMessage.message ?? translate('screens/support', 'Media')} -

-
-
-
- )} - {hasHeader && !isUser && !file &&

{author}

} - {hasFile && } - {message &&

{message}

} -
-
- {reactions?.map((reaction, index) => ( -
id && onEmojiClick && onEmojiClick(id, reaction.emoji, e)} - className="flex flex-row gap-1.5 mr-1 rounded-full px-2 py-0.5 bg-white/20 text-sm cursor-pointer" - > - {reaction.emoji} - {reaction.users.length > 0 && {reaction.users.length}} -
- ))} -
-
- {formatSwissTime(created)} - {failedToSend ? ( - + // Failed own messages stay visually loud (error border) so the user notices them. + const bubbleTone = failedToSend + ? 'bg-dfxRed-100/15 border-2 border-dfxRed-100 text-dfxBlue-800 rounded-br-none' + : isUser + ? 'bg-dfxBlue-800 text-white rounded-br-none' + : 'bg-dfxGray-300 text-dfxBlue-800 rounded-bl-none'; + + const shellClass = `flex flex-col max-w-xs rounded-lg overflow-clip pb-1.5 gap-1.5 text-left ${ + hasHeader || !hasFile ? 'pt-1.5' : '' + } ${bubbleTone}${canRetry ? ' cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-dfxBlue-400' : ''}`; + + const body = ( + <> + {hasHeader && !isUser &&

{author}

} + {hasFile && } + {message &&

{message}

} + {canRetry && ( +

+ {translate('screens/support', 'Tap to retry')} +

+ )} +
+
+ {formatMessageTime(created)} + {isUser && + (failedToSend ? ( + ) : status === SupportMessageStatus.SENT ? ( - + ) : ( - - )} -
+ + ))}
+ + ); + + return ( +
+ {canRetry ? ( + + ) : ( +
+ {body} +
+ )}
); } interface ChatBubbleFileEmbedProps { messageId: number; - fileName?: string; + fileName: string; file?: DataFile; } @@ -521,17 +754,15 @@ function ChatBubbleFileEmbed({ messageId, fileName, file }: ChatBubbleFileEmbedP const [isLoadingFile, setIsLoadingFile] = useState(false); const [error, setError] = useState(); - const isLoaded = !!file; - const hasFile = !!fileName; - const fileType = (isLoaded && FileTypeMap[file?.type.split('/')[0]]) || FileType.DOCUMENT; - - if (!hasFile) return <>; + const loadedFile = file; + const isLoaded = !!loadedFile; + const fileType = (loadedFile && FileTypeMap[loadedFile.type.split('/')[0]]) || FileType.DOCUMENT; function onClick(e: React.MouseEvent) { e.stopPropagation(); - if (isLoaded) { - fileType === FileType.DOCUMENT ? window.open(file.url, '_blank') : setShowPreview(true); + if (loadedFile) { + fileType === FileType.DOCUMENT ? window.open(loadedFile.url, '_blank') : setShowPreview(true); } else { setError(undefined); setIsLoadingFile(true); @@ -551,15 +782,15 @@ function ChatBubbleFileEmbed({ messageId, fileName, file }: ChatBubbleFileEmbedP const description = isLoadingFile ? translate('screens/support', 'Downloading...') - : !isLoaded - ? translate('general/actions', 'Download') - : `${translate('screens/support', fileType)} · ${formatBytes(file.size)}`; + : !loadedFile + ? translate('general/actions', 'Download') + : `${translate('screens/support', fileType)} · ${formatBytes(loadedFile.size)}`; return ( <> - {isLoaded && fileType === FileType.IMAGE ? ( + {loadedFile && fileType === FileType.IMAGE ? ( {fileName}
)} - {showPreview && isLoaded && ( + {showPreview && loadedFile && fileType === FileType.IMAGE && (
e.stopPropagation()} @@ -592,15 +823,7 @@ function ChatBubbleFileEmbed({ messageId, fileName, file }: ChatBubbleFileEmbedP
- {fileType === FileType.IMAGE ? ( - {fileName} - ) : ( -
- -

{fileName}

-

{formatBytes(file.size)}

-
- )} + {fileName}
@@ -608,53 +831,3 @@ function ChatBubbleFileEmbed({ messageId, fileName, file }: ChatBubbleFileEmbedP ); } - -interface ChatBubbleMenuProps { - menuPosition: { top: number; left: number }; - clickedMessage: SupportMessage; - setReplyToMessage: React.Dispatch>; - onEmojiClick: (messageId: number, emoji: string) => void; -} - -function ChatBubbleMenu({ - menuPosition, - clickedMessage, - setReplyToMessage, - onEmojiClick, -}: ChatBubbleMenuProps): JSX.Element { - const { translate } = useSettingsContext(); - - return ( -
window.innerWidth / 2 ? 'translateX(-100%)' : 'translateX(0)'} - ${menuPosition.top > window.innerHeight / 2 ? 'translateY(-100%)' : 'translateY(0)'}`, - }} - className="absolute pointer-events-none" - > -
- {emojiSet.map((emoji, index) => ( - - ))} -
-
-
- -
-
-
- ); -} diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index b2769e27d..b118f5ff5 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -1058,6 +1058,16 @@ "Downloading...": "Herunterladen...", "Image": "Bild", "Document": "Dokument", + "Today": "Heute", + "Yesterday": "Gestern", + "Attach file": "Datei anhängen", + "Send message": "Nachricht senden", + "New": "Neu", + "Scroll to bottom": "Nach unten", + "Scroll to new messages": "Zu neuen Nachrichten", + "Connection interrupted. New messages cannot be received right now.": "Verbindung unterbrochen. Neue Nachrichten kommen gerade nicht an.", + "Tap to retry": "Tippen zum Wiederholen", + "Retry sending message": "Nachricht erneut senden", "Created on": "Erstellt am", "Hide completed tickets": "Abgeschlossene Tickets ausblenden", diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index a069e7672..479f3337d 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -1057,6 +1057,16 @@ "Downloading...": "Téléchargement...", "Image": "Image", "Document": "Document", + "Today": "Aujourd'hui", + "Yesterday": "Hier", + "Attach file": "Joindre un fichier", + "Send message": "Envoyer le message", + "New": "Nouveau", + "Scroll to bottom": "Aller en bas", + "Scroll to new messages": "Vers les nouveaux messages", + "Connection interrupted. New messages cannot be received right now.": "Connexion interrompue. Les nouveaux messages n'arrivent pas pour le moment.", + "Tap to retry": "Appuyer pour réessayer", + "Retry sending message": "Renvoyer le message", "Created on": "Créé le", "Hide completed tickets": "Masquer les tickets terminés", diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index 9bc205a6f..97f26f124 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -1057,6 +1057,16 @@ "Downloading...": "Scaricamento...", "Image": "Immagine", "Document": "Documento", + "Today": "Oggi", + "Yesterday": "Ieri", + "Attach file": "Allega file", + "Send message": "Invia messaggio", + "New": "Nuovo", + "Scroll to bottom": "Scorri in basso", + "Scroll to new messages": "Ai nuovi messaggi", + "Connection interrupted. New messages cannot be received right now.": "Connessione interrotta. I nuovi messaggi non arrivano al momento.", + "Tap to retry": "Tocca per riprovare", + "Retry sending message": "Invia di nuovo il messaggio", "Created on": "Creato il", "Hide completed tickets": "Nascondi i ticket completati", diff --git a/src/util/support-helpers.ts b/src/util/support-helpers.ts index d548352dc..42af2d7e7 100644 --- a/src/util/support-helpers.ts +++ b/src/util/support-helpers.ts @@ -33,3 +33,33 @@ export function visibleDepartmentsForRole(role?: UserRole): Department[] { if (!departments) return []; return departments; } + +// --- Customer chat date separators --- + +/** True when both timestamps fall on the same local calendar day (year, month, day). */ +export function isSameCalendarDay(a: Date | string | number, b: Date | string | number): boolean { + const da = new Date(a); + const db = new Date(b); + return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate(); +} + +/** + * Whether a date separator should appear above `current`. Always true for the first message + * (`previous` undefined); otherwise true when the calendar day changes. + */ +export function shouldShowDateSeparator( + current: Date | string | number, + previous: Date | string | number | undefined, +): boolean { + if (previous === undefined) return true; + return !isSameCalendarDay(current, previous); +} + +/** English i18n keys for relative day labels; null falls back to a locale date format. */ +export function relativeDayKey(date: Date | string | number, now: Date = new Date()): 'Today' | 'Yesterday' | null { + if (isSameCalendarDay(date, now)) return 'Today'; + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (isSameCalendarDay(date, yesterday)) return 'Yesterday'; + return null; +}