Skip to content

Commit 9a673b9

Browse files
feat: add mouseRelay capability (forward mouse back/forward buttons from iframes)
Add a new `mouseRelay` capability, the mouse analog of `shortcutRelay`. An app inside a Teams iframe can forward the mouse back (X1) / forward (X2) buttons to the host so they drive Teams history navigation when focus is inside the iframe. - Captures X1/X2 (MouseEvent.button 3/4) with capture-phase listeners and forwards a semantic { direction: 'back' | 'forward' } via ApiName.MouseRelay_ForwardNavigation. - Forwards on `mouseup` to match the browser's native release timing; suppresses the iframe's own native nav on `mousedown`/`auxclick`. - Gated by runtime.supports.mouseRelay; hosts advertise it on desktop only (on web the browser already handles X1/X2 over the joint session history). - Exposes isSupported(), enableMouseRelayCapability(), resetIsMouseRelayCapabilityEnabled(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f4c9dc commit 9a673b9

8 files changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { mouseRelay } from '@microsoft/teams-js';
2+
import React from 'react';
3+
4+
import { ApiWithoutInput } from './utils';
5+
import { ModuleWrapper } from './utils/ModuleWrapper';
6+
7+
const CheckMouseRelayCapability = (): React.ReactElement =>
8+
ApiWithoutInput({
9+
name: 'mouseRelay_checkMouseRelayCapability',
10+
title: 'Check Mouse Relay Capability',
11+
onClick: async () => `MouseRelay ${mouseRelay.isSupported() ? 'is' : 'is not'} supported`,
12+
});
13+
14+
const EnableMouseRelayCapability = (): React.ReactElement =>
15+
ApiWithoutInput({
16+
name: 'mouseRelay_enableMouseRelayCapability',
17+
title: 'Enable Mouse Relay Capability and Trigger Back (X1) Button',
18+
onClick: async () => {
19+
await mouseRelay.enableMouseRelayCapability();
20+
document.body.dispatchEvent(new MouseEvent('mouseup', { button: 3, bubbles: true, cancelable: true }));
21+
return 'called';
22+
},
23+
});
24+
25+
const ResetIsMouseRelayCapabilityEnabled = (): React.ReactElement =>
26+
ApiWithoutInput({
27+
name: 'mouseRelay_resetIsMouseRelayCapabilityEnabled',
28+
title: 'Reset Mouse Relay Capability',
29+
onClick: async () => {
30+
mouseRelay.resetIsMouseRelayCapabilityEnabled();
31+
return 'called';
32+
},
33+
});
34+
35+
const MouseRelayAPIs = (): React.ReactElement => (
36+
<>
37+
<ModuleWrapper title="MouseRelay">
38+
<CheckMouseRelayCapability />
39+
<EnableMouseRelayCapability />
40+
<ResetIsMouseRelayCapabilityEnabled />
41+
</ModuleWrapper>
42+
</>
43+
);
44+
45+
export default MouseRelayAPIs;

apps/teams-test-app/src/pages/TestApp.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import MarketplaceAPIs from '../components/MarketplaceAPIs';
2929
import MediaAPIs from '../components/MediaAPIs';
3030
import MeetingAPIs from '../components/MeetingAPIs';
3131
import MenusAPIs from '../components/MenusAPIs';
32+
import MouseRelayAPIs from '../components/MouseRelayAPIs';
3233
import NestedAppAuthAPIs from '../components/NestedAppAuthAPIs';
3334
import OtherAppStateChangedAPIs from '../components/OtherAppStateChangeAPIs';
3435
import PagesAPIs from '../components/PagesAPIs';
@@ -146,6 +147,7 @@ export const TestApp: React.FC = () => {
146147
{ name: 'MenusAPIs', component: <MenusAPIs /> },
147148
{ name: 'MessageChannelAPIs', component: <MessageChannelAPIs /> },
148149
{ name: 'MonetizationAPIs', component: <MonetizationAPIs /> },
150+
{ name: 'MouseRelayAPIs', component: <MouseRelayAPIs /> },
149151
{ name: 'NestedAppAuthAPIs', component: <NestedAppAuthAPIs /> },
150152
{ name: 'NotificationAPIs', component: <NotificationAPIs /> },
151153
{ name: 'OtherAppStateChangedAPIs', component: <OtherAppStateChangedAPIs /> },
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"comment": "Added `mouseRelay` capability that will forward mouse back/forward (X1/X2) buttons from app iframes to the host for Teams history navigation.",
3+
"type": "minor",
4+
"packageName": "@microsoft/teams-js",
5+
"email": "yuanboxue@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/teams-js/src/internal/telemetry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export const enum ApiName {
241241
MessageChannels_Telemetry_GetTelemetryPort = 'messageChannels.telemetry.getTelemetryPort',
242242
MessageChannels_DataLayer_GetDataLayerPort = 'messageChannels.dataLayer.getDataLayerPort',
243243
Monetization_OpenPurchaseExperience = 'monetization.openPurchaseExperience',
244+
MouseRelay_ForwardNavigation = 'mouseRelay.forwardNavigation',
244245
Navigation_NavigateBack = 'navigation.navigateBack',
245246
Navigation_NavigateCrossDomain = 'navigation.navigateCrossDomain',
246247
Navigation_NavigateToTab = 'navigation.navigateToTab',

packages/teams-js/src/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,5 +142,6 @@ export * as liveShare from './liveShareHost';
142142
//to keep the named exports so as to not break the existing consumers directly referencing the named exports.
143143
export { LiveShareHost } from './liveShareHost';
144144
export * as marketplace from './marketplace';
145+
export * as mouseRelay from './mouseRelay';
145146
export { ISerializable } from './serializable.interface';
146147
export * as shortcutRelay from './shortcutRelay';
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Lets an app inside a Teams iframe forward the mouse back (X1) / forward (X2)
3+
* buttons to the host so they drive Teams history navigation.
4+
*
5+
* @module
6+
*/
7+
8+
import { callFunctionInHost } from '../internal/communication';
9+
import { ensureInitialized } from '../internal/internalAPIs';
10+
import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
11+
import { errorNotSupportedOnPlatform } from './constants';
12+
import { runtime } from './runtime';
13+
import { ISerializable } from './serializable.interface';
14+
15+
/* ------------------------------------------------------------------ */
16+
/* Types */
17+
/* ------------------------------------------------------------------ */
18+
19+
/**
20+
* The direction of a forwarded history-navigation intent.
21+
*/
22+
export type NavigationDirection = 'back' | 'forward';
23+
24+
/* ------------------------------------------------------------------ */
25+
/* Utils */
26+
/* ------------------------------------------------------------------ */
27+
28+
/** `MouseEvent.button` value for the back (X1) button. */
29+
const MOUSE_BUTTON_BACK = 3;
30+
31+
/** `MouseEvent.button` value for the forward (X2) button. */
32+
const MOUSE_BUTTON_FORWARD = 4;
33+
34+
class SerializableNavigationIntent implements ISerializable {
35+
public constructor(private direction: NavigationDirection) {}
36+
public serialize(): object {
37+
return { direction: this.direction };
38+
}
39+
}
40+
41+
/**
42+
* Maps a `MouseEvent.button` value to a navigation direction, or `undefined`
43+
* for any button other than the back (X1) / forward (X2) buttons.
44+
*/
45+
function directionForButton(button: number): NavigationDirection | undefined {
46+
if (button === MOUSE_BUTTON_BACK) {
47+
return 'back';
48+
}
49+
if (button === MOUSE_BUTTON_FORWARD) {
50+
return 'forward';
51+
}
52+
return undefined;
53+
}
54+
55+
/**
56+
* Suppress the iframe's own native X1/X2 history navigation (and the synthetic
57+
* `click`). Attached to `mousedown` (earliest, most reliable) and `auxclick`;
58+
* never forwards.
59+
*/
60+
function suppressX1X2(event: MouseEvent): void {
61+
if (directionForButton(event.button) !== undefined) {
62+
event.preventDefault();
63+
event.stopImmediatePropagation();
64+
}
65+
}
66+
67+
/**
68+
* Forward the back/forward intent to the host on `mouseup` (release), matching
69+
* the browser's native timing so press-and-hold does nothing until release.
70+
*/
71+
function mouseupHandler(event: MouseEvent): void {
72+
const direction = directionForButton(event.button);
73+
if (!direction) {
74+
return; // not the back/forward button
75+
}
76+
77+
event.preventDefault();
78+
event.stopImmediatePropagation();
79+
80+
callFunctionInHost(
81+
ApiName.MouseRelay_ForwardNavigation,
82+
[new SerializableNavigationIntent(direction)],
83+
getApiVersionTag(ApiVersionNumber.V_2, ApiName.MouseRelay_ForwardNavigation),
84+
);
85+
}
86+
87+
/* ------------------------------------------------------------------ */
88+
/* In-memory */
89+
/* ------------------------------------------------------------------ */
90+
91+
/**
92+
* @hidden
93+
* @internal
94+
* Flag to indicate the mouse relay capability has been enabled, so that we do
95+
* not register the event listeners multiple times.
96+
*/
97+
let isMouseRelayCapabilityEnabled = false;
98+
99+
/* ------------------------------------------------------------------ */
100+
/* API */
101+
/* ------------------------------------------------------------------ */
102+
103+
/**
104+
* Enable the capability so the mouse back (X1) / forward (X2) buttons pressed
105+
* inside this iframe drive Teams history navigation in the host.
106+
*
107+
* @throws Error if {@link app.initialize} has not successfully completed or the
108+
* host does not support the mouseRelay capability.
109+
*/
110+
export async function enableMouseRelayCapability(): Promise<void> {
111+
if (!isSupported()) {
112+
throw errorNotSupportedOnPlatform;
113+
}
114+
115+
if (!isMouseRelayCapabilityEnabled) {
116+
document.addEventListener('mousedown', suppressX1X2, { capture: true });
117+
document.addEventListener('mouseup', mouseupHandler, { capture: true });
118+
document.addEventListener('auxclick', suppressX1X2, { capture: true });
119+
}
120+
isMouseRelayCapabilityEnabled = true;
121+
}
122+
123+
/**
124+
* Reset the state of the mouse relay capability, detaching its listeners.
125+
* This is useful for tests to ensure a clean state.
126+
*
127+
* @throws Error if {@link app.initialize} has not successfully completed or the
128+
* host does not support the mouseRelay capability.
129+
*/
130+
export function resetIsMouseRelayCapabilityEnabled(): void {
131+
if (!isSupported()) {
132+
throw errorNotSupportedOnPlatform;
133+
}
134+
135+
isMouseRelayCapabilityEnabled = false;
136+
document.removeEventListener('mousedown', suppressX1X2, { capture: true });
137+
document.removeEventListener('mouseup', mouseupHandler, { capture: true });
138+
document.removeEventListener('auxclick', suppressX1X2, { capture: true });
139+
}
140+
141+
/**
142+
* Checks if the mouseRelay capability is supported by the host.
143+
* @returns boolean to represent whether the mouseRelay capability is supported.
144+
*
145+
* @throws Error if {@link app.initialize} has not successfully completed.
146+
*/
147+
export function isSupported(): boolean {
148+
return ensureInitialized(runtime) && runtime.supports.mouseRelay ? true : false;
149+
}

packages/teams-js/src/public/runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ interface IRuntimeV4 extends IBaseRuntime {
280280
readonly dataLayer?: {};
281281
};
282282
readonly monetization?: {};
283+
readonly mouseRelay?: {};
283284
readonly nestedAppAuth?: {};
284285
readonly notifications?: {};
285286
readonly otherAppStateChange?: {};
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { errorLibraryNotInitialized } from '../../src/internal/constants';
2+
import { GlobalVars } from '../../src/internal/globalVars';
3+
import { ApiName } from '../../src/internal/telemetry';
4+
import * as app from '../../src/public/app/app';
5+
import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants';
6+
import * as mouseRelay from '../../src/public/mouseRelay';
7+
import { latestRuntimeApiVersion } from '../../src/public/runtime';
8+
import { Utils } from '../utils';
9+
10+
const BACK_BUTTON = 3; // X1
11+
const FORWARD_BUTTON = 4; // X2
12+
13+
describe('mouseRelay capability', () => {
14+
describe('frameless', () => {
15+
let utils: Utils = new Utils();
16+
beforeEach(() => {
17+
utils = new Utils();
18+
utils.mockWindow.parent = undefined;
19+
utils.messages = [];
20+
GlobalVars.isFramelessWindow = false;
21+
});
22+
afterEach(() => {
23+
app._uninitialize?.();
24+
});
25+
26+
describe('isSupported()', () => {
27+
it('returns false when runtime says it is not supported', async () => {
28+
await utils.initializeWithContext(FrameContexts.content);
29+
utils.setRuntimeConfig({ apiVersion: 1, supports: {} });
30+
expect(mouseRelay.isSupported()).toBeFalsy();
31+
});
32+
33+
it('returns true when runtime says it is supported', async () => {
34+
await utils.initializeWithContext(FrameContexts.content);
35+
utils.setRuntimeConfig({ apiVersion: 1, supports: { mouseRelay: {} } });
36+
expect(mouseRelay.isSupported()).toBeTruthy();
37+
});
38+
39+
it('throws before initialization', () => {
40+
utils.uninitializeRuntimeConfig();
41+
expect(() => mouseRelay.isSupported()).toThrowError(new Error(errorLibraryNotInitialized));
42+
});
43+
});
44+
45+
describe('enableMouseRelayCapability()', () => {
46+
it('should reject before initialization', async () => {
47+
await expect(mouseRelay.enableMouseRelayCapability()).rejects.toThrowError(
48+
new Error(errorLibraryNotInitialized),
49+
);
50+
});
51+
52+
it('should reject when capability not supported in runtime', async () => {
53+
await utils.initializeWithContext(FrameContexts.content);
54+
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: {} });
55+
56+
await expect(mouseRelay.enableMouseRelayCapability()).rejects.toEqual(errorNotSupportedOnPlatform);
57+
});
58+
59+
it('forwards { direction: back } to host on the back (X1) button mouseup', async () => {
60+
await utils.initializeWithContext(FrameContexts.content);
61+
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });
62+
63+
await mouseRelay.enableMouseRelayCapability();
64+
65+
document.body.dispatchEvent(
66+
new MouseEvent('mouseup', { button: BACK_BUTTON, bubbles: true, cancelable: true }),
67+
);
68+
69+
const fwd = utils.findMessageByFunc(ApiName.MouseRelay_ForwardNavigation);
70+
expect(fwd).not.toBeNull();
71+
expect(fwd?.args?.[0]).toEqual({ direction: 'back' });
72+
});
73+
74+
it('forwards { direction: forward } to host on the forward (X2) button mouseup', async () => {
75+
await utils.initializeWithContext(FrameContexts.content);
76+
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });
77+
78+
await mouseRelay.enableMouseRelayCapability();
79+
80+
document.body.dispatchEvent(
81+
new MouseEvent('mouseup', { button: FORWARD_BUTTON, bubbles: true, cancelable: true }),
82+
);
83+
84+
const fwd = utils.findMessageByFunc(ApiName.MouseRelay_ForwardNavigation);
85+
expect(fwd?.args?.[0]).toEqual({ direction: 'forward' });
86+
});
87+
88+
it('suppresses native nav on mousedown but forwards only on release (mouseup)', async () => {
89+
await utils.initializeWithContext(FrameContexts.content);
90+
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });
91+
92+
await mouseRelay.enableMouseRelayCapability();
93+
94+
const down = new MouseEvent('mousedown', { button: BACK_BUTTON, bubbles: true, cancelable: true });
95+
document.body.dispatchEvent(down);
96+
97+
expect(down.defaultPrevented).toBe(true); // suppressed early
98+
expect(utils.findMessageByFunc(ApiName.MouseRelay_ForwardNavigation)).toBeNull(); // not yet
99+
});
100+
101+
it('ignores non-navigation mouse buttons', async () => {
102+
await utils.initializeWithContext(FrameContexts.content);
103+
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });
104+
105+
await mouseRelay.enableMouseRelayCapability();
106+
107+
const evt = new MouseEvent('mouseup', { button: 0, bubbles: true, cancelable: true });
108+
document.body.dispatchEvent(evt);
109+
110+
expect(utils.findMessageByFunc(ApiName.MouseRelay_ForwardNavigation)).toBeNull();
111+
expect(evt.defaultPrevented).toBe(false);
112+
});
113+
});
114+
115+
describe('resetIsMouseRelayCapabilityEnabled()', () => {
116+
it('detaches the listeners so events stop forwarding', async () => {
117+
await utils.initializeWithContext(FrameContexts.content);
118+
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });
119+
120+
await mouseRelay.enableMouseRelayCapability();
121+
mouseRelay.resetIsMouseRelayCapabilityEnabled();
122+
utils.messages = [];
123+
124+
document.body.dispatchEvent(
125+
new MouseEvent('mouseup', { button: BACK_BUTTON, bubbles: true, cancelable: true }),
126+
);
127+
128+
expect(utils.findMessageByFunc(ApiName.MouseRelay_ForwardNavigation)).toBeNull();
129+
});
130+
});
131+
});
132+
});

0 commit comments

Comments
 (0)