Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
45 changes: 45 additions & 0 deletions apps/teams-test-app/src/components/MouseRelayAPIs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { mouseRelay } from '@microsoft/teams-js';
import React from 'react';

import { ApiWithoutInput } from './utils';
import { ModuleWrapper } from './utils/ModuleWrapper';

const CheckMouseRelayCapability = (): React.ReactElement =>
ApiWithoutInput({
name: 'mouseRelay_checkMouseRelayCapability',
title: 'Check Mouse Relay Capability',
onClick: async () => `MouseRelay ${mouseRelay.isSupported() ? 'is' : 'is not'} supported`,
});

const EnableMouseRelayCapability = (): React.ReactElement =>
ApiWithoutInput({
name: 'mouseRelay_enableMouseRelayCapability',
title: 'Enable Mouse Relay Capability and Trigger Back (X1) Button',
onClick: async () => {
await mouseRelay.enableMouseRelayCapability();
document.body.dispatchEvent(new MouseEvent('mouseup', { button: 3, bubbles: true, cancelable: true }));
return 'called';
},
});
Comment thread
YuanboXue-Amber marked this conversation as resolved.

const ResetIsMouseRelayCapabilityEnabled = (): React.ReactElement =>
ApiWithoutInput({
name: 'mouseRelay_resetIsMouseRelayCapabilityEnabled',
title: 'Reset Mouse Relay Capability',
onClick: async () => {
mouseRelay.resetIsMouseRelayCapabilityEnabled();
return 'called';
},
});

const MouseRelayAPIs = (): React.ReactElement => (
<>
<ModuleWrapper title="MouseRelay">
<CheckMouseRelayCapability />
<EnableMouseRelayCapability />
<ResetIsMouseRelayCapabilityEnabled />
</ModuleWrapper>
</>
);

