Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/component/src/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer';
import ReducedMotionComposer from './providers/ReducedMotion/ReducedMotionComposer';
import useTheme from './providers/Theme/useTheme';
import createDefaultSendBoxMiddleware from './SendBox/createMiddleware';
import PostMessageListener from './PostMessageListener';
import createDefaultSendBoxToolbarMiddleware from './SendBoxToolbar/createMiddleware';
import createStyleSet from './Styles/createStyleSet';
import WebChatTheme from './Styles/WebChatTheme';
Expand Down Expand Up @@ -477,6 +478,7 @@ const InternalComposer = ({
>
{children}
{onTelemetry && <UITracker />}
<PostMessageListener />
</ComposerCore>
</DecoratorComposer>
</BuiltInDecorator>
Expand Down
204 changes: 204 additions & 0 deletions packages/component/src/PostMessageListener.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* @jest-environment @happy-dom/jest-environment
*/
/* eslint-disable prefer-destructuring */

import React from 'react';
import { render } from '@testing-library/react';

import PostMessageListener from './PostMessageListener';

jest.mock('./hooks/useFocus', () => ({
__esModule: true,
default: jest.fn()
}));

const mockFocus = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
mockFocus.mockClear();
const useFocusMock = require('./hooks/useFocus').default;
useFocusMock.mockReturnValue(mockFocus);
});

describe('PostMessageListener', () => {
let mockAddEventListener: jest.SpyInstance;
let mockRemoveEventListener: jest.SpyInstance;

beforeEach(() => {
mockAddEventListener = jest.spyOn(window, 'addEventListener');
mockRemoveEventListener = jest.spyOn(window, 'removeEventListener');
mockAddEventListener.mockClear();
mockRemoveEventListener.mockClear();
});

afterEach(() => {
mockAddEventListener.mockRestore();
mockRemoveEventListener.mockRestore();
});

test('should render without crashing', () => {
const { container } = render(<PostMessageListener />);
expect(container).toBeTruthy();
});

test('should return null', () => {
const { container } = render(<PostMessageListener />);
expect(container.firstChild).toBeNull();
});

test('should add message event listener on mount', () => {
render(<PostMessageListener />);

expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function));
});

test('should remove message event listener on unmount', () => {
const { unmount } = render(<PostMessageListener />);

const messageListenerCalls = mockAddEventListener.mock.calls.filter(call => call[0] === 'message');
expect(messageListenerCalls).toHaveLength(1);

const [, handleMessage] = messageListenerCalls[0];

unmount();

expect(mockRemoveEventListener).toHaveBeenCalledWith('message', handleMessage);
});

test('should call focus when receiving WEBCHAT_FOCUS message from parent window', async () => {
render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: window.parent,
data: {
type: 'WEBCHAT_FOCUS',
target: 'sendBox'
}
};

await handleMessage(mockEvent);

expect(mockFocus).toHaveBeenCalledWith('sendBox');
});

test('should not call focus when message is not from parent window', async () => {
render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: {}, // Different source, not window.parent
data: {
type: 'WEBCHAT_FOCUS',
target: 'sendBox'
}
};

const focusCallCountBefore = mockFocus.mock.calls.length;
await handleMessage(mockEvent);
const focusCallCountAfter = mockFocus.mock.calls.length;

expect(focusCallCountAfter).toBe(focusCallCountBefore);
});

test('should not call focus when message type is not WEBCHAT_FOCUS', async () => {
render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: window.parent,
data: {
type: 'OTHER_MESSAGE',
target: 'sendBox'
}
};

const focusCallCountBefore = mockFocus.mock.calls.length;
await handleMessage(mockEvent);
const focusCallCountAfter = mockFocus.mock.calls.length;

expect(focusCallCountAfter).toBe(focusCallCountBefore);
});

test('should handle message with no data', async () => {
render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: window.parent,
data: null
};

const focusCallCountBefore = mockFocus.mock.calls.length;
await handleMessage(mockEvent);
const focusCallCountAfter = mockFocus.mock.calls.length;

expect(focusCallCountAfter).toBe(focusCallCountBefore);
});

test('should handle message with undefined data', async () => {
render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: window.parent,
data: undefined
};

const focusCallCountBefore = mockFocus.mock.calls.length;
await handleMessage(mockEvent);
const focusCallCountAfter = mockFocus.mock.calls.length;

expect(focusCallCountAfter).toBe(focusCallCountBefore);
});

