From 1eb35d88ea4b5fed7f6fe992f23bfcadca157068 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Tue, 5 Aug 2025 16:54:04 +0200 Subject: [PATCH 1/8] wip --- packages/teams-js/src/internal/telemetry.ts | 2 + packages/teams-js/src/public/index.ts | 1 + packages/teams-js/src/public/runtime.ts | 1 + packages/teams-js/src/public/shortcutRelay.ts | 262 ++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 packages/teams-js/src/public/shortcutRelay.ts diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts index 0ac67330f2..8b6ac44a56 100644 --- a/packages/teams-js/src/internal/telemetry.ts +++ b/packages/teams-js/src/internal/telemetry.ts @@ -332,6 +332,8 @@ export const enum ApiName { Settings_SetValidityState = 'settings.setValidityState', Sharing_History_GetContent = 'sharing.history.getContent', Sharing_ShareWebContent = 'sharing.shareWebContent', + ShortcutRelay_RequestHostShortcuts = 'shortcutRelay.requestHostShortcuts', + ShortcutRelay_ForwardShortcutEvent = 'shortcutRelay.forwardShortcutEvent', 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..f1fd3c97cc --- /dev/null +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -0,0 +1,262 @@ +/** + * 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 { 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; +}; + +export type OverridableShortcutHandlerData = { + 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('+'); +} + +function isMatchingShortcut( + shortcuts: Shortcuts, + e: KeyboardEvent, +): { + matchedShortcut: string | undefined; + isOverridable: boolean; +} { + const pressedShortcut = eventToCanonicalShortcut(e); + const isMatching = shortcuts.has(pressedShortcut); + if (isMatching) { + return { + matchedShortcut: pressedShortcut, + isOverridable: overridableShortcuts.has(pressedShortcut), + }; + } + return { + matchedShortcut: undefined, + isOverridable: false, + }; +} + +class HostShortcutsResponseHandler extends ResponseHandler { + public validate(response: HostShortcutsResponse): boolean { + return response && Array.isArray(response.shortcuts) && Array.isArray(response.overridableShortcuts); + } + + public deserialize(response: HostShortcutsResponse): HostShortcutsResponse { + this.onSuccess(response); + return response; + } + + /** Persist the received shortcuts in memory */ + private onSuccess(response: HostShortcutsResponse): void { + hostShortcuts.clear(); + response.shortcuts.forEach((shortcut: string) => { + hostShortcuts.add(normalizeShortcut(shortcut)); + }); + overridableShortcuts.clear(); + response.overridableShortcuts.forEach((shortcut: string) => { + overridableShortcuts.add(normalizeShortcut(shortcut)); + }); + } +} + +/* ------------------------------------------------------------------ */ +/* 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; + +/* ------------------------------------------------------------------ */ +/* 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 { + const previous = overridableShortcutHandler; + overridableShortcutHandler = handler; + return previous; +} + +/** + * Enable capability to support host shortcuts. + * + * @beta + */ +export function enableShortcutRelayCapability(): void { + ensureInitialized(runtime); + + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + + /* 1. Ask host for the list of enabled shortcuts */ + callFunctionInHostAndHandleResponse( + ApiName.ShortcutRelay_RequestHostShortcuts, + [], + new HostShortcutsResponseHandler(), + getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_RequestHostShortcuts), + ); + + /* 2. Global key-down handler */ + document.addEventListener( + 'keydown', + (event: KeyboardEvent) => { + // 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(); + }, + { capture: true }, + ); +} + +/** + * 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'; From bbe29e1d5c5cf415669404677d09a982ddd00455 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Wed, 6 Aug 2025 13:23:36 +0200 Subject: [PATCH 2/8] rename --- packages/teams-js/src/internal/telemetry.ts | 2 +- packages/teams-js/src/public/shortcutRelay.ts | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts index 8b6ac44a56..242e4c77e2 100644 --- a/packages/teams-js/src/internal/telemetry.ts +++ b/packages/teams-js/src/internal/telemetry.ts @@ -332,7 +332,7 @@ export const enum ApiName { Settings_SetValidityState = 'settings.setValidityState', Sharing_History_GetContent = 'sharing.history.getContent', Sharing_ShareWebContent = 'sharing.shareWebContent', - ShortcutRelay_RequestHostShortcuts = 'shortcutRelay.requestHostShortcuts', + ShortcutRelay_GetHostShortcuts = 'shortcutRelay.getHostShortcuts', ShortcutRelay_ForwardShortcutEvent = 'shortcutRelay.forwardShortcutEvent', StageView_Open = 'stageView.open', StageView_Self_Close = 'stageView.self.close', diff --git a/packages/teams-js/src/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts index f1fd3c97cc..e5cad988af 100644 --- a/packages/teams-js/src/public/shortcutRelay.ts +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -7,6 +7,7 @@ */ 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'; @@ -163,6 +164,13 @@ const hostShortcuts: Set = new Set(); */ 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 */ /* ------------------------------------------------------------------ */ @@ -189,6 +197,11 @@ export function setOverridableShortcutHandler( * @beta */ export function enableShortcutRelayCapability(): void { + if (isShortcutRelayCapabilityEnabled) { + return; + } + isShortcutRelayCapabilityEnabled = true; + ensureInitialized(runtime); if (!isSupported()) { @@ -197,10 +210,10 @@ export function enableShortcutRelayCapability(): void { /* 1. Ask host for the list of enabled shortcuts */ callFunctionInHostAndHandleResponse( - ApiName.ShortcutRelay_RequestHostShortcuts, + ApiName.ShortcutRelay_GetHostShortcuts, [], new HostShortcutsResponseHandler(), - getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_RequestHostShortcuts), + getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_GetHostShortcuts), ); /* 2. Global key-down handler */ From dcb94b5790757eba252677ada9a71498683063c8 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Wed, 6 Aug 2025 17:12:21 +0200 Subject: [PATCH 3/8] ut --- packages/teams-js/src/public/shortcutRelay.ts | 82 ++++++----- .../test/public/shortcutRelay.spec.ts | 138 ++++++++++++++++++ 2 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 packages/teams-js/test/public/shortcutRelay.spec.ts diff --git a/packages/teams-js/src/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts index e5cad988af..6907361d6a 100644 --- a/packages/teams-js/src/public/shortcutRelay.ts +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -139,6 +139,38 @@ class HostShortcutsResponseHandler extends ResponseHandler { - // 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(); - }, - { capture: true }, - ); + document.addEventListener('keydown', keydownHandler, { capture: true }); } /** 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..bf2d53b2b4 --- /dev/null +++ b/packages/teams-js/test/public/shortcutRelay.spec.ts @@ -0,0 +1,138 @@ +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 not allow calls before initialization', () => { + expect(() => shortcutRelay.enableShortcutRelayCapability()).toThrowError(new Error(errorLibraryNotInitialized)); + }); + + it('should throw when capability not supported in runtime', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: {} }); + expect(() => shortcutRelay.enableShortcutRelayCapability()).toThrowError( + expect.objectContaining(errorNotSupportedOnPlatform), + ); + }); + + it('sends ShortcutRelay_GetHostShortcuts request and adds handler exactly once', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { shortcutRelay: {} } }); + + shortcutRelay.enableShortcutRelayCapability(); + const firstMessage = utils.findMessageByFunc(ApiName.ShortcutRelay_GetHostShortcuts); + expect(firstMessage).not.toBeNull(); + + // second call should NOT send another request + shortcutRelay.enableShortcutRelayCapability(); + const all = utils.messages.filter((m) => m.func === ApiName.ShortcutRelay_GetHostShortcuts); + expect(all.length).toBe(1); + }); + + 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); + }); + }); + }); +}); From 9001096d02d9b4ae5190cbdb9174dfc4b6a348f3 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Wed, 6 Aug 2025 19:54:59 +0200 Subject: [PATCH 4/8] apply reviews and add handler for host shortcut update --- packages/teams-js/src/internal/telemetry.ts | 1 + packages/teams-js/src/public/shortcutRelay.ts | 57 +++++++++++-------- .../test/public/shortcutRelay.spec.ts | 25 ++------ 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts index 242e4c77e2..45316a44ba 100644 --- a/packages/teams-js/src/internal/telemetry.ts +++ b/packages/teams-js/src/internal/telemetry.ts @@ -334,6 +334,7 @@ export const enum ApiName { 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/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts index 6907361d6a..b2a9f8debb 100644 --- a/packages/teams-js/src/public/shortcutRelay.ts +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -116,27 +116,36 @@ function isMatchingShortcut( }; } +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 { - this.onSuccess(response); return response; } - - /** Persist the received shortcuts in memory */ - private onSuccess(response: HostShortcutsResponse): void { - hostShortcuts.clear(); - response.shortcuts.forEach((shortcut: string) => { - hostShortcuts.add(normalizeShortcut(shortcut)); - }); - overridableShortcuts.clear(); - response.overridableShortcuts.forEach((shortcut: string) => { - overridableShortcuts.add(normalizeShortcut(shortcut)); - }); - } +} +/** + * 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 { @@ -242,28 +251,30 @@ export function resetIsShortcutRelayCapabilityEnabled(): void { * * @beta */ -export function enableShortcutRelayCapability(): void { - if (isShortcutRelayCapabilityEnabled) { - return; - } - isShortcutRelayCapabilityEnabled = true; - - ensureInitialized(runtime); - +export async function enableShortcutRelayCapability(): Promise { if (!isSupported()) { throw errorNotSupportedOnPlatform; } /* 1. Ask host for the list of enabled shortcuts */ - callFunctionInHostAndHandleResponse( + const response = await callFunctionInHostAndHandleResponse( ApiName.ShortcutRelay_GetHostShortcuts, [], new HostShortcutsResponseHandler(), getApiVersionTag(ApiVersionNumber.V_2, ApiName.ShortcutRelay_GetHostShortcuts), ); + updateHostShortcuts(response); /* 2. Global key-down handler */ - document.addEventListener('keydown', keydownHandler, { capture: true }); + if (!isShortcutRelayCapabilityEnabled) { + document.addEventListener('keydown', keydownHandler, { capture: true }); + } + isShortcutRelayCapabilityEnabled = true; + + /* 3. Register handler for host shortcut updates */ + registerOnHostShortcutChangedHandler((hostShortcuts: HostShortcutsResponse) => { + updateHostShortcuts(hostShortcuts); + }); } /** diff --git a/packages/teams-js/test/public/shortcutRelay.spec.ts b/packages/teams-js/test/public/shortcutRelay.spec.ts index bf2d53b2b4..ffc2de7516 100644 --- a/packages/teams-js/test/public/shortcutRelay.spec.ts +++ b/packages/teams-js/test/public/shortcutRelay.spec.ts @@ -44,30 +44,17 @@ describe('shortcutRelay capability', () => { }); describe('enableShortcutRelayCapability()', () => { - it('should not allow calls before initialization', () => { - expect(() => shortcutRelay.enableShortcutRelayCapability()).toThrowError(new Error(errorLibraryNotInitialized)); - }); - - it('should throw when capability not supported in runtime', async () => { - await utils.initializeWithContext(FrameContexts.content); - utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: {} }); - expect(() => shortcutRelay.enableShortcutRelayCapability()).toThrowError( - expect.objectContaining(errorNotSupportedOnPlatform), + it('should reject before initialization', async () => { + await expect(shortcutRelay.enableShortcutRelayCapability()).rejects.toThrowError( + new Error(errorLibraryNotInitialized), ); }); - it('sends ShortcutRelay_GetHostShortcuts request and adds handler exactly once', async () => { + it('should reject when capability not supported in runtime', async () => { await utils.initializeWithContext(FrameContexts.content); - utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: { shortcutRelay: {} } }); - - shortcutRelay.enableShortcutRelayCapability(); - const firstMessage = utils.findMessageByFunc(ApiName.ShortcutRelay_GetHostShortcuts); - expect(firstMessage).not.toBeNull(); + utils.setRuntimeConfig({ apiVersion: latestRuntimeApiVersion, supports: {} }); - // second call should NOT send another request - shortcutRelay.enableShortcutRelayCapability(); - const all = utils.messages.filter((m) => m.func === ApiName.ShortcutRelay_GetHostShortcuts); - expect(all.length).toBe(1); + await expect(shortcutRelay.enableShortcutRelayCapability()).rejects.toEqual(errorNotSupportedOnPlatform); }); it('forwards a matching non-overridden shortcut to host', async () => { From 50e32f3f5efa8c9b7b9883519cb6431430f7e6bf Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Thu, 7 Aug 2025 14:26:44 +0200 Subject: [PATCH 5/8] add test app --- .../src/components/ShortcutRelayAPIs.tsx | 55 +++++++++++++++++++ apps/teams-test-app/src/pages/TestApp.tsx | 2 + packages/teams-js/src/public/shortcutRelay.ts | 8 +++ 3 files changed, 65 insertions(+) create mode 100644 apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx 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..aa1b10976d --- /dev/null +++ b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx @@ -0,0 +1,55 @@ +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', + onClick: async () => { + await shortcutRelay.enableShortcutRelayCapability(); + 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/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts index b2a9f8debb..413d8c4768 100644 --- a/packages/teams-js/src/public/shortcutRelay.ts +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -227,6 +227,10 @@ let isShortcutRelayCapabilityEnabled = false; export function setOverridableShortcutHandler( handler: OverridableShortcutHandler | undefined, ): OverridableShortcutHandler | undefined { + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + const previous = overridableShortcutHandler; overridableShortcutHandler = handler; return previous; @@ -239,6 +243,10 @@ export function setOverridableShortcutHandler( * @beta */ export function resetIsShortcutRelayCapabilityEnabled(): void { + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + isShortcutRelayCapabilityEnabled = false; hostShortcuts.clear(); overridableShortcuts.clear(); From 0eb56c537a64e76f636b9bb508d00347d688f024 Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Thu, 7 Aug 2025 16:00:12 +0200 Subject: [PATCH 6/8] filter valid shortcut --- packages/teams-js/src/public/shortcutRelay.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/teams-js/src/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts index 413d8c4768..d18cf01c8f 100644 --- a/packages/teams-js/src/public/shortcutRelay.ts +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -95,6 +95,15 @@ function eventToCanonicalShortcut(e: KeyboardEvent): string { .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, @@ -102,14 +111,17 @@ function isMatchingShortcut( matchedShortcut: string | undefined; isOverridable: boolean; } { - const pressedShortcut = eventToCanonicalShortcut(e); - const isMatching = shortcuts.has(pressedShortcut); - if (isMatching) { - return { - matchedShortcut: pressedShortcut, - isOverridable: overridableShortcuts.has(pressedShortcut), - }; + 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, From 141a07df8768132a4315a937dc735f0be981181c Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Mon, 11 Aug 2025 13:17:40 +0200 Subject: [PATCH 7/8] add doc --- packages/teams-js/src/public/shortcutRelay.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/teams-js/src/public/shortcutRelay.ts b/packages/teams-js/src/public/shortcutRelay.ts index d18cf01c8f..81a9fd7b56 100644 --- a/packages/teams-js/src/public/shortcutRelay.ts +++ b/packages/teams-js/src/public/shortcutRelay.ts @@ -26,7 +26,14 @@ type HostShortcutsResponse = { 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; }; From b7c853064d0270822ba5d20a29097990cbd8c33b Mon Sep 17 00:00:00 2001 From: YuanboXue-Amber Date: Mon, 11 Aug 2025 16:20:37 +0200 Subject: [PATCH 8/8] update integration test to trigger shortcut --- apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx index aa1b10976d..d575ed7fcc 100644 --- a/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx +++ b/apps/teams-test-app/src/components/ShortcutRelayAPIs.tsx @@ -14,9 +14,10 @@ const CheckShortcutRelayCapability = (): React.ReactElement => const EnableShortcutRelayCapability = (): React.ReactElement => ApiWithoutInput({ name: 'shortcutRelay_enableShortcutRelayCapability', - title: 'Enable Shortcut Relay Capability', + 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'; }, });