export default MouseRelayAPIs;
2 changes: 2 additions & 0 deletions apps/teams-test-app/src/pages/TestApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import MarketplaceAPIs from '../components/MarketplaceAPIs';
import MediaAPIs from '../components/MediaAPIs';
import MeetingAPIs from '../components/MeetingAPIs';
import MenusAPIs from '../components/MenusAPIs';
import MouseRelayAPIs from '../components/MouseRelayAPIs';
import NestedAppAuthAPIs from '../components/NestedAppAuthAPIs';
import OtherAppStateChangedAPIs from '../components/OtherAppStateChangeAPIs';
import PagesAPIs from '../components/PagesAPIs';
Expand Down Expand Up @@ -146,6 +147,7 @@ export const TestApp: React.FC = () => {
{ name: 'MenusAPIs', component: <MenusAPIs /> },
{ name: 'MessageChannelAPIs', component: <MessageChannelAPIs /> },
{ name: 'MonetizationAPIs', component: <MonetizationAPIs /> },
{ name: 'MouseRelayAPIs', component: <MouseRelayAPIs /> },
{ name: 'NestedAppAuthAPIs', component: <NestedAppAuthAPIs /> },
{ name: 'NotificationAPIs', component: <NotificationAPIs /> },
{ name: 'OtherAppStateChangedAPIs', component: <OtherAppStateChangedAPIs /> },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"comment": "Added `mouseRelay` capability that will forward mouse back/forward (X1/X2) buttons from app iframes to the host for Teams history navigation.",
"type": "minor",
"packageName": "@microsoft/teams-js",
"email": "yuanboxue@microsoft.com",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions packages/teams-js/src/internal/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export const enum ApiName {
MessageChannels_Telemetry_GetTelemetryPort = 'messageChannels.telemetry.getTelemetryPort',
MessageChannels_DataLayer_GetDataLayerPort = 'messageChannels.dataLayer.getDataLayerPort',
Monetization_OpenPurchaseExperience = 'monetization.openPurchaseExperience',
MouseRelay_NavigateHistory = 'mouseRelay.navigateHistory',
Navigation_NavigateBack = 'navigation.navigateBack',
Navigation_NavigateCrossDomain = 'navigation.navigateCrossDomain',
Navigation_NavigateToTab = 'navigation.navigateToTab',
Expand Down
1 change: 1 addition & 0 deletions packages/teams-js/src/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,6 @@ export * as liveShare from './liveShareHost';
//to keep the named exports so as to not break the existing consumers directly referencing the named exports.
export { LiveShareHost } from './liveShareHost';
export * as marketplace from './marketplace';
export * as mouseRelay from './mouseRelay';
export { ISerializable } from './serializable.interface';
export * as shortcutRelay from './shortcutRelay';
149 changes: 149 additions & 0 deletions packages/teams-js/src/public/mouseRelay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Lets an app inside a Teams iframe relay the mouse back (X1) / forward (X2)
* buttons to the host so they drive Teams history navigation.
*
* @module
*/

import { callFunctionInHost } from '../internal/communication';
import { ensureInitialized } from '../internal/internalAPIs';
import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
import { errorNotSupportedOnPlatform } from './constants';
import { runtime } from './runtime';
import { ISerializable } from './serializable.interface';

/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */

/**
* The direction of a relayed history-navigation intent.
*/
export type NavigationDirection = 'back' | 'forward';

/* ------------------------------------------------------------------ */
/* Utils */
/* ------------------------------------------------------------------ */

/** `MouseEvent.button` value for the back (X1) button. */
const MOUSE_BUTTON_BACK = 3;

/** `MouseEvent.button` value for the forward (X2) button. */
const MOUSE_BUTTON_FORWARD = 4;

class SerializableNavigationIntent implements ISerializable {
public constructor(private direction: NavigationDirection) {}
public serialize(): object {
return { direction: this.direction };
}
}

/**
* Maps a `MouseEvent.button` value to a navigation direction, or `undefined`
* for any button other than the back (X1) / forward (X2) buttons.
*/
function directionForButton(button: number): NavigationDirection | undefined {
if (button === MOUSE_BUTTON_BACK) {
return 'back';
}
if (button === MOUSE_BUTTON_FORWARD) {
return 'forward';
}
return undefined;
}

/**
* Suppress the iframe's own native X1/X2 history navigation (and the synthetic
* `click`). Attached to `mousedown` (earliest, most reliable) and `auxclick`;
* never relays.
*/
function suppressX1X2(event: MouseEvent): void {
if (directionForButton(event.button) !== undefined) {
event.preventDefault();
event.stopImmediatePropagation();
}
}

/**
* Relay the back/forward intent to the host on `mouseup` (release), matching
* the browser's native timing so press-and-hold does nothing until release.
*/
function mouseupHandler(event: MouseEvent): void {
const direction = directionForButton(event.button);
if (!direction) {
return; // not the back/forward button
}

event.preventDefault();
event.stopImmediatePropagation();

callFunctionInHost(
ApiName.MouseRelay_NavigateHistory,
[new SerializableNavigationIntent(direction)],
getApiVersionTag(ApiVersionNumber.V_2, ApiName.MouseRelay_NavigateHistory),
);
}

/* ------------------------------------------------------------------ */
/* In-memory */
/* ------------------------------------------------------------------ */

/**
* @hidden
* @internal
* Flag to indicate the mouse relay capability has been enabled, so that we do
* not register the event listeners multiple times.
*/
let isMouseRelayCapabilityEnabled = false;

/* ------------------------------------------------------------------ */
/* API */
/* ------------------------------------------------------------------ */

/**
* Enable the capability so the mouse back (X1) / forward (X2) buttons pressed
* inside this iframe drive Teams history navigation in the host.
*
* @throws Error if {@link app.initialize} has not successfully completed or the
* host does not support the mouseRelay capability.
*/
export async function enableMouseRelayCapability(): Promise<void> {
if (!isSupported()) {
throw errorNotSupportedOnPlatform;
}

if (!isMouseRelayCapabilityEnabled) {
document.addEventListener('mousedown', suppressX1X2, { capture: true });
document.addEventListener('mouseup', mouseupHandler, { capture: true });
document.addEventListener('auxclick', suppressX1X2, { capture: true });
}
isMouseRelayCapabilityEnabled = true;
}

/**
* Reset the state of the mouse relay capability, detaching its listeners.
* This is useful for tests to ensure a clean state.
*
* @throws Error if {@link app.initialize} has not successfully completed or the
* host does not support the mouseRelay capability.
*/
export function resetIsMouseRelayCapabilityEnabled(): void {
if (!isSupported()) {
throw errorNotSupportedOnPlatform;
}

isMouseRelayCapabilityEnabled = false;
document.removeEventListener('mousedown', suppressX1X2, { capture: true });
document.removeEventListener('mouseup', mouseupHandler, { capture: true });
document.removeEventListener('auxclick', suppressX1X2, { capture: true });
}
Comment thread
YuanboXue-Amber marked this conversation as resolved.

/**
* Checks if the mouseRelay capability is supported by the host.
* @returns boolean to represent whether the mouseRelay capability is supported.
*
* @throws Error if {@link app.initialize} has not successfully completed.
*/
export function isSupported(): boolean {
return ensureInitialized(runtime) && runtime.supports.mouseRelay ? true : false;
}
1 change: 1 addition & 0 deletions packages/teams-js/src/public/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ interface IRuntimeV4 extends IBaseRuntime {
readonly dataLayer?: {};
};
readonly monetization?: {};
readonly mouseRelay?: {};
readonly nestedAppAuth?: {};
readonly notifications?: {};
readonly otherAppStateChange?: {};
Expand Down
132 changes: 132 additions & 0 deletions packages/teams-js/test/public/mouseRelay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { errorLibraryNotInitialized } from '../../src/internal/constants';
import { GlobalVars } from '../../src/internal/globalVars';
import { ApiName } from '../../src/internal/telemetry';
import * as app from '../../src/public/app/app';
import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants';
import * as mouseRelay from '../../src/public/mouseRelay';
import { latestRuntimeApiVersion } from '../../src/public/runtime';
import { Utils } from '../utils';

const BACK_BUTTON = 3; // X1
const FORWARD_BUTTON = 4; // X2

describe('mouseRelay capability', () => {
describe('frameless', () => {
let utils: Utils = new Utils();
beforeEach(() => {
utils = new Utils();
utils.mockWindow.parent = undefined;
utils.messages = [];
GlobalVars.isFramelessWindow = false;
});
afterEach(() => {
app._uninitialize?.();
});

describe('isSupported()', () => {
it('returns false when runtime says it is not supported', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: 1, supports: {} });
expect(mouseRelay.isSupported()).toBeFalsy();
});

it('returns true when runtime says it is supported', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: 1, supports: { mouseRelay: {} } });
expect(mouseRelay.isSupported()).toBeTruthy();
});

it('throws before initialization', () => {
utils.uninitializeRuntimeConfig();
expect(() => mouseRelay.isSupported()).toThrowError(new Error(errorLibraryNotInitialized));
});
});

