diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94c047f9ea..1f38c53886 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
* Added labels and tooltips to dropdowns in the FDC3 for Web reference implementation demo. ([#193](https://github.com/finos/FDC3/pull/1932))
* Added `fdc3.close()` API call allowing an app to request that its own window or frame be closed, with `closeRequest`/`closeResponse` DACP messages and `CloseError` enumeration ([#1918](https://github.com/finos/FDC3/pull/1918))
+* Added FDC3 Workbench support for `DesktopAgent` and `PrivateChannel` events, including automatic user channel updates, private channel event reporting, context-type-aware streaming, FDC3 2.0-2.1 compatibility, and copyable code examples. ([#1674](https://github.com/finos/FDC3/issues/1674))
* Added an optional `newInstance` parameter to the `raiseIntent` and `raiseIntentForContext` API calls, allowing an app to explicitly request that a **new instance** of the target application be launched (`newInstance: true`) or that an **existing instance** be used and a new one never launched (`newInstance: false`, which rejects with `ResolveError.TargetInstanceUnavailable` if no running instance is available). Omitting the parameter (or passing `null` or `undefined`) preserves the Desktop Agent's default resolution behavior. The parameter is carried on the `raiseIntentRequest` / `raiseIntentForContextRequest` DACP payloads, implemented in the agent proxy and reference web implementation, and covered by unit and conformance tests (`RaiseIntentNewInstanceForced`, `RaiseIntentExistingInstanceRequired`, `RaiseIntentFailExistingInstanceRequired`). ([#1940](https://github.com/finos/FDC3/issues/1940))
diff --git a/toolbox/fdc3-workbench/package.json b/toolbox/fdc3-workbench/package.json
index befd8f7928..f7e460036e 100644
--- a/toolbox/fdc3-workbench/package.json
+++ b/toolbox/fdc3-workbench/package.json
@@ -13,6 +13,8 @@
"start": "vite",
"preview": "vite preview",
"build": "tsc && vite build",
+ "pretest": "npm run build --workspace=@finos/fdc3",
+ "test": "vitest run",
"lint": " eslint src/",
"lint:fix": "eslint --fix src/**/*.{ts,tsx} && prettier --write src/**/*.{ts,tsx}"
},
diff --git a/toolbox/fdc3-workbench/src/components/ChannelField.tsx b/toolbox/fdc3-workbench/src/components/ChannelField.tsx
index 1a17f34a11..c022f217fd 100644
--- a/toolbox/fdc3-workbench/src/components/ChannelField.tsx
+++ b/toolbox/fdc3-workbench/src/components/ChannelField.tsx
@@ -210,7 +210,7 @@ export const ChannelField = observer(
const handleRemoveOrDisconnect = (channel: Fdc3ChannelRecord) => {
if (isPrivateChannel) {
- privateChannelStore.disconnect(channel.channel as PrivateChannel);
+ void privateChannelStore.disconnect(channel.channel as PrivateChannel);
} else {
appChannelStore.remove(channel.channel);
}
@@ -341,6 +341,37 @@ export const ChannelField = observer(
+ {isPrivateChannel && (
+
+
+
+ Private channel events
+
+
+ Received events are shown in the Workbench listeners panel.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ User channel events
+
+
+
+
+ {channelStore.isUserChannelChangedListenerActive
+ ? 'Listening for userChannelChanged events'
+ : 'Event listener unavailable (requires FDC3 2.2+)'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Join user channels
diff --git a/toolbox/fdc3-workbench/src/components/IntentResolutionField.tsx b/toolbox/fdc3-workbench/src/components/IntentResolutionField.tsx
index 5e0f33547e..29cbdd848b 100644
--- a/toolbox/fdc3-workbench/src/components/IntentResolutionField.tsx
+++ b/toolbox/fdc3-workbench/src/components/IntentResolutionField.tsx
@@ -61,6 +61,7 @@ export const IntentResolutionField = observer(
setPrivateChannel(true);
setChannelsList([{ id: channel.id, channel: channel }]);
privateChannelStore.addChannelListener(channel as PrivateChannel, 'all');
+ await privateChannelStore.listenForEvents(channel as PrivateChannel);
}
setResolutionResult(null);
} else if (result) {
diff --git a/toolbox/fdc3-workbench/src/components/Workbench/PrivateChannelListeners.tsx b/toolbox/fdc3-workbench/src/components/Workbench/PrivateChannelListeners.tsx
index cf97479cf4..28c76ba8fa 100644
--- a/toolbox/fdc3-workbench/src/components/Workbench/PrivateChannelListeners.tsx
+++ b/toolbox/fdc3-workbench/src/components/Workbench/PrivateChannelListeners.tsx
@@ -22,6 +22,7 @@ const classes = {
export const PrivateChannelListeners = observer(() => {
const contextListeners: AccordionListItem[] = [];
+ const channelEvents: AccordionListItem[] = [];
privateChannelStore.channelListeners.forEach(({ id, channelId, type, lastReceivedContext, metaData }) => {
const receivedContextListenerValue = lastReceivedContext ? JSON.stringify(lastReceivedContext, undefined, 4) : '';
@@ -51,17 +52,30 @@ export const PrivateChannelListeners = observer(() => {
contextListeners.push({ id, textPrimary: `Channel Id: ${channelId}: ${type}`, afterEachElement: contextField });
});
+ privateChannelStore.privateChannelEvents.forEach(({ id, channelId, type, contextType }) => {
+ const eventDetails = type === 'disconnect' ? type : `${type}: ${contextType ?? 'all context types'}`;
+ channelEvents.push({ id, textPrimary: `Channel Id: ${channelId}: ${eventDetails}` });
+ });
+
const handleDeleteListener = (id: string) => {
privateChannelStore.removeContextListener(id);
};
return (
-
+ <>
+
+
+ >
);
});
diff --git a/toolbox/fdc3-workbench/src/fixtures/codeExamples.ts b/toolbox/fdc3-workbench/src/fixtures/codeExamples.ts
index d8317dca5c..05bc1f9e58 100644
--- a/toolbox/fdc3-workbench/src/fixtures/codeExamples.ts
+++ b/toolbox/fdc3-workbench/src/fixtures/codeExamples.ts
@@ -17,6 +17,14 @@ let current = await fdc3.getCurrentChannel();
//leave the current channel\nawait fdc3.leaveCurrentChannel();
//the fdc3Listener will now cease receiving context`,
+ userChannelChangedEvent: `// FDC3 2.2+
+const listener = await fdc3.addEventListener('userChannelChanged', event => {
+ console.log('Current user channel:', event.details.currentChannelId);
+});
+
+// Stop listening when the event is no longer needed
+await listener.unsubscribe();`,
+
broadcast: `const instrument = {
type: 'fdc3.instrument',
id: {
@@ -78,6 +86,19 @@ const contactListener = appChannel.addContextListener('fdc3.contact', contact =>
//add context handling code here
});`,
+ privateChannelEvents: `// FDC3 2.2+
+const added = await privateChannel.addEventListener('addContextListener', event => {
+ console.log('Listener added for:', event.details.contextType ?? 'all');
+});
+
+const removed = await privateChannel.addEventListener('unsubscribe', event => {
+ console.log('Listener removed for:', event.details.contextType ?? 'all');
+});
+
+const disconnected = await privateChannel.addEventListener('disconnect', () => {
+ console.log('The other participant disconnected');
+});`,
+
intentListener: `const listener = fdc3.addIntentListener('StartChat', context => {
// start chat has been requested by another application
});`,
@@ -100,9 +121,20 @@ const listener = fdc3.addIntentListener('StartChat', context => {
return channel;
});`,
- intentListenerWithPrivateChannel: `const listener = fdc3.addIntentListener('StartChat', context => {
+ intentListenerWithPrivateChannel: `const listener = fdc3.addIntentListener('StartChat', async context => {
// start chat has been requested by another application
const channel = await fdc3.createPrivateChannel();
+
+ await channel.addEventListener('addContextListener', event => {
+ console.log('Listener added for:', event.details.contextType ?? 'all');
+ });
+ await channel.addEventListener('unsubscribe', event => {
+ console.log('Listener removed for:', event.details.contextType ?? 'all');
+ });
+ await channel.addEventListener('disconnect', () => {
+ console.log('The other participant disconnected');
+ });
+
return channel;
});`,
diff --git a/toolbox/fdc3-workbench/src/fixtures/logMessages.ts b/toolbox/fdc3-workbench/src/fixtures/logMessages.ts
index 5cf2ee85b0..b9df8b6718 100644
--- a/toolbox/fdc3-workbench/src/fixtures/logMessages.ts
+++ b/toolbox/fdc3-workbench/src/fixtures/logMessages.ts
@@ -19,6 +19,10 @@ export const getLogMessage = (name: logMessagesName, type: logMessagesType, valu
success: `Retrieved current channel [${value}]`,
error: `Failed to retrieve current channel`,
},
+ userChannelChanged: {
+ info: `User channel changed to [${value}]`,
+ error: `Failed to listen for user channel changes`,
+ },
joinUserChannel: {
success: `Joined the [${value}] channel`,
error: `Failed to join the [${value}] channel`,
@@ -112,6 +116,9 @@ export const getLogMessage = (name: logMessagesName, type: logMessagesType, valu
success: `${value}`,
error: `${value}`,
},
+ privateChannelEventListener: {
+ error: `Failed to listen for events on private channel [${value}]`,
+ },
};
return logMessages[name][type] ?? (value != '' ? `${value}` : `Undefined log message ${name}.${type}`);
diff --git a/toolbox/fdc3-workbench/src/store/ChannelStore.test.ts b/toolbox/fdc3-workbench/src/store/ChannelStore.test.ts
new file mode 100644
index 0000000000..0129835ea5
--- /dev/null
+++ b/toolbox/fdc3-workbench/src/store/ChannelStore.test.ts
@@ -0,0 +1,82 @@
+/**
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright FINOS FDC3 contributors - see NOTICE file
+ */
+import { Channel, DesktopAgent, EventHandler, Listener } from '@finos/fdc3';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import systemLogStore from './SystemLogStore.js';
+import { ChannelStore } from './ChannelStore.js';
+
+const createListener = (): Listener => ({
+ unsubscribe: vi.fn(),
+});
+
+describe('ChannelStore event support', () => {
+ afterEach(() => {
+ systemLogStore.logList = [];
+ });
+
+ it('updates the current user channel when a 2.2 event is received', async () => {
+ const channels = [{ id: 'red' }, { id: 'green' }] as Channel[];
+ let eventHandler: EventHandler | undefined;
+ const agent = {
+ getUserChannels: vi.fn().mockResolvedValue(channels),
+ getCurrentChannel: vi.fn().mockResolvedValue(channels[0]),
+ getInfo: vi.fn().mockResolvedValue({ fdc3Version: '2.2' }),
+ addEventListener: vi.fn(async (_type: string, handler: EventHandler) => {
+ eventHandler = handler;
+ return createListener();
+ }),
+ } as unknown as DesktopAgent;
+ const store = new ChannelStore(async () => agent, false);
+
+ await store.getUserChannels();
+ await store.listenForUserChannelChanges();
+ eventHandler?.({
+ type: 'userChannelChanged',
+ details: { currentChannelId: 'green' },
+ });
+
+ expect(agent.addEventListener).toHaveBeenCalledWith('userChannelChanged', expect.any(Function));
+ expect(store.isUserChannelChangedListenerActive).toBe(true);
+ expect(store.currentUserChannel).toEqual(channels[1]);
+ expect(systemLogStore.logList.at(-1)?.message).toBe('User channel changed to [green]');
+ });
+
+ it('clears the current user channel when the event reports no channel', async () => {
+ const channel = { id: 'red' } as Channel;
+ let eventHandler: EventHandler | undefined;
+ const agent = {
+ getUserChannels: vi.fn().mockResolvedValue([channel]),
+ getCurrentChannel: vi.fn().mockResolvedValue(channel),
+ getInfo: vi.fn().mockResolvedValue({ fdc3Version: '3.0.0' }),
+ addEventListener: vi.fn(async (_type: string, handler: EventHandler) => {
+ eventHandler = handler;
+ return createListener();
+ }),
+ } as unknown as DesktopAgent;
+ const store = new ChannelStore(async () => agent, false);
+
+ await store.getUserChannels();
+ await store.listenForUserChannelChanges();
+ eventHandler?.({
+ type: 'userChannelChanged',
+ details: { currentChannelId: null },
+ });
+
+ expect(store.currentUserChannel).toBeNull();
+ });
+
+ it('does not register the new event API for FDC3 2.1', async () => {
+ const agent = {
+ getInfo: vi.fn().mockResolvedValue({ fdc3Version: '2.1' }),
+ addEventListener: vi.fn(),
+ } as unknown as DesktopAgent;
+ const store = new ChannelStore(async () => agent, false);
+
+ await store.listenForUserChannelChanges();
+
+ expect(agent.addEventListener).not.toHaveBeenCalled();
+ expect(store.isUserChannelChangedListenerActive).toBe(false);
+ });
+});
diff --git a/toolbox/fdc3-workbench/src/store/ChannelStore.ts b/toolbox/fdc3-workbench/src/store/ChannelStore.ts
index 8e3104f5d3..2d47064d5f 100644
--- a/toolbox/fdc3-workbench/src/store/ChannelStore.ts
+++ b/toolbox/fdc3-workbench/src/store/ChannelStore.ts
@@ -2,31 +2,49 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright FINOS FDC3 contributors - see NOTICE file
*/
-import { makeObservable, observable, action, runInAction } from 'mobx';
+import { action, makeObservable, observable, runInAction } from 'mobx';
import systemLogStore from './SystemLogStore.js';
-import { Channel } from '@finos/fdc3';
+import { Channel, DesktopAgent, FDC3ChannelChangedEvent, Listener, versionIsAtLeast } from '@finos/fdc3';
import { getWorkbenchAgent } from '../utility/Fdc3Api.js';
-class ChannelStore {
+type WorkbenchAgentProvider = () => Promise;
+
+export class ChannelStore {
userChannels: Channel[] = [];
currentUserChannel: Channel | null = null;
- constructor() {
+ isUserChannelChangedListenerActive = false;
+
+ private userChannelChangedListener: Listener | null = null;
+
+ constructor(
+ private readonly getAgent: WorkbenchAgentProvider = getWorkbenchAgent,
+ autoInitialize = typeof window !== 'undefined'
+ ) {
makeObservable(this, {
userChannels: observable,
currentUserChannel: observable,
+ isUserChannelChangedListenerActive: observable,
getUserChannels: action,
joinUserChannel: action,
leaveUserChannel: action,
getCurrentUserChannel: action,
+ listenForUserChannelChanges: action,
});
- this.getUserChannels();
+ if (autoInitialize) {
+ void this.initialize();
+ }
+ }
+
+ private async initialize() {
+ await this.getUserChannels();
+ await this.listenForUserChannelChanges();
}
async getCurrentUserChannel() {
- const agent = await getWorkbenchAgent();
+ const agent = await this.getAgent();
try {
const userChannel = await agent.getCurrentChannel();
runInAction(() => {
@@ -51,7 +69,7 @@ class ChannelStore {
}
async getUserChannels() {
- const agent = await getWorkbenchAgent();
+ const agent = await this.getAgent();
//defer retrieving channels until fdc3 API is ready
try {
const userChannels: Channel[] = await agent.getUserChannels();
@@ -77,7 +95,7 @@ class ChannelStore {
}
async joinUserChannel(channelId: string) {
- const agent = await getWorkbenchAgent();
+ const agent = await this.getAgent();
try {
await agent.joinUserChannel(channelId);
@@ -105,7 +123,7 @@ class ChannelStore {
}
async leaveUserChannel() {
- const agent = await getWorkbenchAgent();
+ const agent = await this.getAgent();
try {
//check that we're on a channel
let currentUserChannel = await agent.getCurrentChannel();
@@ -144,6 +162,54 @@ class ChannelStore {
});
}
}
+
+ async listenForUserChannelChanges() {
+ if (this.userChannelChangedListener) {
+ return;
+ }
+
+ const agent = await this.getAgent();
+
+ try {
+ const implementationMetadata = await agent.getInfo();
+ if (versionIsAtLeast(implementationMetadata, '2.2') !== true) {
+ return;
+ }
+
+ const listener = await agent.addEventListener('userChannelChanged', event => {
+ const channelId = (event as FDC3ChannelChangedEvent).details.currentChannelId;
+ const currentUserChannel =
+ channelId === null ? null : (this.userChannels.find(channel => channel.id === channelId) ?? null);
+
+ runInAction(() => {
+ this.currentUserChannel = currentUserChannel;
+ systemLogStore.addLog({
+ name: 'userChannelChanged',
+ type: 'info',
+ value: channelId ?? 'none',
+ variant: 'code',
+ body: JSON.stringify(event, null, 4),
+ });
+ });
+
+ if (channelId !== null && currentUserChannel === null) {
+ void this.getCurrentUserChannel();
+ }
+ });
+
+ runInAction(() => {
+ this.userChannelChangedListener = listener;
+ this.isUserChannelChangedListenerActive = true;
+ });
+ } catch (e) {
+ systemLogStore.addLog({
+ name: 'userChannelChanged',
+ type: 'error',
+ variant: 'code',
+ body: JSON.stringify(e, null, 4),
+ });
+ }
+ }
}
const channelStore = new ChannelStore();
diff --git a/toolbox/fdc3-workbench/src/store/IntentStore.ts b/toolbox/fdc3-workbench/src/store/IntentStore.ts
index dd6c7fe624..d3fa2f0548 100644
--- a/toolbox/fdc3-workbench/src/store/IntentStore.ts
+++ b/toolbox/fdc3-workbench/src/store/IntentStore.ts
@@ -64,10 +64,7 @@ class IntentStore {
if (isPrivate && !channelName) {
channel = await privateChannelStore.createPrivateChannel();
privateChannelStore.addChannelListener(channel, 'all');
-
- privateChannelStore.onDisconnect(channel);
- privateChannelStore.onUnsubscribe(channel);
- privateChannelStore.onAddContextListener(channel, channelContexts, channelContextDelay);
+ await privateChannelStore.listenForEvents(channel, channelContexts, channelContextDelay);
}
if (!isPrivate && channel) {
diff --git a/toolbox/fdc3-workbench/src/store/PrivateChannelStore.test.ts b/toolbox/fdc3-workbench/src/store/PrivateChannelStore.test.ts
new file mode 100644
index 0000000000..06b5d46d9b
--- /dev/null
+++ b/toolbox/fdc3-workbench/src/store/PrivateChannelStore.test.ts
@@ -0,0 +1,172 @@
+/**
+ * SPDX-License-Identifier: Apache-2.0
+ * Copyright FINOS FDC3 contributors - see NOTICE file
+ */
+import { DesktopAgent, EventHandler, Listener, PrivateChannel } from '@finos/fdc3';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import systemLogStore from './SystemLogStore.js';
+import { PrivateChannelStore } from './PrivateChannelStore.js';
+
+const createListener = (): Listener => ({
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
+});
+
+interface PrivateChannelHarness {
+ channel: PrivateChannel;
+ eventHandlers: Map;
+ eventListeners: Listener[];
+ legacyHandlers: {
+ addContextListener?: (contextType?: string) => void;
+ unsubscribe?: (contextType?: string) => void;
+ disconnect?: () => void;
+ };
+ addEventListener: ReturnType;
+}
+
+const createPrivateChannelHarness = (): PrivateChannelHarness => {
+ const eventHandlers = new Map();
+ const eventListeners: Listener[] = [];
+ const legacyHandlers: PrivateChannelHarness['legacyHandlers'] = {};
+ const addEventListener = vi.fn(async (type: string, handler: EventHandler) => {
+ eventHandlers.set(type, handler);
+ const listener = createListener();
+ eventListeners.push(listener);
+ return listener;
+ });
+ const channel = {
+ id: 'private-1',
+ type: 'private',
+ addEventListener,
+ onAddContextListener: vi.fn((handler: (contextType?: string) => void) => {
+ legacyHandlers.addContextListener = handler;
+ return createListener();
+ }),
+ onUnsubscribe: vi.fn((handler: (contextType?: string) => void) => {
+ legacyHandlers.unsubscribe = handler;
+ return createListener();
+ }),
+ onDisconnect: vi.fn((handler: () => void) => {
+ legacyHandlers.disconnect = handler;
+ return createListener();
+ }),
+ disconnect: vi.fn().mockResolvedValue(undefined),
+ } as unknown as PrivateChannel;
+
+ return { channel, eventHandlers, eventListeners, legacyHandlers, addEventListener };
+};
+
+const createAgentProvider = (fdc3Version: string) => async () =>
+ ({
+ getInfo: vi.fn().mockResolvedValue({ fdc3Version }),
+ }) as unknown as DesktopAgent;
+
+describe('PrivateChannelStore event support', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ systemLogStore.logList = [];
+ });
+
+ it('uses the 2.2 event API and reports the received context type', async () => {
+ const harness = createPrivateChannelHarness();
+ const store = new PrivateChannelStore(createAgentProvider('2.2'));
+
+ await store.listenForEvents(harness.channel);
+ harness.eventHandlers.get('addContextListener')?.({
+ type: 'addContextListener',
+ details: { contextType: 'fdc3.instrument' },
+ });
+ harness.eventHandlers.get('unsubscribe')?.({
+ type: 'unsubscribe',
+ details: { contextType: 'fdc3.instrument' },
+ });
+
+ expect(harness.addEventListener.mock.calls.map(call => call[0])).toEqual([
+ 'addContextListener',
+ 'unsubscribe',
+ 'disconnect',
+ ]);
+ expect(store.privateChannelEvents.map(event => [event.type, event.contextType])).toEqual([
+ ['addContextListener', 'fdc3.instrument'],
+ ['unsubscribe', 'fdc3.instrument'],
+ ]);
+ });
+
+ it('broadcasts only contexts matching the listener type', async () => {
+ vi.useFakeTimers();
+ const harness = createPrivateChannelHarness();
+ const store = new PrivateChannelStore(createAgentProvider('3.0.0'));
+ const broadcast = vi.spyOn(store, 'broadcast').mockResolvedValue();
+ const instrument = { type: 'fdc3.instrument', id: { ticker: 'AAPL' } };
+ const contact = { type: 'fdc3.contact', id: { email: 'person@example.com' } };
+
+ await store.listenForEvents(harness.channel, { instrument, contact });
+ harness.eventHandlers.get('addContextListener')?.({
+ type: 'addContextListener',
+ details: { contextType: 'fdc3.instrument' },
+ });
+ await vi.runAllTimersAsync();
+
+ expect(broadcast).toHaveBeenCalledOnce();
+ expect(broadcast).toHaveBeenCalledWith(harness.channel, instrument);
+ });
+
+ it('broadcasts every configured context when an all-listener event omits contextType', async () => {
+ vi.useFakeTimers();
+ const harness = createPrivateChannelHarness();
+ const store = new PrivateChannelStore(createAgentProvider('2.2'));
+ const broadcast = vi.spyOn(store, 'broadcast').mockResolvedValue();
+ const instrument = { type: 'fdc3.instrument', id: { ticker: 'AAPL' } };
+ const contact = { type: 'fdc3.contact', id: { email: 'person@example.com' } };
+
+ await store.listenForEvents(harness.channel, { instrument, contact });
+ harness.eventHandlers.get('addContextListener')?.({
+ type: 'addContextListener',
+ details: {},
+ });
+ await vi.runAllTimersAsync();
+
+ expect(broadcast).toHaveBeenCalledTimes(2);
+ expect(broadcast).toHaveBeenCalledWith(harness.channel, instrument);
+ expect(broadcast).toHaveBeenCalledWith(harness.channel, contact);
+ expect(store.privateChannelEvents.at(-1)?.contextType).toBeNull();
+ });
+
+ it('uses deprecated private channel callbacks for FDC3 2.0 and 2.1', async () => {
+ const harness = createPrivateChannelHarness();
+ const store = new PrivateChannelStore(createAgentProvider('2.1'));
+
+ await store.listenForEvents(harness.channel);
+ harness.legacyHandlers.addContextListener?.('fdc3.contact');
+ harness.legacyHandlers.unsubscribe?.('fdc3.contact');
+
+ expect(harness.addEventListener).not.toHaveBeenCalled();
+ expect(store.privateChannelEvents.map(event => [event.type, event.contextType])).toEqual([
+ ['addContextListener', 'fdc3.contact'],
+ ['unsubscribe', 'fdc3.contact'],
+ ]);
+ });
+
+ it('registers event listeners only once per private channel', async () => {
+ const harness = createPrivateChannelHarness();
+ const store = new PrivateChannelStore(createAgentProvider('2.2'));
+
+ await store.listenForEvents(harness.channel);
+ await store.listenForEvents(harness.channel);
+
+ expect(harness.addEventListener).toHaveBeenCalledTimes(3);
+ });
+
+ it('unsubscribes every event listener when disconnecting', async () => {
+ const harness = createPrivateChannelHarness();
+ const store = new PrivateChannelStore(createAgentProvider('2.2'));
+
+ await store.listenForEvents(harness.channel);
+ await store.disconnect(harness.channel);
+
+ expect(harness.eventListeners).toHaveLength(3);
+ harness.eventListeners.forEach(listener => {
+ expect(listener.unsubscribe).toHaveBeenCalledOnce();
+ });
+ expect(harness.channel.disconnect).toHaveBeenCalledOnce();
+ });
+});
diff --git a/toolbox/fdc3-workbench/src/store/PrivateChannelStore.tsx b/toolbox/fdc3-workbench/src/store/PrivateChannelStore.tsx
index 85971420b0..4a62c51039 100644
--- a/toolbox/fdc3-workbench/src/store/PrivateChannelStore.tsx
+++ b/toolbox/fdc3-workbench/src/store/PrivateChannelStore.tsx
@@ -2,44 +2,61 @@
* SPDX-License-Identifier: Apache-2.0
* Copyright FINOS FDC3 contributors - see NOTICE file
*/
-import { makeObservable, observable, action, runInAction, toJS } from 'mobx';
-import { ContextType, Fdc3Listener, PrivateChannel } from '../utility/Fdc3Api.js';
-import systemLogStore from './SystemLogStore.js';
+import {
+ ContextMetadata,
+ DesktopAgent,
+ Listener,
+ PrivateChannelAddContextListenerEvent,
+ PrivateChannelUnsubscribeEvent,
+ versionIsAtLeast,
+} from '@finos/fdc3-standard';
+import { action, makeObservable, observable, runInAction, toJS } from 'mobx';
import { nanoid } from 'nanoid';
-import { getWorkbenchAgent } from '../utility/Fdc3Api.js';
-import { ContextMetadata } from '@finos/fdc3-standard';
-// interface ListenerOptionType {
-// title: string;
-// value: string;
-// type: string | undefined;
-// }
-
-class PrivateChannelStore {
+import { ContextType, Fdc3Listener, getWorkbenchAgent, PrivateChannel } from '../utility/Fdc3Api.js';
+import systemLogStore from './SystemLogStore.js';
+
+interface LegacyPrivateChannel extends PrivateChannel {
+ onAddContextListener(handler: (contextType?: string) => void): Listener;
+ onUnsubscribe(handler: (contextType?: string) => void): Listener;
+ onDisconnect(handler: () => void): Listener;
+}
+
+export interface PrivateChannelEventRecord {
+ id: string;
+ channelId: string;
+ type: 'addContextListener' | 'unsubscribe' | 'disconnect';
+ contextType: string | null;
+}
+
+type WorkbenchAgentProvider = () => Promise;
+
+export class PrivateChannelStore {
privateChannelsList: PrivateChannel[] = [];
currentPrivateChannel: PrivateChannel | null = null;
channelListeners: Fdc3Listener[] = [];
- constructor() {
+ privateChannelEvents: PrivateChannelEventRecord[] = [];
+
+ private channelEventListeners = new Map();
+
+ constructor(private readonly getAgent: WorkbenchAgentProvider = getWorkbenchAgent) {
makeObservable(this, {
privateChannelsList: observable,
currentPrivateChannel: observable,
channelListeners: observable,
+ privateChannelEvents: observable,
createPrivateChannel: action,
broadcast: action,
- onAddContextListener: action,
- onDisconnect: action,
- onUnsubscribe: action,
+ listenForEvents: action,
disconnect: action,
});
}
async createPrivateChannel() {
try {
- const currentPrivateChannel: PrivateChannel = await getWorkbenchAgent().then(agent =>
- agent.createPrivateChannel()
- );
+ const currentPrivateChannel = await this.getAgent().then(agent => agent.createPrivateChannel());
const isSuccess = currentPrivateChannel !== null;
if (isSuccess) {
this.privateChannelsList.push(currentPrivateChannel);
@@ -67,7 +84,7 @@ class PrivateChannelStore {
}
isContextListenerExists(channelId: string, type: string | undefined) {
- return !!this.channelListeners?.find(listener => listener.type === type && listener.channelId === channelId);
+ return !!this.channelListeners.find(listener => listener.type === type && listener.channelId === channelId);
}
isPrivateChannelExists(channelId: string) {
@@ -121,10 +138,10 @@ class PrivateChannelStore {
const foundListener = this.channelListeners.find(
currentListener => currentListener.type === newListener && currentListener.channelId === channelId
);
- if (!foundListener && currentChannel && newListener !== undefined) {
+ if (!foundListener && newListener !== undefined) {
const listenerId = nanoid();
const contactListener = await currentChannel.addContextListener(
- newListener?.toLowerCase() === 'all' ? null : newListener,
+ newListener.toLowerCase() === 'all' ? null : newListener,
(context, metaData?: ContextMetadata) => {
const currentListener = this.channelListeners.find(
listener => listener.type === newListener && listener.channelId === channelId
@@ -162,7 +179,7 @@ class PrivateChannelStore {
}
removeContextListener(id: string) {
- const listenerIndex = this.channelListeners?.findIndex(listener => listener.id === id);
+ const listenerIndex = this.channelListeners.findIndex(listener => listener.id === id);
const listener = this.channelListeners[listenerIndex];
if (listenerIndex !== -1) {
try {
@@ -189,83 +206,154 @@ class PrivateChannelStore {
}
}
- onAddContextListener(
+ private recordEvent(channel: PrivateChannel, type: PrivateChannelEventRecord['type'], contextType: string | null) {
+ this.privateChannelEvents.push({
+ id: nanoid(),
+ channelId: channel.id,
+ type,
+ contextType,
+ });
+ }
+
+ private onAddContextListenerEvent(
channel: PrivateChannel,
+ contextType: string | null,
channelContexts?: Record,
channelContextDelay?: Record
) {
- channel.addEventListener('addContextListener', () => {
- try {
+ try {
+ const displayedContextType = contextType ?? 'all';
+ runInAction(() => {
+ this.recordEvent(channel, 'addContextListener', contextType);
systemLogStore.addLog({
name: 'pcAddContextListener',
type: 'success',
- value: `A context listener for '[all]' has been added on channel [${channel.id}]`,
+ value: `A context listener for '[${displayedContextType}]' has been added on channel [${channel.id}]`,
});
+ });
- if (channelContexts && Object.keys(channelContexts).length !== 0) {
- Object.keys(channelContexts).forEach(key => {
- const broadcast = setTimeout(async () => {
- this.broadcast(channel, channelContexts[key]);
- clearTimeout(broadcast);
- }, channelContextDelay?.[key] ?? 0);
- });
- }
- } catch {
- systemLogStore.addLog({
- name: 'pcAddContextListener',
- type: 'error',
- value: `Failed to add a context listener for '[all]' on channel [${channel.id}]`,
+ Object.entries(channelContexts ?? {})
+ .filter(([, context]) => contextType === null || context.type === contextType)
+ .forEach(([key, context]) => {
+ const broadcast = setTimeout(() => {
+ void this.broadcast(channel, context);
+ clearTimeout(broadcast);
+ }, channelContextDelay?.[key] ?? 0);
});
- }
- });
+ } catch {
+ systemLogStore.addLog({
+ name: 'pcAddContextListener',
+ type: 'error',
+ value: `Failed to handle an added '[${contextType ?? 'all'}]' context listener on channel [${channel.id}]`,
+ });
+ }
}
- onUnsubscribe(channel: PrivateChannel) {
- channel.addEventListener('unsubscribe', () => {
- try {
+ private onUnsubscribeEvent(channel: PrivateChannel, contextType: string | null) {
+ try {
+ runInAction(() => {
+ this.recordEvent(channel, 'unsubscribe', contextType);
systemLogStore.addLog({
name: 'pcOnUnsubscribe',
type: 'success',
- value: `Sucessfully unsubscribed from listener '[all]' for channel [${channel.id}]`,
- });
- } catch {
- systemLogStore.addLog({
- name: 'pcOnUnsubscribe',
- type: 'error',
- value: `Could not unsubscribed listener '[all]' from channel [${channel.id}]`,
+ value: `Unsubscribed listener '[${contextType ?? 'all'}]' for channel [${channel.id}]`,
});
- }
- });
+ });
+ } catch {
+ systemLogStore.addLog({
+ name: 'pcOnUnsubscribe',
+ type: 'error',
+ value: `Could not process listener '[${contextType ?? 'all'}]' being unsubscribed from channel [${channel.id}]`,
+ });
+ }
}
- onDisconnect(channel: PrivateChannel) {
- channel.addEventListener('disconnect', () => {
- try {
- this.channelListeners.forEach(listener => {
- this.removeContextListener(listener.id);
- });
+ private onDisconnectEvent(channel: PrivateChannel) {
+ try {
+ this.channelListeners
+ .filter(listener => listener.channelId === channel.id)
+ .forEach(listener => this.removeContextListener(listener.id));
+ void this.unsubscribeEventListeners(channel.id);
+
+ runInAction(() => {
this.privateChannelsList = this.privateChannelsList.filter(chan => chan.id !== channel.id);
+ this.recordEvent(channel, 'disconnect', null);
systemLogStore.addLog({
name: 'pcOnDisconnect',
type: 'success',
- value: `Sucessfully disconntected from channel [${channel.id}]`,
- });
- } catch {
- systemLogStore.addLog({
- name: 'pcOnDisconnect',
- type: 'error',
- value: `Unable to disconnect from channel [${channel.id}]`,
+ value: `Disconnected from channel [${channel.id}]`,
});
+ });
+ } catch {
+ systemLogStore.addLog({
+ name: 'pcOnDisconnect',
+ type: 'error',
+ value: `Unable to disconnect from channel [${channel.id}]`,
+ });
+ }
+ }
+
+ async listenForEvents(
+ channel: PrivateChannel,
+ channelContexts?: Record,
+ channelContextDelay?: Record
+ ) {
+ if (this.channelEventListeners.has(channel.id)) {
+ return;
+ }
+
+ try {
+ const implementationMetadata = await this.getAgent().then(agent => agent.getInfo());
+ let listeners: Listener[];
+
+ if (versionIsAtLeast(implementationMetadata, '2.2') === true) {
+ listeners = await Promise.all([
+ channel.addEventListener('addContextListener', event => {
+ const contextType = (event as PrivateChannelAddContextListenerEvent).details.contextType ?? null;
+ this.onAddContextListenerEvent(channel, contextType, channelContexts, channelContextDelay);
+ }),
+ channel.addEventListener('unsubscribe', event => {
+ const contextType = (event as PrivateChannelUnsubscribeEvent).details.contextType ?? null;
+ this.onUnsubscribeEvent(channel, contextType);
+ }),
+ channel.addEventListener('disconnect', () => this.onDisconnectEvent(channel)),
+ ]);
+ } else {
+ const legacyChannel = channel as LegacyPrivateChannel;
+ listeners = [
+ legacyChannel.onAddContextListener(contextType =>
+ this.onAddContextListenerEvent(channel, contextType ?? null, channelContexts, channelContextDelay)
+ ),
+ legacyChannel.onUnsubscribe(contextType => this.onUnsubscribeEvent(channel, contextType ?? null)),
+ legacyChannel.onDisconnect(() => this.onDisconnectEvent(channel)),
+ ];
}
- });
+
+ this.channelEventListeners.set(channel.id, listeners);
+ } catch (e) {
+ systemLogStore.addLog({
+ name: 'privateChannelEventListener',
+ type: 'error',
+ value: channel.id,
+ variant: 'code',
+ body: JSON.stringify(e, null, 4),
+ });
+ }
}
- disconnect(channel: PrivateChannel) {
- this.channelListeners.forEach(listener => {
- this.removeContextListener(listener.id);
- });
+ private async unsubscribeEventListeners(channelId: string) {
+ const listeners = this.channelEventListeners.get(channelId) ?? [];
+ this.channelEventListeners.delete(channelId);
+ await Promise.allSettled(listeners.map(listener => listener.unsubscribe()));
+ }
+
+ async disconnect(channel: PrivateChannel) {
+ this.channelListeners
+ .filter(listener => listener.channelId === channel.id)
+ .forEach(listener => this.removeContextListener(listener.id));
this.privateChannelsList = this.privateChannelsList.filter(chan => chan.id !== channel.id);
- channel.disconnect();
+ await this.unsubscribeEventListeners(channel.id);
+ await channel.disconnect();
}
}
diff --git a/toolbox/fdc3-workbench/src/store/SystemLogStore.ts b/toolbox/fdc3-workbench/src/store/SystemLogStore.ts
index 38b5387551..10cd0684ac 100644
--- a/toolbox/fdc3-workbench/src/store/SystemLogStore.ts
+++ b/toolbox/fdc3-workbench/src/store/SystemLogStore.ts
@@ -11,6 +11,7 @@ export type logMessagesName =
| 'getFdc3'
| 'getChannels'
| 'getCurrentChannel'
+ | 'userChannelChanged'
| 'joinUserChannel'
| 'leaveChannel'
| 'broadcast'
@@ -33,7 +34,8 @@ export type logMessagesName =
| 'removeAppChannelContextListener'
| 'pcAddContextListener'
| 'pcOnUnsubscribe'
- | 'pcOnDisconnect';
+ | 'pcOnDisconnect'
+ | 'privateChannelEventListener';
export type logMessagesType = 'error' | 'success' | 'warning' | 'info';