diff --git a/change/@microsoft-teams-js-3c6d20f0-0715-499f-8835-457526329b67.json b/change/@microsoft-teams-js-3c6d20f0-0715-499f-8835-457526329b67.json new file mode 100644 index 0000000000..9102be6541 --- /dev/null +++ b/change/@microsoft-teams-js-3c6d20f0-0715-499f-8835-457526329b67.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Updated `pages.navigateToApp` to now optionally accept a more type-safe input object", + "packageName": "@microsoft/teams-js", + "email": "36546967+AE-MS@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/teams-js/src/internal/utils.ts b/packages/teams-js/src/internal/utils.ts index 03b12c82e8..f3f460803b 100644 --- a/packages/teams-js/src/internal/utils.ts +++ b/packages/teams-js/src/internal/utils.ts @@ -272,16 +272,16 @@ export function runWithTimeout( * @internal * Limited to Microsoft-internal use */ -export function createTeamsAppLink(params: pages.NavigateToAppParams): string { +export function createTeamsAppLink(params: pages.AppNavigationParameters): string { const url = new URL( 'https://teams.microsoft.com/l/entity/' + - encodeURIComponent(params.appId) + + encodeURIComponent(params.appId.toString()) + '/' + encodeURIComponent(params.pageId), ); if (params.webUrl) { - url.searchParams.append('webUrl', params.webUrl); + url.searchParams.append('webUrl', params.webUrl.toString()); } if (params.chatId || params.channelId || params.subPageId) { url.searchParams.append( diff --git a/packages/teams-js/src/public/appId.ts b/packages/teams-js/src/public/appId.ts index 8c9b00b0fa..7ffa7ff76f 100644 --- a/packages/teams-js/src/public/appId.ts +++ b/packages/teams-js/src/public/appId.ts @@ -8,7 +8,8 @@ import { validateStringAsAppId } from '../internal/appIdValidation'; * However, there are some older internal/hard-coded apps which violate this schema and use names like * com.microsoft.teamspace.tab.youtube. For compatibility with these legacy apps, we unfortunately cannot * securely and completely validate app ids as UUIDs. Based on this, the validation is limited to checking - * for script tags, length, and non-printable characters. + * for script tags, length, and non-printable characters. Validation will be updated in the future to ensure + * the app id is a valid UUID as legacy apps update. */ export class AppId { /** diff --git a/packages/teams-js/src/public/pages.ts b/packages/teams-js/src/public/pages.ts index 4d0f0b1d90..fff140840d 100644 --- a/packages/teams-js/src/public/pages.ts +++ b/packages/teams-js/src/public/pages.ts @@ -13,6 +13,7 @@ import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemet import { isNullOrUndefined } from '../internal/typeCheckUtilities'; import { createTeamsAppLink } from '../internal/utils'; import { prefetchOriginsFromCDN } from '../internal/validOrigins'; +import { AppId } from '../public/appId'; import { appInitializeHelper } from './app'; import { errorNotSupportedOnPlatform, FrameContexts } from './constants'; import { FrameInfo, ShareDeepLinkParameters, TabInformation, TabInstance, TabInstanceParameters } from './interfaces'; @@ -383,8 +384,9 @@ export namespace pages { * * @param params Parameters for the navigation * @returns a `Promise` that will resolve if the navigation was successful or reject if it was not + * @throws `Error` if the app ID is not valid or `params.webUrl` is defined but not a valid URL */ - export function navigateToApp(params: NavigateToAppParams): Promise { + export function navigateToApp(params: AppNavigationParameters | NavigateToAppParams): Promise { return new Promise((resolve) => { ensureInitialized( runtime, @@ -399,10 +401,17 @@ export namespace pages { throw errorNotSupportedOnPlatform; } const apiVersionTag: string = getApiVersionTag(pagesTelemetryVersionNumber, ApiName.Pages_NavigateToApp); + if (runtime.isLegacyTeams) { - resolve(sendAndHandleStatusAndReason(apiVersionTag, 'executeDeepLink', createTeamsAppLink(params))); + const typeSafeParameters: AppNavigationParameters = !isAppNavigationParametersObject(params) + ? convertNavigateToAppParamsToAppNavigationParameters(params) + : params; + resolve(sendAndHandleStatusAndReason(apiVersionTag, 'executeDeepLink', createTeamsAppLink(typeSafeParameters))); } else { - resolve(sendAndHandleStatusAndReason(apiVersionTag, 'pages.navigateToApp', params)); + const serializedParameters: NavigateToAppParams = isAppNavigationParametersObject(params) + ? convertAppNavigationParametersToNavigateToAppParams(params) + : params; + resolve(sendAndHandleStatusAndReason(apiVersionTag, 'pages.navigateToApp', serializedParameters)); } }); } @@ -452,7 +461,10 @@ export namespace pages { } /** - * Parameters for the NavigateToApp API + * @deprecated + * This interface has been deprecated in favor of a more type-safe interface using {@link pages.AppNavigationParameters} + * + * Parameters for the {@link pages.navigateToApp} function */ export interface NavigateToAppParams { /** @@ -488,6 +500,44 @@ export namespace pages { chatId?: string; } + /** + * Type-safer version of parameters for the {@link pages.navigateToApp} function + */ + export interface AppNavigationParameters { + /** + * ID of the app to navigate to + */ + appId: AppId; + + /** + * Developer-defined ID of the page to navigate to within the app (formerly called `entityId`) + */ + pageId: string; + + /** + * Fallback URL to open if the navigation cannot be completed within the host (e.g., if the target app is not installed) + */ + webUrl?: URL; + + /** + * Developer-defined ID describing the content to navigate to within the page. This ID is passed to the application + * via the {@link app.PageInfo.subPageId} property on the {@link app.Context} object (retrieved by calling {@link app.getContext}) + */ + subPageId?: string; + + /** + * For apps installed as a channel tab, this ID can be supplied to indicate in which Teams channel the app should be opened + * This property has no effect in hosts where apps cannot be opened in channels + */ + channelId?: string; + + /** + * Optional ID of the chat or meeting where the app should be opened + * This property has no effect in hosts where apps cannot be opened in chats or meetings + */ + chatId?: string; + } + /** * Provides APIs for querying and navigating between contextual tabs of an application. Unlike personal tabs, * contextual tabs are pages associated with a specific context, such as channel or chat. @@ -1197,3 +1247,29 @@ export namespace pages { } } } + +export function isAppNavigationParametersObject( + obj: pages.AppNavigationParameters | pages.NavigateToAppParams, +): obj is pages.AppNavigationParameters { + return obj.appId instanceof AppId; +} + +export function convertNavigateToAppParamsToAppNavigationParameters( + params: pages.NavigateToAppParams, +): pages.AppNavigationParameters { + return { + ...params, + appId: new AppId(params.appId), + webUrl: params.webUrl ? new URL(params.webUrl) : undefined, + }; +} + +export function convertAppNavigationParametersToNavigateToAppParams( + params: pages.AppNavigationParameters, +): pages.NavigateToAppParams { + return { + ...params, + appId: params.appId.toString(), + webUrl: params.webUrl ? params.webUrl.toString() : undefined, + }; +} diff --git a/packages/teams-js/test/internal/utils.spec.ts b/packages/teams-js/test/internal/utils.spec.ts index ef4f609dc9..740745de14 100644 --- a/packages/teams-js/test/internal/utils.spec.ts +++ b/packages/teams-js/test/internal/utils.spec.ts @@ -8,7 +8,7 @@ import { validateUuid, } from '../../src/internal/utils'; import { UUID } from '../../src/internal/uuidObject'; -import { pages } from '../../src/public'; +import { AppId, pages } from '../../src/public'; import { ClipboardSupportedMimeType } from '../../src/public/interfaces'; describe('utils', () => { @@ -24,26 +24,26 @@ describe('utils', () => { }); describe('createTeamsAppLink', () => { it('builds a basic URL with an appId and pageId', () => { - const params: pages.NavigateToAppParams = { - appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194', + const params: pages.AppNavigationParameters = { + appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'), pageId: 'tasklist123', }; const expected = 'https://teams.microsoft.com/l/entity/fe4a8eba-2a31-4737-8e33-e5fae6fee194/tasklist123'; expect(createTeamsAppLink(params)).toBe(expected); }); it('builds a URL with a webUrl parameter', () => { - const params: pages.NavigateToAppParams = { - appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194', + const params: pages.AppNavigationParameters = { + appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'), pageId: 'tasklist123', - webUrl: 'https://tasklist.example.com/123', + webUrl: new URL('https://tasklist.example.com/123'), }; const expected = 'https://teams.microsoft.com/l/entity/fe4a8eba-2a31-4737-8e33-e5fae6fee194/tasklist123?webUrl=https%3A%2F%2Ftasklist.example.com%2F123'; expect(createTeamsAppLink(params)).toBe(expected); }); it('builds a URL with a subPageUrl parameter', () => { - const params: pages.NavigateToAppParams = { - appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194', + const params: pages.AppNavigationParameters = { + appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'), pageId: 'tasklist123', subPageId: 'task456', }; @@ -52,8 +52,8 @@ describe('utils', () => { expect(createTeamsAppLink(params)).toBe(expected); }); it('builds a URL with a channelId parameter', () => { - const params: pages.NavigateToAppParams = { - appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194', + const params: pages.AppNavigationParameters = { + appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'), pageId: 'tasklist123', channelId: '19:cbe3683f25094106b826c9cada3afbe0@thread.skype', }; @@ -63,8 +63,8 @@ describe('utils', () => { }); it('builds a URL with a chatId parameter', () => { - const params: pages.NavigateToAppParams = { - appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194', + const params: pages.AppNavigationParameters = { + appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'), pageId: 'tasklist123', chatId: '19:cbe3683f25094106b826c9cada3afbe0@thread.skype', }; @@ -73,10 +73,10 @@ describe('utils', () => { expect(createTeamsAppLink(params)).toBe(expected); }); it('builds a URL with all optional properties', () => { - const params: pages.NavigateToAppParams = { - appId: 'fe4a8eba-2a31-4737-8e33-e5fae6fee194', + const params: pages.AppNavigationParameters = { + appId: new AppId('fe4a8eba-2a31-4737-8e33-e5fae6fee194'), pageId: 'tasklist123', - webUrl: 'https://tasklist.example.com/123', + webUrl: new URL('https://tasklist.example.com/123'), channelId: '19:cbe3683f25094106b826c9cada3afbe0@thread.skype', subPageId: 'task456', }; diff --git a/packages/teams-js/test/public/pages.spec.ts b/packages/teams-js/test/public/pages.spec.ts index ff86bb9a4a..9301df2bcb 100644 --- a/packages/teams-js/test/public/pages.spec.ts +++ b/packages/teams-js/test/public/pages.spec.ts @@ -6,7 +6,12 @@ import { getGenericOnCompleteHandler } from '../../src/internal/utils'; import { app } from '../../src/public/app'; import { errorNotSupportedOnPlatform, FrameContexts } from '../../src/public/constants'; import { FrameInfo, ShareDeepLinkParameters, TabInstance, TabInstanceParameters } from '../../src/public/interfaces'; -import { pages } from '../../src/public/pages'; +import { + convertAppNavigationParametersToNavigateToAppParams, + convertNavigateToAppParamsToAppNavigationParameters, + isAppNavigationParametersObject, + pages, +} from '../../src/public/pages'; import { latestRuntimeApiVersion } from '../../src/public/runtime'; import { version } from '../../src/public/version'; import { @@ -451,6 +456,11 @@ describe('Testing pages module', () => { subPageId: 'task456', }; + const typeSafeAppNavigationParams: pages.AppNavigationParameters = + convertNavigateToAppParamsToAppNavigationParameters(navigateToAppParams); + const typeSafeAppNavigationParamsWithChat: pages.AppNavigationParameters = + convertNavigateToAppParamsToAppNavigationParameters(navigateToAppParamsWithChat); + it('pages.navigateToApp should not allow calls before initialization', async () => { await expect(pages.navigateToApp(navigateToAppParams)).rejects.toThrowError( new Error(errorLibraryNotInitialized), @@ -489,7 +499,9 @@ describe('Testing pages module', () => { await expect(promise).resolves.toBe(undefined); }); - it('pages.navigateToApp should successfully send the navigateToApp message', async () => { + async function validateNavigateToAppMessage( + navigateToAppParams: pages.NavigateToAppParams | pages.AppNavigationParameters, + ) { await utils.initializeWithContext(context); utils.setRuntimeConfig({ apiVersion: 1, supports: { pages: {} } }); @@ -500,14 +512,26 @@ describe('Testing pages module', () => { navigateToAppMessage, 'pages.navigateToApp', MatcherType.ToStrictEqual, - navigateToAppParams, + isAppNavigationParametersObject(navigateToAppParams) + ? convertAppNavigationParametersToNavigateToAppParams(navigateToAppParams) + : navigateToAppParams, ); await utils.respondToMessage(navigateToAppMessage!, true); await promise; + } + + it('pages.navigateToApp should successfully send the navigateToApp message using serialized parameter', async () => { + validateNavigateToAppMessage(navigateToAppParams); }); - it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients', async () => { + it('pages.navigateToApp should successfully send the navigateToApp message using type-safe parameter', async () => { + validateNavigateToAppMessage(typeSafeAppNavigationParams); + }); + + async function validateNavigateToAppMessageForLegacyTeams( + navigateToAppParams: pages.NavigateToAppParams | pages.AppNavigationParameters, + ) { await utils.initializeWithContext(context); utils.setRuntimeConfig({ apiVersion: 1, @@ -529,9 +553,19 @@ describe('Testing pages module', () => { await utils.respondToMessage(executeDeepLinkMessage!, true); await promise; + } + + it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients using a serialized parameter', async () => { + validateNavigateToAppMessageForLegacyTeams(navigateToAppParams); + }); + + it('pages.navigateToApp should successfully send an executeDeepLink message for legacy teams clients using a type-safe parameter', async () => { + validateNavigateToAppMessageForLegacyTeams(typeSafeAppNavigationParams); }); - it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients', async () => { + async function validateNavigateToAppMessageForLegacyTeamsWithChat( + navigateToAppParamsWithChat: pages.NavigateToAppParams | pages.AppNavigationParameters, + ) { await utils.initializeWithContext(context); utils.setRuntimeConfig({ apiVersion: 1, @@ -553,6 +587,13 @@ describe('Testing pages module', () => { await utils.respondToMessage(executeDeepLinkMessage!, true); await promise; + } + + it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients using serialized parameter', async () => { + validateNavigateToAppMessageForLegacyTeamsWithChat(navigateToAppParamsWithChat); + }); + it('pages.navigateToApp should successfully send an executeDeepLink message with chat id for legacy teams clients using type-safe parameter', async () => { + validateNavigateToAppMessageForLegacyTeamsWithChat(typeSafeAppNavigationParamsWithChat); }); } else { it(`pages.navigateToApp should not allow calls from ${context} context`, async () => {