diff --git a/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx new file mode 100644 index 0000000000..d575ed7fcc --- /dev/null +++ b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx @@ -0,0 +1,56 @@ +import { shortcutRelay } from '@microsoft/teams-js'; +import React from 'react'; + +import { ApiWithoutInput } from './utils'; +import { ModuleWrapper } from './utils/ModuleWrapper'; + +const CheckShortcutRelayCapability = (): React.ReactElement => + ApiWithoutInput({ + name: 'shortcutRelay_checkShortcutRelayCapability', + title: 'Check Shortcut Relay Capability', + onClick: async () => `ShortcutRelay ${shortcutRelay.isSupported() ? 'is' : 'is not'} supported`, + }); + +const EnableShortcutRelayCapability = (): React.ReactElement => + ApiWithoutInput({ + name: 'shortcutRelay_enableShortcutRelayCapability', + title: 'Enable Shortcut Relay Capability and Trigger Ctrl+1 shortcut', + onClick: async () => { + await shortcutRelay.enableShortcutRelayCapability(); + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '1', ctrlKey: true })); + return 'called'; + }, + }); + +const SetOverridableShortcutHandler = (): React.ReactElement => + ApiWithoutInput({ + name: 'shortcutRelay_setOverridableShortcutHandler', + title: 'Set Overridable Shortcut Handler', + onClick: async () => { + shortcutRelay.setOverridableShortcutHandler(() => true); + return 'called'; + }, + }); + +const ResetIsShortcutRelayCapabilityEnabled = (): React.ReactElement => + ApiWithoutInput({ + name: 'shortcutRelay_resetIsShortcutRelayCapabilityEnabled', + title: 'Reset Shortcut Relay Capability', + onClick: async () => { + shortcutRelay.resetIsShortcutRelayCapabilityEnabled(); + return 'called'; + }, + }); + +const ShortcutRelayAPIs = (): React.ReactElement => ( + <> + + + + + + + +); + +export default ShortcutRelayAPIs; diff --git a/apps/teams-test-app/src/pages/TestApp.tsx b/apps/teams-test-app/src/pages/TestApp.tsx index 5ae331c273..1f8a19c24c 100644 --- a/apps/teams-test-app/src/pages/TestApp.tsx +++ b/apps/teams-test-app/src/pages/TestApp.tsx @@ -59,6 +59,7 @@ import RemoteCameraAPIs from '../components/RemoteCameraAPIs'; import SearchAPIs from '../components/SearchAPIs'; import SecondaryBrowserAPIs from '../components/SecondaryBrowserAPIs'; import SharingAPIs from '../components/SharingAPIs'; +import ShortcutRelayAPIs from '../components/ShortcutRelayAPIs'; import StageViewAPIs from '../components/StageViewAPIs'; import StageViewSelfAPIs from '../components/StageViewSelfAPIs'; import StoreAPIs from '../components/StoreApis'; @@ -157,6 +158,7 @@ export const TestApp: React.FC = () => { { name: 'SearchAPIs', component: }, { name: 'SecondaryBrowserAPIs', component: }, { name: 'SharingAPIs', component: }, + { name: 'ShortcutRelayAPIs', component: }, { name: 'WebStorageAPIs', component: }, { name: 'StageViewAPIs', component: }, { name: 'StageViewSelfAPIs', component: }, diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts index 0ac67330f2..45316a44ba 100644 --- a/packages/teams-js/src/internal/telemetry.ts +++ b/packages/teams-js/src/internal/telemetry.ts @@ -332,6 +332,9 @@ export const enum ApiName { Settings_SetValidityState = 'settings.setValidityState', Sharing_History_GetContent = 'sharing.history.getContent', Sharing_ShareWebContent = 'sharing.shareWebContent', + ShortcutRelay_GetHostShortcuts = 'shortcutRelay.getHostShortcuts', + ShortcutRelay_ForwardShortcutEvent = 'shortcutRelay.forwardShortcutEvent', + ShortcutRelay_HostShortcutChanged = 'shortcutRelay.hostShortcutChanged', StageView_Open = 'stageView.open', StageView_Self_Close = 'stageView.self.close', Store_OpenFullStore = 'store.openFullStore', diff --git a/packages/teams-js/src/public/index.ts b/packages/teams-js/src/public/index.ts index 10a778a61b..1bc8b00f3e 100644 --- a/packages/teams-js/src/public/index.ts +++ b/packages/teams-js/src/public/index.ts @@ -139,3 +139,4 @@ export * as liveShare from './liveShareHost'; export { LiveShareHost } from './liveShareHost'; export * as marketplace from './marketplace'; export { ISerializable } from './serializable.interface'; +export * as shortcutRelay from './shortcutRelay'; diff --git a/packages/teams-js/src/public/runtime.ts b/packages/teams-js/src/public/runtime.ts index 1fcb97e6a0..891679f3f4 100644 --- a/packages/teams-js/src/public/runtime.ts +++ b/packages/teams-js/src/public/runtime.ts @@ -298,6 +298,7 @@ interface IRuntimeV4 extends IBaseRuntime { readonly sharing?: { readonly history?: {}; }; + readonly shortcutRelay?: {}; readonly stageView?: { readonly self?: {}; }; diff --git a/packages/teams-js/src/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts new file mode 100644 index 0000000000..81a9fd7b56 --- /dev/null +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -0,0 +1,325 @@ +/** + * Allows host shortcuts to function in your application by forwarding keyboard shortcuts to the host. + * + * This functionality is in Beta. + * @beta + * @module + */ + +import { callFunctionInHost, callFunctionInHostAndHandleResponse } from '../internal/communication'; +import { registerHandler } from '../internal/handlers'; +import { ensureInitialized } from '../internal/internalAPIs'; +import { ResponseHandler } from '../internal/responseHandler'; +import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry'; +import { errorNotSupportedOnPlatform } from './constants'; +import { runtime } from './runtime'; +import { ISerializable } from './serializable.interface'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type Shortcuts = Set; // stores shortcut key combinations, for example: ["ctrl+/", "ctrl+shift+/"] + +type HostShortcutsResponse = { + shortcuts: Shortcuts; + overridableShortcuts: Shortcuts; +}; + +/** + * Data passed to the overridable shortcut handler. + */ +export type OverridableShortcutHandlerData = { + /** + * The matched shortcut that triggered the handler. + * This is a canonical form of the shortcut, e.g. "ctrl+shift+x". + */ + matchedShortcut: string; +}; + +/** + * A handler must return `true` when it wants to **consume** the shortcut + * (i.e. prevent forwarding to host). Any other return value forwards it. + */ +export type OverridableShortcutHandler = (event: KeyboardEvent, data: OverridableShortcutHandlerData) => boolean; + +/* ------------------------------------------------------------------ */ +/* Utils */ +/* ------------------------------------------------------------------ */ + +class SerializableKeyboardEvent implements ISerializable { + public constructor(private event: KeyboardEvent) {} + public serialize(): object | string { + return { + altKey: this.event.altKey, + bubbles: this.event.bubbles, + cancelBubble: this.event.cancelBubble, + charCode: this.event.charCode, + code: this.event.code, + composed: this.event.composed, + ctrlKey: this.event.ctrlKey, + // currentTarget: skipped + defaultPrevented: this.event.defaultPrevented, + detail: this.event.detail, + eventPhase: this.event.eventPhase, + isComposing: this.event.isComposing, + isTrusted: this.event.isTrusted, + key: this.event.key, + keyCode: this.event.keyCode, + location: this.event.location, + metaKey: this.event.metaKey, + // path - skipped, + repeat: this.event.repeat, + returnValue: this.event.returnValue, + shiftKey: this.event.shiftKey, + // sourceCapabilities - skipped, + // srcElement - slipped. + // target - skipped. + timeStamp: this.event.timeStamp, + type: this.event.type, + // view - skipped + which: this.event.which, + }; + } +} + +/** + * Normalizes a shortcut string to a canonical form. + */ +function normalizeShortcut(shortcut: string): string { + return shortcut.toLowerCase().split('+').sort().join('+'); +} + +/** + * Build a canonical, lower-case “ctrl+shift+x” representation of the + * currently pressed keys. The array is sorted so the order in which + * modifiers are pressed does not matter. + */ +function eventToCanonicalShortcut(e: KeyboardEvent): string { + return [e.ctrlKey && 'ctrl', e.shiftKey && 'shift', e.altKey && 'alt', e.metaKey && 'meta', e.key.toLowerCase()] + .filter(Boolean) + .sort() + .join('+'); +} + +/** + * Checks if the event is a valid shortcut event. + * A valid shortcut event is one that has at least one modifier key pressed + * (ctrl, shift, alt, meta) or the Escape key. + */ +function isValidShortcutEvent(e: KeyboardEvent): boolean { + return e.ctrlKey || e.shiftKey || e.altKey || e.metaKey || (!!e.key && e.key.toLowerCase() === 'escape'); +} + +function isMatchingShortcut( + shortcuts: Shortcuts, + e: KeyboardEvent, +): { + matchedShortcut: string | undefined; + isOverridable: boolean; +} { + if (isValidShortcutEvent(e)) { + const pressedShortcut = eventToCanonicalShortcut(e); + const isMatching = shortcuts.has(pressedShortcut); + if (isMatching) { + return { + matchedShortcut: pressedShortcut, + isOverridable: overridableShortcuts.has(pressedShortcut), + }; + } + } + + return { + matchedShortcut: undefined, + isOverridable: false, + }; +} + +function updateHostShortcuts(data: HostShortcutsResponse): void { + hostShortcuts.clear(); + data.shortcuts.forEach((shortcut: string) => { + hostShortcuts.add(normalizeShortcut(shortcut)); + }); + + overridableShortcuts.clear(); + data.overridableShortcuts.forEach((shortcut: string) => { + overridableShortcuts.add(normalizeShortcut(shortcut)); + }); +} + +class HostShortcutsResponseHandler extends ResponseHandler { + public validate(response: HostShortcutsResponse): boolean { + return response && Array.isArray(response.shortcuts) && Array.isArray(response.overridableShortcuts); + } + + public deserialize(response: HostShortcutsResponse): HostShortcutsResponse { + return response; + } +} +/** + * register a handler to be called when shortcuts are updated in the host. + */ +function registerOnHostShortcutChangedHandler(handler: (hostShortcuts: HostShortcutsResponse) => void): void { + registerHandler( + getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_HostShortcutChanged), + ApiName.ShortcutRelay_HostShortcutChanged, + handler, + ); +} + +function keydownHandler(event: KeyboardEvent): void { + // Skip if the event target is within an element that has the `data-disable-shortcuts-forwarding` attribute + if ((event.target as HTMLElement).closest(`[${DISABLE_SHORTCUT_FORWARDING_ATTRIBUTE}]`)) { + return; + } + + const { matchedShortcut, isOverridable } = isMatchingShortcut(hostShortcuts, event); + + if (!matchedShortcut) { + return; // ignore unrelated events + } + + if (isOverridable && overridableShortcutHandler) { + const shouldOverride = overridableShortcutHandler(event, { matchedShortcut }); + if (shouldOverride) { + return; // Do not forward shortcut to host + } + } + + /* Forward shortcut to host */ + const payload = new SerializableKeyboardEvent(event); + + callFunctionInHost( + ApiName.ShortcutRelay_ForwardShortcutEvent, + [payload], + getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_ForwardShortcutEvent), + ); + + event.preventDefault(); + event.stopImmediatePropagation(); +} + +/* ------------------------------------------------------------------ */ +/* In-memory */ +/* ------------------------------------------------------------------ */ +/** + * @hidden + * @internal + * Stores the shortcuts that can be overridden by the app. + */ +const overridableShortcuts: Set = new Set(); + +/** + * @hidden + * @internal + * Stores the shortcuts that are enabled in host. + * This set is populated when the host sends the list of enabled shortcuts. + */ +const hostShortcuts: Set = new Set(); + +/** + * @hidden + * @internal + * Stores the handler for overridable shortcuts. + */ +let overridableShortcutHandler: OverridableShortcutHandler | undefined = undefined; + +/** + * @hidden + * @internal + * Flag to indicate if the shortcut relay capability has been enabled, so that we do not register the event listener multiple times. + */ +let isShortcutRelayCapabilityEnabled = false; + +/* ------------------------------------------------------------------ */ +/* API */ +/* ------------------------------------------------------------------ */ + +/** + * Replace the current overridable-shortcut handler. + * + * • Pass `undefined` to remove an existing handler. + * • Returns the previous handler so callers can restore it if needed. + * + * @beta + */ +export function setOverridableShortcutHandler( + handler: OverridableShortcutHandler | undefined, +): OverridableShortcutHandler | undefined { + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + const previous = overridableShortcutHandler; + overridableShortcutHandler = handler; + return previous; +} + +/** + * Reset the state of the shortcut relay capability. + * This is useful for tests to ensure a clean state. + * + * @beta + */ +export function resetIsShortcutRelayCapabilityEnabled(): void { + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + isShortcutRelayCapabilityEnabled = false; + hostShortcuts.clear(); + overridableShortcuts.clear(); + overridableShortcutHandler = undefined; + document.removeEventListener('keydown', keydownHandler, { capture: true }); +} + +/** + * Enable capability to support host shortcuts. + * + * @beta + */ +export async function enableShortcutRelayCapability(): Promise { + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + /* 1. Ask host for the list of enabled shortcuts */ + const response = await callFunctionInHostAndHandleResponse( + ApiName.ShortcutRelay_GetHostShortcuts, + [], + new HostShortcutsResponseHandler(), + getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_GetHostShortcuts), + ); + updateHostShortcuts(response); + + /* 2. Global key-down handler */ + if (!isShortcutRelayCapabilityEnabled) { + document.addEventListener('keydown', keydownHandler, { capture: true }); + } + isShortcutRelayCapabilityEnabled = true; + + /* 3. Register handler for host shortcut updates */ + registerOnHostShortcutChangedHandler((hostShortcuts: HostShortcutsResponse) => { + updateHostShortcuts(hostShortcuts); + }); +} + +/** + * Checks if shortcutRelay capability is supported by the host + * @returns boolean to represent whether the shortcutRelay capability is supported + * + * @throws Error if {@link app.initialize} has not successfully completed + * + * @beta + */ +export function isSupported(): boolean { + return ensureInitialized(runtime) && runtime.supports.shortcutRelay ? true : false; +} + +/** + * Allow apps to define zones where shortcuts should not be forwarded to the host. + * This is useful for input fields for password where shortcuts should not trigger host actions. + * + * @beta + */ +export const DISABLE_SHORTCUT_FORWARDING_ATTRIBUTE = 'data-disable-shortcuts-forwarding'; diff --git a/packages/teams-js/test/public/shortcutRelay.spec.ts b/packages/teams-js/test/public/shortcutRelay.spec.ts new file mode 100644 index 0000000000..ffc2de7516 --- /dev/null +++ b/packages/teams-js/test/public/shortcutRelay.spec.ts @@ -0,0 +1,125 @@ +import { errorLibraryNotInitialized } from '../../src/internal/constants'; +import { GlobalVars } from '../../src/internal/globalVars'; +import { DOMMessageEvent } from '../../src/internal/interfaces'; +import { ApiName } from '../../src/internal/telemetry'; +import * as app from '../../src/public/app/app'; +import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants'; +import { latestRuntimeApiVersion } from '../../src/public/runtime'; +import * as shortcutRelay from '../../src/public/shortcutRelay'; +import { Utils } from '../utils'; + +describe('shortcutRelay capability', () => { + describe('frameless', () => { + let utils: Utils = new Utils(); + beforeEach(() => { + utils = new Utils(); + utils.mockWindow.parent = undefined; + utils.messages = []; + GlobalVars.isFramelessWindow = false; + }); + afterEach(() => { + app._uninitialize(); + GlobalVars.isFramelessWindow = false; + jest.resetModules(); + shortcutRelay.resetIsShortcutRelayCapabilityEnabled(); + }); + + describe('isSupported()', () => { + it('returns false when runtime says it is not supported', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 1, supports: {} }); + expect(shortcutRelay.isSupported()).toBeFalsy(); + }); + + it('returns true when runtime says it is supported', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 1, supports: { shortcutRelay: {} } }); + expect(shortcutRelay.isSupported()).toBeTruthy(); + }); + + it('throws before initialization', () => { + utils.uninitializeRuntimeConfig(); + expect(() => shortcutRelay.isSupported()).toThrowError(new Error(errorLibraryNotInitialized)); + }); + }); + + describe('enableShortcutRelayCapability()', () => { + it('should reject before initialization', async () => { + await expect(shortcutRelay.enableShortcutRelayCapability()).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(shortcutRelay.enableShortcutRelayCapability()).rejects.toEqual(errorNotSupportedOnPlatform); + }); + + it('forwards a matching non-overridden shortcut to host', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { shortcutRelay: {} } }); + + shortcutRelay.enableShortcutRelayCapability(); + + // simulate host response with shortcuts + const response = { + shortcuts: ['ctrl+s'], + overridableShortcuts: [], + }; + const request = utils.findMessageByFunc(ApiName.ShortcutRelay_GetHostShortcuts); + utils.respondToFramelessMessage({ + data: { id: request?.id, args: [response] }, + } as DOMMessageEvent); + + // fire keydown in next animation frame + await new Promise((resolve) => setTimeout(resolve, 0)); + const evt = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }); + document.body.dispatchEvent(evt); + + const fwd = utils.findMessageByFunc(ApiName.ShortcutRelay_ForwardShortcutEvent); + expect(fwd).not.toBeNull(); + }); + + it('gives app chance to consume overridable shortcut', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { shortcutRelay: {} } }); + + const handler = jest.fn(() => true); // consume event + shortcutRelay.setOverridableShortcutHandler(handler); + shortcutRelay.enableShortcutRelayCapability(); + + const response = { + shortcuts: ['ctrl+p'], + overridableShortcuts: ['ctrl+p'], + }; + const request = utils.findMessageByFunc(ApiName.ShortcutRelay_GetHostShortcuts); + utils.respondToFramelessMessage({ + data: { id: request?.id, args: [response] }, + } as DOMMessageEvent); + + // fire keydown in next animation frame + await new Promise((resolve) => setTimeout(resolve, 0)); + const evt = new KeyboardEvent('keydown', { key: 'p', ctrlKey: true, bubbles: true }); + document.body.dispatchEvent(evt); + + expect(handler).toHaveBeenCalled(); + const fwd = utils.findMessageByFunc(ApiName.ShortcutRelay_ForwardShortcutEvent); + expect(fwd).toBeNull(); // consumed, so not forwarded + }); + }); + + describe('setOverridableShortcutHandler()', () => { + it('replaces and returns previous handler', () => { + const noop = (): boolean => true; + const prev = shortcutRelay.setOverridableShortcutHandler(noop); + expect(prev).toBeUndefined(); + + const next = (): boolean => false; + const old = shortcutRelay.setOverridableShortcutHandler(next); + expect(old).toBe(noop); + }); + }); + }); +});