-
Notifications
You must be signed in to change notification settings - Fork 210
feat: mouseRelay capability (relay mouse back/forward buttons from iframes) #3078
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
YuanboXue-Amber
wants to merge
3
commits into
OfficeDev:main
Choose a base branch
from
YuanboXue-Amber:users/yuanboxue/mouse-relay-capability
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
7cf8fa4
feat: add mouseRelay capability (forward mouse back/forward buttons f…
YuanboXue-Amber 3db5ff0
refactor: rename resetIsMouseRelayCapabilityEnabled to disableMouseRe…
YuanboXue-Amber 94d80d7
address PR review: drop synthetic mouseup in test app, add auxclick u…
YuanboXue-Amber File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| }, | ||
| }); | ||
|
|
||
| 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
7 changes: 7 additions & 0 deletions
7
change/@microsoft-teams-js-e7c0b12b-b04f-4889-8666-0308f962df9d.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| } | ||
|
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; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
|
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(); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.