describe('enableMouseRelayCapability()', () => {
it('should reject before initialization', async () => {
await expect(mouseRelay.enableMouseRelayCapability()).rejects.toThrowError(
new Error(errorLibraryNotInitialized),
);
});

it('should reject when capability not supported in runtime', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: {} });

await expect(mouseRelay.enableMouseRelayCapability()).rejects.toEqual(errorNotSupportedOnPlatform);
});

it('forwards { direction: back } to host on the back (X1) button mouseup', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });

await mouseRelay.enableMouseRelayCapability();

document.body.dispatchEvent(
new MouseEvent('mouseup', { button: BACK_BUTTON, bubbles: true, cancelable: true }),
);

const fwd = utils.findMessageByFunc(ApiName.MouseRelay_NavigateHistory);
expect(fwd).not.toBeNull();
expect(fwd?.args?.[0]).toEqual({ direction: 'back' });
});

it('forwards { direction: forward } to host on the forward (X2) button mouseup', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });

await mouseRelay.enableMouseRelayCapability();

document.body.dispatchEvent(
new MouseEvent('mouseup', { button: FORWARD_BUTTON, bubbles: true, cancelable: true }),
);

const fwd = utils.findMessageByFunc(ApiName.MouseRelay_NavigateHistory);
expect(fwd?.args?.[0]).toEqual({ direction: 'forward' });
});

it('suppresses native nav on mousedown but forwards only on release (mouseup)', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });

await mouseRelay.enableMouseRelayCapability();
Comment thread
YuanboXue-Amber marked this conversation as resolved.

const down = new MouseEvent('mousedown', { button: BACK_BUTTON, bubbles: true, cancelable: true });
document.body.dispatchEvent(down);

expect(down.defaultPrevented).toBe(true); // suppressed early
expect(utils.findMessageByFunc(ApiName.MouseRelay_NavigateHistory)).toBeNull(); // not yet
});

it('ignores non-navigation mouse buttons', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });

await mouseRelay.enableMouseRelayCapability();

const evt = new MouseEvent('mouseup', { button: 0, bubbles: true, cancelable: true });
document.body.dispatchEvent(evt);

expect(utils.findMessageByFunc(ApiName.MouseRelay_NavigateHistory)).toBeNull();
expect(evt.defaultPrevented).toBe(false);
});
});

describe('resetIsMouseRelayCapabilityEnabled()', () => {
it('detaches the listeners so events stop forwarding', async () => {
await utils.initializeWithContext(FrameContexts.content);
utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { mouseRelay: {} } });

await mouseRelay.enableMouseRelayCapability();
mouseRelay.resetIsMouseRelayCapabilityEnabled();
utils.messages = [];

document.body.dispatchEvent(
new MouseEvent('mouseup', { button: BACK_BUTTON, bubbles: true, cancelable: true }),
);

expect(utils.findMessageByFunc(ApiName.MouseRelay_NavigateHistory)).toBeNull();
});
});
});
});