diff --git a/.vscode/settings.json b/.vscode/settings.json index 1ef4602f7f..e63df634d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "cSpell.words": ["adal", "frameless", "ipados", "teamspace", "uninitialize", "xvfb"], + "cSpell.words": ["adal", "frameless", "ipados", "teamsjs", "teamspace", "uninitialize", "xvfb"], "editor.defaultFormatter": "esbenp.prettier-vscode", "eslint.workingDirectories": [ "./apps/ssr-test-app/", diff --git a/change/@microsoft-teams-js-120060c9-a050-4da3-a0a5-2323e3a78d10.json b/change/@microsoft-teams-js-120060c9-a050-4da3-a0a5-2323e3a78d10.json new file mode 100644 index 0000000000..7670ddd01a --- /dev/null +++ b/change/@microsoft-teams-js-120060c9-a050-4da3-a0a5-2323e3a78d10.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Added logging for version on startup", + "packageName": "@microsoft/teams-js", + "email": "36546967+AE-MS@users.noreply.github.com", + "dependentChangeType": "patch" +} 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/app.ts b/packages/teams-js/src/public/app.ts index 8efd2fa1e9..67d08dbfca 100644 --- a/packages/teams-js/src/public/app.ts +++ b/packages/teams-js/src/public/app.ts @@ -750,9 +750,13 @@ export namespace app { const scriptUsageWarning = 'Today, teamsjs can only be used from a single script or you may see undefined behavior. This log line is used to help detect cases where teamsjs is loaded multiple times -- it is always written. The presence of the log itself does not indicate a multi-load situation, but multiples of these log lines will. If you would like to use teamjs from more than one script at the same time, please open an issue at https://github.com/OfficeDev/microsoft-teams-library-js/issues'; if (!currentScriptSrc || currentScriptSrc.length === 0) { - appLogger('teamsjs is being used from a script tag embedded directly in your html. %s', scriptUsageWarning); + appLogger( + 'teamsjs version %s is being used from a script tag embedded directly in your html. %s', + version, + scriptUsageWarning, + ); } else { - appLogger('teamsjs is being used from %s. %s', currentScriptSrc, scriptUsageWarning); + appLogger('teamsjs version %s is being used from %s. %s', version, currentScriptSrc, scriptUsageWarning); } } 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 () => {