test('should log error when focus throws an error', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {
// Mock implementation
});
const focusError = new Error('Focus failed');
mockFocus.mockRejectedValue(focusError);

render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: window.parent,
data: {
type: 'WEBCHAT_FOCUS',
target: 'sendBox'
}
};

await handleMessage(mockEvent);

expect(consoleErrorSpy).toHaveBeenCalledWith('WebChat focus error:', focusError);

consoleErrorSpy.mockRestore();
});

test('should handle focus without target', async () => {
render(<PostMessageListener />);

const [, handleMessage] = mockAddEventListener.mock.calls.filter(call => call[0] === 'message')[0];

const mockEvent = {
source: window.parent,
data: {
type: 'WEBCHAT_FOCUS'
}
};

await handleMessage(mockEvent);

expect(mockFocus).toHaveBeenCalledWith(undefined);
});
});
35 changes: 35 additions & 0 deletions packages/component/src/PostMessageListener.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect } from 'react';

import useFocus from './hooks/useFocus';

const PostMessageListener = () => {
const focus = useFocus();

useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (event.source !== window.parent) {
return;
}

const { type, target } = event.data ?? {};

if (type === 'WEBCHAT_FOCUS') {
try {
await focus(target);
} catch (error) {
console.error('WebChat focus error:', error);
}
}
};

window.addEventListener('message', handleMessage);

return () => {
window.removeEventListener('message', handleMessage);
};
}, [focus]);

return null;
};

export default PostMessageListener;
107 changes: 107 additions & 0 deletions samples/04.api/o.iframe-rpc-focus/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Sample - Cross-iframe RPC focus control

This sample demonstrates how to control WebChat focus from a parent application when WebChat is embedded in an iframe.

## Background

When WebChat is running inside an iframe, the parent application cannot directly access the DOM elements inside the iframe due to security restrictions. This sample shows how to use `postMessage` communication to send focus commands from the parent window to WebChat.

## What this sample does

- Demonstrates cross-iframe communication using `postMessage`
- Shows how to focus different parts of WebChat from an external application
- Provides a complete working example of parent-iframe RPC communication

## How to run this sample

### Option 1: Using npx serve (recommended)

1. Navigate to this sample's folder:
```bash
cd samples/04.api/o.iframe-rpc-focus/
```
2. Start a local server:
```bash
npx serve
```
3. Open your browser to the URL shown (typically http://localhost:3000)

### Option 2: Direct file opening

1. Navigate to this sample's folder: `samples/04.api/o.iframe-rpc-focus/`
2. Open `index.html` in your web browser
3. Note: Some features may not work due to CORS restrictions without a server

## How it works

### 1. PostMessage Listener

WebChat includes a `PostMessageListener` component that listens for messages from the parent window:

```typescript
// Inside WebChat iframe
window.addEventListener('message', event => {
if (event.data.type === 'WEBCHAT_FOCUS') {
await focus(event.data.target);
}
});
```

### 2. Parent Application RPC

The parent application sends focus commands via postMessage:

```javascript
// From parent application
function focusWebChatSendBox() {
const iframe = document.getElementById('webchat-iframe');
iframe.contentWindow.postMessage(
{
type: 'WEBCHAT_FOCUS',
target: 'sendBox'
},
'*'
);
}
```

### 3. Available Focus Targets

- `'sendBox'` - Focus the send box input with keyboard
- `'sendBoxWithoutKeyboard'` - Focus the send box without showing virtual keyboard
- `'main'` - Focus the main transcript area

## Use cases

This pattern is useful when:

- WebChat is embedded in an iframe within a larger application
- You need to programmatically focus WebChat from outside the iframe
- You want to integrate WebChat focus control with external UI elements
- You're building a dashboard or portal that includes WebChat

## Implementation details

### CDN Version (Current Sample)

This sample uses the CDN version of WebChat with DOM-based focus fallback:

- Direct DOM manipulation to find and focus elements
- Uses common selectors to locate send box and transcript
- Works with any WebChat version from CDN

### Development Version (Recommended for Production)

For the full implementation with WebChat's `useFocus()` hook:

- Use the PostMessageListener component in packages/component/src/PostMessageListener.tsx
- Provides proper focus management, accessibility support, and screen reader compatibility
- Requires building WebChat from source or using a custom build

## Security considerations

The sample includes basic security measures:

- Only accepts messages from the parent window
- Validates message structure before processing
- Handles errors gracefully
Loading
Loading