diff --git a/.changeset/afraid-dingos-arrive.md b/.changeset/afraid-dingos-arrive.md new file mode 100644 index 000000000..c1fa58748 --- /dev/null +++ b/.changeset/afraid-dingos-arrive.md @@ -0,0 +1,7 @@ +--- +"@tma.js/parsing": minor +--- + +- Add new types of errors +- Add ability to specify parser type name +- Improve schema parsing be throwing more accurate errors diff --git a/.changeset/angry-dragons-develop.md b/.changeset/angry-dragons-develop.md new file mode 100644 index 000000000..07fff83d1 --- /dev/null +++ b/.changeset/angry-dragons-develop.md @@ -0,0 +1,11 @@ +--- +"@tma.js/parsing": major +--- + +This is the first `@tma.js/parsing` package major update. + +- Implement `ParsingError` and `ParseSchemaFieldError` classes to provide better understanding of the error +- Allow specifying parser type name to improve error messages +- Fix minor problems in throwing errors +- Actualize tests +- Set type names for all built-in types diff --git a/.changeset/gorgeous-moose-reflect.md b/.changeset/gorgeous-moose-reflect.md new file mode 100644 index 000000000..3aeb865f5 --- /dev/null +++ b/.changeset/gorgeous-moose-reflect.md @@ -0,0 +1,5 @@ +--- +"@tma.js/bridge": patch +--- + +- Add `logger.log` call in postEvent diff --git a/.changeset/lemon-foxes-obey.md b/.changeset/lemon-foxes-obey.md new file mode 100644 index 000000000..8595f38ec --- /dev/null +++ b/.changeset/lemon-foxes-obey.md @@ -0,0 +1,5 @@ +--- +"@tma.js/init-data": patch +--- + +- Specify parsers type names diff --git a/.changeset/proud-mails-march.md b/.changeset/proud-mails-march.md new file mode 100644 index 000000000..5520997ff --- /dev/null +++ b/.changeset/proud-mails-march.md @@ -0,0 +1,7 @@ +--- +"@tma.js/theme-params": minor +--- + +- Implement new 6.10 palette keys +- Add parser type name +- Allow specifying previous and current launch parameters in launch data computation methods diff --git a/.changeset/silent-pens-heal.md b/.changeset/silent-pens-heal.md new file mode 100644 index 000000000..0f2057f43 --- /dev/null +++ b/.changeset/silent-pens-heal.md @@ -0,0 +1,6 @@ +--- +"@tma.js/sdk": minor +--- + +- Actualize theme parameters list +- Simplify init process diff --git a/README.md b/README.md index fe0c825fd..1e0fc028e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,3 @@ -[code-badge]: https://img.shields.io/badge/source-black?logo=github - -[docs-badge]: https://img.shields.io/badge/documentation-blue?logo=gitbook&logoColor=white - -[react-badge]: https://img.shields.io/badge/React-244654?logo=react&logoColor=61DAFB - -[solid-badge]: https://img.shields.io/badge/Solid-203A59?logo=solid&logoColor=38659F - -[node-badge]: https://img.shields.io/badge/Node-1f491f?logo=node.js&logoColor=339933 - # @tma.js Mono-repository, containing all the packages, connected with comfortable and safe TypeScript diff --git a/apps/docs/.vitepress/config.mts b/apps/docs/.vitepress/config.mts index 10e20c99e..848949f46 100644 --- a/apps/docs/.vitepress/config.mts +++ b/apps/docs/.vitepress/config.mts @@ -136,6 +136,7 @@ export default defineConfig({ text: '@tma.js/init-data-node', link: '/packages/typescript/tma-js-init-data-node', }, + { text: '@tma.js/launch-params', link: '/packages/typescript/tma-js-launch-params' }, { text: '@tma.js/sdk', collapsed: true, diff --git a/apps/docs/.vitepress/theme/custom.css b/apps/docs/.vitepress/theme/custom.css new file mode 100644 index 000000000..fee52c299 --- /dev/null +++ b/apps/docs/.vitepress/theme/custom.css @@ -0,0 +1,23 @@ +@media (max-width: 768px) { + /* In mobile version we would like to move hashtag ("#") to the right side of section title. */ + .vp-doc h1, + .vp-doc h2, + .vp-doc h3, + .vp-doc h4, + .vp-doc h5, + .vp-doc h6 { + padding-right: 1em; + } + + .vp-doc h1 .header-anchor, + .vp-doc h2 .header-anchor, + .vp-doc h3 .header-anchor, + .vp-doc h4 .header-anchor, + .vp-doc h5 .header-anchor, + .vp-doc h6 .header-anchor { + left: auto; + top: auto; + bottom: 0; + margin-left: .3em; + } +} diff --git a/apps/docs/.vitepress/theme/index.ts b/apps/docs/.vitepress/theme/index.ts new file mode 100644 index 000000000..268bcb312 --- /dev/null +++ b/apps/docs/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme'; +import './custom.css'; + +export default DefaultTheme; \ No newline at end of file diff --git a/apps/docs/packages/typescript/tma-js-launch-params.md b/apps/docs/packages/typescript/tma-js-launch-params.md new file mode 100644 index 000000000..c7294234f --- /dev/null +++ b/apps/docs/packages/typescript/tma-js-launch-params.md @@ -0,0 +1,204 @@ +# @tma.js/launch-params + +[npm-link]: https://npmjs.com/package/@tma.js/launch-params + +[npm-shield]: https://img.shields.io/npm/v/@tma.js/launch-params?logo=npm + +![[npm-link]][npm-shield] + +Provides utilities to work with Telegram Mini +Apps [launch parameters](../../launch-parameters/common-information.md). + +## Installation + +::: code-group + +```bash [pnpm] +pnpm i @tma.js/launch-params +``` + +```bash [npm] +npm i @tma.js/launch-params +``` + +```bash [yarn] +yarn add @tma.js/launch-params +``` + +::: + +## Parsing + +To parse value as launch parameters, package provides method `parse` and parser `launchParams` +which is being utilized by `parse`. + +Method and parser accept query parameters presented as a string or an instance of `URLSearchParams`, +returning the `LaunchParams` interface. It throws an error if the passed data is invalid. + +::: code-group + +```typescript [Usage example] +import { parse, launchParams } from '@tma.js/launch-params'; + +const searchParams = new URLSearchParams([ + ['tgWebAppVersion', '6.7'], + ['tgWebAppPlatform', 'tdekstop'], + ['tgWebAppBotInline', '1'], + ['tgWebAppData', new URLSearchParams([ + ['query_id', 'AAHdF6IQAAAAAN0XohAOqR8k'], + ['user', JSON.stringify({ + id: 279058397, + first_name: 'Vladislav', + last_name: 'Kibenko', + username: 'vdkfrost', + language_code: 'ru', + is_premium: true, + allows_write_to_pm: true, + })], + ['auth_date', '1691441944'], + ['hash', 'abc'], + ]).toString()], + ['tgWebAppThemeParams', JSON.stringify({ + bg_color: '#17212b', + button_color: '#5288c1', + button_text_color: '#ffffff', + hint_color: '#708499', + link_color: '#6ab3f3', + secondary_bg_color: '#232e3c', + text_color: '#f5f5f5', + })], +]); + +const lp = parse(searchParams); +// or +const lp = launchParams().parse(searchParams); +``` + +```typescript [Expected result] +const result = { + botInline: true, + version: '6.7', + platform: 'tdesktop', + themeParams: { + backgroundColor: '#17212b', + buttonColor: '#5288c1', + buttonTextColor: '#ffffff', + hintColor: '#708499', + linkColor: '#6ab3f3', + secondaryBackgroundColor: '#232e3c', + textColor: '#f5f5f5', + }, + initDataRaw: 'query_id=AAHdF6IQAAAAAN0XohAOqR8k&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1691441944&hash=abc', + initData: { + queryId: 'AAHdF6IQAAAAAN0XohAOqR8k', + authDate: new Date(1691441944000), + hash: 'abc', + user: { + id: 279058397, + firstName: 'Vladislav', + lastName: 'Kibenko', + username: 'vdkfrost', + languageCode: 'ru', + isPremium: true, + allowsWriteToPm: true, + }, + }, +}; +``` + +::: + +## Serializing + +To convert the launch parameters object representation to a string, developers should use +the `serialize` function: + +```typescript +import { serialize } from '@tma.js/launch-params'; + +serialize({ + version: '6.7', + platform: 'tdesktop', + themeParams: { + backgroundColor: '#17212b', + buttonColor: '#5288c1', + buttonTextColor: '#ffffff', + hintColor: '#708499', + linkColor: '#6ab3f3', + secondaryBackgroundColor: '#232e3c', + textColor: '#f5f5f5', + }, +}); + +// Result: +// tgWebAppVersion=6.7&tgWebAppPlatform=tdesktop&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D +``` + +## Retrieving + +This package enables the extraction of launch parameters from the current environment using +the [retrieveFromLocation](#retrievefromlocation), [retrieveFromPerformance](#retrievefromperformance), +and [retrieveFromStorage](#retrievefromstorage) functions. Developer is also able +to use `retrieveLaunchData` to surely extract launch parameters and determine if current page was +reloaded. Each of these functions throws an error if the source contains invalid data. + +### `retrieveLaunchData` + +Extracts actual launch parameters and page reload flag. + +```typescript +import { retrieveLaunchData } from '@tma.js/launch-params'; + +const { launchParams, isPageReload } = retrieveLaunchData(); +``` + +::: info + +This function is more likely to be used by developers because it offers a stable way of retrieving +the actual launch parameters. + +::: + +### `retrieveFromStorage` + +Extracts launch parameters from `sessionStorage`. This method expects that launch parameters have +been saved in the `sessionStorage` via the `saveToStorage` method. + +```typescript +import { retrieveFromStorage, saveToStorage } from '@tma.js/launch-params'; + +saveToStorage({ + initData: { + authDate: new Date(16552413000), + hash: 'hash', + }, + initDataRaw: 'auth_date=16552413&hash=hash', + themeParams: {}, + version: '7.0', + platform: 'macos', +}); + +const launchParameters = retrieveFromStorage(); +``` + +### `retrieveFromLocation` + +Extracts launch parameters from `window.location.hash`: + +```typescript +import { retrieveFromLocation } from '@tma.js/launch-params'; + +const launchParameters = retrieveFromLocation(); +``` + +### `retrieveFromPerformance` + +Extracts launch parameters from `window.performance`. This function allows retrieving launch +parameters +using [performance navigation entries](https://developer.mozilla.org/en-US/docs/Web/Performance/Navigation_and_resource_timings). + +```typescript +import { retrieveFromPerformance } from '@tma.js/launch-params'; + +const launchParameters = retrieveFromPerformance(); +``` diff --git a/packages/bridge/src/events/parsing.ts b/packages/bridge/src/events/parsing.ts index e8395882b..f3c36fba5 100644 --- a/packages/bridge/src/events/parsing.ts +++ b/packages/bridge/src/events/parsing.ts @@ -4,7 +4,7 @@ import { boolean, json, rgb, - createValueParserGen, + createValueParserGenerator, } from '@tma.js/parsing'; import type { @@ -22,9 +22,11 @@ function isNullOrUndefined(value: unknown): boolean { const rgbOptional = rgb().optional(); const num = number(); -const windowWidthParser = createValueParserGen((value) => (value === null || value === undefined - ? window.innerWidth - : num.parse(value))); +const windowWidthParser = createValueParserGenerator( + (value) => (value === null || value === undefined + ? window.innerWidth + : num.parse(value)), +); /** * Parses incoming value as ThemeChangedPayload. diff --git a/packages/bridge/src/methods/postEvent.ts b/packages/bridge/src/methods/postEvent.ts index 70d3aa3e2..8080594ef 100644 --- a/packages/bridge/src/methods/postEvent.ts +++ b/packages/bridge/src/methods/postEvent.ts @@ -3,8 +3,7 @@ import { hasExternalNotify, hasWebviewProxy, } from '../env.js'; -import { targetOrigin as globalTargetOrigin } from '../globals.js'; - +import { logger, targetOrigin as globalTargetOrigin } from '../globals.js'; import type { EmptyMethodName, MethodName, @@ -30,8 +29,7 @@ export type PostEvent = typeof postEvent; * @param eventType - event name. * @param params - event parameters. * @param options - posting options. - * @throws {Error} Bridge could not determine current - * environment and possible way to send event. + * @throws {Error} Bridge could not determine current environment and possible way to send event. */ export function postEvent( eventType: E, @@ -44,8 +42,7 @@ export function postEvent( * accepts only events, which require arguments. * @param eventType - event name. * @param options - posting options. - * @throws {Error} Bridge could not determine current - * environment and possible way to send event. + * @throws {Error} Bridge could not determine current environment and possible way to send event. */ export function postEvent(eventType: EmptyMethodName, options?: PostEventOptions): void; @@ -74,6 +71,8 @@ export function postEvent( } const { targetOrigin = globalTargetOrigin() } = postOptions; + logger.log(`Calling method "${eventType}"`, eventData); + // Telegram Web. if (isIframe()) { window.parent.postMessage(JSON.stringify({ @@ -95,8 +94,7 @@ export function postEvent( return; } - // Otherwise current environment is unknown, and we are not able to send - // event. + // Otherwise current environment is unknown, and we are not able to send event. throw new Error( 'Unable to determine current environment and possible way to send event.', ); diff --git a/packages/init-data/package.json b/packages/init-data/package.json index 60bfe6a55..f29435c28 100644 --- a/packages/init-data/package.json +++ b/packages/init-data/package.json @@ -50,7 +50,8 @@ "devDependencies": { "tsconfig": "workspace:*", "eslint-config-custom": "workspace:*", - "build-utils": "workspace:*" + "build-utils": "workspace:*", + "test-utils": "workspace:*" }, "publishConfig": { "access": "public" diff --git a/packages/init-data/src/chat.ts b/packages/init-data/src/chat.ts index 1ae1209ca..23dba2ce7 100644 --- a/packages/init-data/src/chat.ts +++ b/packages/init-data/src/chat.ts @@ -13,5 +13,7 @@ export function chat() { from: 'photo_url', }, username: string().optional(), + }, { + type: 'Chat', }); } diff --git a/packages/init-data/src/initData.ts b/packages/init-data/src/initData.ts index 4830105d5..1d4652fca 100644 --- a/packages/init-data/src/initData.ts +++ b/packages/init-data/src/initData.ts @@ -37,5 +37,7 @@ export function initData() { from: 'start_param', }, user: user().optional(), + }, { + type: 'InitData', }); } diff --git a/packages/init-data/src/serialize.ts b/packages/init-data/src/serialize.ts index 79e378862..49ab5b5fa 100644 --- a/packages/init-data/src/serialize.ts +++ b/packages/init-data/src/serialize.ts @@ -38,16 +38,16 @@ function setUser(params: URLSearchParams, key: string, value: User | undefined) */ export function serialize(value: InitData): string { const { + authDate, + canSendAfter, chat, - chatType, chatInstance, + chatType, hash, - user, queryId, receiver, startParam, - canSendAfter, - authDate, + user, } = value; const params = new URLSearchParams(); diff --git a/packages/init-data/src/user.ts b/packages/init-data/src/user.ts index 92e691b9d..25c386c18 100644 --- a/packages/init-data/src/user.ts +++ b/packages/init-data/src/user.ts @@ -41,5 +41,7 @@ export function user() { from: 'photo_url', }, username: string().optional(), + }, { + type: 'User', }); } diff --git a/packages/init-data/tests/initData.ts b/packages/init-data/tests/initData.ts index ade95e74d..c11d047a5 100644 --- a/packages/init-data/tests/initData.ts +++ b/packages/init-data/tests/initData.ts @@ -1,29 +1,17 @@ import { describe, expect, it } from 'vitest'; +import { toSearchParams } from 'test-utils'; import { initData } from '../src/index.js'; -function createSearchParams(json: Record): string { - const params = new URLSearchParams(); - - Object.entries(json).forEach(([key, value]) => { - params.set( - key, - typeof value === 'object' ? JSON.stringify(value) : String(value), - ); - }); - - return params.toString(); -} - describe('initData.ts', () => { describe('initData', () => { describe('auth_date', () => { it('should throw an error in case, this property is missing', () => { - expect(() => initData().parse(createSearchParams({ hash: 'abcd' }))).toThrow(); + expect(() => initData().parse(toSearchParams({ hash: 'abcd' }))).toThrow(); }); it('should parse source property as Date and pass it to the "authDate" property', () => { - expect(initData().parse(createSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ + expect(initData().parse(toSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ authDate: new Date(1000), }); }); @@ -32,7 +20,7 @@ describe('initData.ts', () => { describe('can_send_after', () => { it('should parse source property as Date and pass it to the "canSendAfter" property', () => { expect( - initData().parse(createSearchParams({ + initData().parse(toSearchParams({ auth_date: 1, hash: 'abcd', can_send_after: 8882, @@ -46,7 +34,7 @@ describe('initData.ts', () => { describe('chat', () => { it('should parse source property as Chat and pass it to the "chat" property', () => { expect( - initData().parse(createSearchParams({ + initData().parse(toSearchParams({ auth_date: 1, hash: 'abcd', chat: { @@ -72,7 +60,7 @@ describe('initData.ts', () => { describe('hash', () => { it('should throw an error in case, this property is missing', () => { expect( - () => initData().parse(createSearchParams({ + () => initData().parse(toSearchParams({ auth_date: 1, })), ).toThrow(); @@ -80,7 +68,7 @@ describe('initData.ts', () => { it('should parse source property as string and pass it to the "hash" property', () => { expect( - initData().parse(createSearchParams({ + initData().parse(toSearchParams({ auth_date: 1, hash: 'abcd', })), @@ -99,7 +87,7 @@ describe('initData.ts', () => { describe(from, () => { it(`should parse source property as string and pass it to the "${to}" property`, () => { expect( - initData().parse(createSearchParams({ + initData().parse(toSearchParams({ auth_date: 1, hash: 'abcd', [from]: 'my custom property', @@ -115,7 +103,7 @@ describe('initData.ts', () => { describe(property, () => { it('should parse source property as User and pass it to the property with the same name', () => { expect( - initData().parse(createSearchParams({ + initData().parse(toSearchParams({ auth_date: 1, hash: 'abcd', [property]: { diff --git a/packages/init-data/tests/parse.ts b/packages/init-data/tests/parse.ts index 86a57a397..c341f6f18 100644 --- a/packages/init-data/tests/parse.ts +++ b/packages/init-data/tests/parse.ts @@ -1,29 +1,17 @@ import { describe, expect, it } from 'vitest'; +import { toSearchParams } from 'test-utils'; import { parse } from '../src/index.js'; -function createSearchParams(json: Record): string { - const params = new URLSearchParams(); - - Object.entries(json).forEach(([key, value]) => { - params.set( - key, - typeof value === 'object' ? JSON.stringify(value) : String(value), - ); - }); - - return params.toString(); -} - describe('parse.ts', () => { describe('parse', () => { describe('auth_date', () => { it('should throw an error in case, this property is missing', () => { - expect(() => parse(createSearchParams({ hash: 'abcd' }))).toThrow(); + expect(() => parse(toSearchParams({ hash: 'abcd' }))).toThrow(); }); it('should parse source property as Date and pass it to the "authDate" property', () => { - expect(parse(createSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ + expect(parse(toSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ authDate: new Date(1000), }); }); @@ -32,7 +20,7 @@ describe('parse.ts', () => { describe('can_send_after', () => { it('should parse source property as Date and pass it to the "canSendAfter" property', () => { expect( - parse(createSearchParams({ + parse(toSearchParams({ auth_date: 1, hash: 'abcd', can_send_after: 8882, @@ -46,7 +34,7 @@ describe('parse.ts', () => { describe('chat', () => { it('should parse source property as Chat and pass it to the "chat" property', () => { expect( - parse(createSearchParams({ + parse(toSearchParams({ auth_date: 1, hash: 'abcd', chat: { @@ -72,7 +60,7 @@ describe('parse.ts', () => { describe('hash', () => { it('should throw an error in case, this property is missing', () => { expect( - () => parse(createSearchParams({ + () => parse(toSearchParams({ auth_date: 1, })), ).toThrow(); @@ -80,7 +68,7 @@ describe('parse.ts', () => { it('should parse source property as string and pass it to the "hash" property', () => { expect( - parse(createSearchParams({ + parse(toSearchParams({ auth_date: 1, hash: 'abcd', })), @@ -99,7 +87,7 @@ describe('parse.ts', () => { describe(from, () => { it(`should parse source property as string and pass it to the "${to}" property`, () => { expect( - parse(createSearchParams({ + parse(toSearchParams({ auth_date: 1, hash: 'abcd', [from]: 'my custom property', @@ -115,7 +103,7 @@ describe('parse.ts', () => { describe(property, () => { it('should parse source property as User and pass it to the property with the same name', () => { expect( - parse(createSearchParams({ + parse(toSearchParams({ auth_date: 1, hash: 'abcd', [property]: { diff --git a/packages/launch-params/README.md b/packages/launch-params/README.md index e5f0f94a6..867143c54 100644 --- a/packages/launch-params/README.md +++ b/packages/launch-params/README.md @@ -1,20 +1,17 @@ # @tma.js/launch-params -[npm-badge]: https://img.shields.io/npm/v/@tma.js/launch-params?logo=npm - [npm-link]: https://npmjs.com/package/@tma.js/launch-params -[size-badge]: https://img.shields.io/bundlephobia/minzip/@tma.js/launch-params +[npm-shield]: https://img.shields.io/npm/v/@tma.js/launch-params?logo=npm -[![NPM][npm-badge]][npm-link] -![Size][size-badge] +![[npm-link]][npm-shield] -Package which contains utilities to work with Telegram Mini +Provides utilities to work with Telegram Mini Apps [launch parameters](https://docs.telegram-mini-apps.com/launch-parameters/common-information). This library is a part of TypeScript packages ecosystem around Telegram Web Apps. To see full documentation and other libraries, please, visit -[this](https://docs.telegram-mini-apps.com/) link. +[this](https://docs.telegram-mini-apps.com/packages/typescript/tma-js-launch-params) link. ## Installation diff --git a/packages/launch-params/package.json b/packages/launch-params/package.json index f8ed2e47c..f656a8140 100644 --- a/packages/launch-params/package.json +++ b/packages/launch-params/package.json @@ -51,7 +51,8 @@ "devDependencies": { "tsconfig": "workspace:*", "eslint-config-custom": "workspace:*", - "build-utils": "workspace:*" + "build-utils": "workspace:*", + "test-utils": "workspace:*" }, "publishConfig": { "access": "public" diff --git a/packages/launch-params/src/computeLaunchData.ts b/packages/launch-params/src/computeLaunchData.ts new file mode 100644 index 000000000..14aa960fa --- /dev/null +++ b/packages/launch-params/src/computeLaunchData.ts @@ -0,0 +1,95 @@ +import { retrieveFromStorage } from './storage.js'; +import { retrieveCurrent } from './retrieveCurrent.js'; +import { computePageReload } from './computePageReload.js'; +import type { LaunchData, LaunchParams } from './types.js'; + +export interface ComputeLaunchDataOptions { + /** + * Previous known launch parameters. If not passed, function attempts to extract them by + * itself. + */ + previousLaunchParams?: LaunchParams; + /** + * Currently known launch parameters. + */ + currentLaunchParams?: LaunchParams; +} + +/** + * Returns true in case, current environment is iframe. + * @see https://stackoverflow.com/a/326076 + */ +function isIframe(): boolean { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} + +/** + * Computes launch data information. Extracts both previous and current launch parameters + * to compute current list of them. Additionally, computes if page was reloaded. + */ +export function computeLaunchData(options: ComputeLaunchDataOptions = {}): LaunchData { + const { + // Retrieve launch parameters from the session storage. We consider this value as the launch + // parameters saved previously, in the previous runtime session (before the page reload). + previousLaunchParams: lpPrevious = retrieveFromStorage(), + + // Currently used launch parameters passed to the Mini App. + currentLaunchParams: lpCurrent = retrieveCurrent(), + } = options; + + const isPageReload = computePageReload(); + + if (lpPrevious) { + if (lpCurrent) { + return { + launchParams: lpCurrent, + isPageReload: isIframe() + // In iframes we should check page reload via 2 ways: + // 1. Native one via navigation entry. + // 2. By comparing raw init data representations, when the first step did not return + // explicit true. + // + // The reason is Telegram provides the horrible way of reloading current iframes which + // does not guarantee, that reload will be proceeded properly. + // Issue: https://github.com/morethanwords/tweb/issues/271 + // + // We trust isPageReload variable value only in case it is "true". Otherwise, it can be + // wrong. That's why we compare raw init data raw representations, which is unstable + // also. This will not work as expected in cases, user launches applications via + // KeyboardButton-s which can lack of init data. So, this code will not differ reload + // from the fresh start. + ? isPageReload || lpPrevious.initDataRaw === lpCurrent.initDataRaw + + // In environments different from iframe, when we have both previous and current launch + // parameters it is guaranteed that page was reloaded as long as session is created only + // for the Mini App launch and will be automatically disposed after it is closed. + : true, + }; + } + + // Explicit page reload allows us to trust previously saved launch parameters. + if (isPageReload) { + return { + launchParams: lpPrevious, + isPageReload, + }; + } + + // We can't trust previously saved launch parameters as long as we don't really know if + // current session was born due to restart. It is better to throw an error. + throw new Error('Unable to retrieve current launch parameters, which must exist.'); + } + + if (lpCurrent) { + return { + launchParams: lpCurrent, + isPageReload: false, + }; + } + + throw new Error('Unable to retrieve any launch parameters.'); +} diff --git a/packages/launch-params/src/computePageReload.ts b/packages/launch-params/src/computePageReload.ts new file mode 100644 index 000000000..42a3a44a8 --- /dev/null +++ b/packages/launch-params/src/computePageReload.ts @@ -0,0 +1,13 @@ +import { getFirstNavigationEntry } from './getFirstNavigationEntry.js'; + +/** + * Determines if current page was reloaded. + * @returns Boolean if function was able to compute any valid value. Null in case, no + * navigation entries were found. + */ +export function computePageReload(): boolean | null { + const firstNavigationEntry = getFirstNavigationEntry(); + return firstNavigationEntry + ? firstNavigationEntry.type === 'reload' + : null; +} diff --git a/packages/launch-params/src/getFirstNavigationEntry.ts b/packages/launch-params/src/getFirstNavigationEntry.ts new file mode 100644 index 000000000..a72974ccf --- /dev/null +++ b/packages/launch-params/src/getFirstNavigationEntry.ts @@ -0,0 +1,10 @@ +/** + * Returns the first navigation entry from window.performance. + * @returns First navigation entry or null, in case performance functionality is not supported + * or navigation entry was not found. + */ +export function getFirstNavigationEntry(): PerformanceNavigationTiming | null { + return ( + performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined + ) || null; +} diff --git a/packages/launch-params/src/index.ts b/packages/launch-params/src/index.ts index d38be2888..cae9849dd 100644 --- a/packages/launch-params/src/index.ts +++ b/packages/launch-params/src/index.ts @@ -1,5 +1,12 @@ +export * from './computeLaunchData.js'; +export * from './computePageReload.js'; +export * from './getFirstNavigationEntry.js'; export * from './launchParams.js'; export * from './parse.js'; +export * from './retrieveCurrent.js'; +export * from './retrieveFromLocation.js'; +export * from './retrieveFromPerformance.js'; +export * from './retrieveLaunchData.js'; export * from './serialize.js'; export * from './storage.js'; export * from './types.js'; diff --git a/packages/launch-params/src/launchParams.ts b/packages/launch-params/src/launchParams.ts index 9170529ff..5d6bcad82 100644 --- a/packages/launch-params/src/launchParams.ts +++ b/packages/launch-params/src/launchParams.ts @@ -1,4 +1,4 @@ -import { searchParams, string } from '@tma.js/parsing'; +import { boolean, searchParams, string } from '@tma.js/parsing'; import { initData } from '@tma.js/init-data'; import { themeParams } from '@tma.js/theme-params'; @@ -9,6 +9,10 @@ import type { LaunchParams } from './types.js'; */ export function launchParams() { return searchParams({ + botInline: { + type: boolean().optional(), + from: 'tgWebAppBotInline', + }, initData: { type: initData().optional(), from: 'tgWebAppData', @@ -21,6 +25,10 @@ export function launchParams() { type: string(), from: 'tgWebAppPlatform', }, + showSettings: { + type: boolean().optional(), + from: 'tgWebAppShowSettings', + }, themeParams: { type: themeParams(), from: 'tgWebAppThemeParams', @@ -29,5 +37,7 @@ export function launchParams() { type: string(), from: 'tgWebAppVersion', }, + }, { + type: 'LaunchParams', }); } diff --git a/packages/launch-params/src/parse.ts b/packages/launch-params/src/parse.ts index 576bcce77..2fb8675ff 100644 --- a/packages/launch-params/src/parse.ts +++ b/packages/launch-params/src/parse.ts @@ -4,6 +4,7 @@ import type { LaunchParams } from './types.js'; /** * Parses incoming value as launch parameters. * @param value - value to parse. + * @throws {Error} Value contains invalid data. */ export function parse(value: unknown): LaunchParams { return launchParams().parse(value); diff --git a/packages/launch-params/src/retrieveCurrent.ts b/packages/launch-params/src/retrieveCurrent.ts new file mode 100644 index 000000000..39a1760b1 --- /dev/null +++ b/packages/launch-params/src/retrieveCurrent.ts @@ -0,0 +1,27 @@ +import { retrieveFromLocation } from './retrieveFromLocation.js'; +import { retrieveFromPerformance } from './retrieveFromPerformance.js'; +import type { LaunchParams } from './types.js'; + +/** + * Attempts to retrieve launch parameters using every known way. + */ +export function retrieveCurrent(): LaunchParams | null { + // First of all, attempt to retrieve launch parameters from the window.performance as long as + // this way is considered the most stable. Nevertheless, this method can return nothing in case, + // location was changed and then page was reloaded. + try { + return retrieveFromPerformance(); + // eslint-disable-next-line no-empty + } catch (e) { + } + + // In case, usage of window.performance was unsuccessful, try to retrieve launch parameters + // from the window.location. + try { + return retrieveFromLocation(); + // eslint-disable-next-line no-empty + } catch (e) { + } + + return null; +} diff --git a/packages/launch-params/src/retrieveFromLocation.ts b/packages/launch-params/src/retrieveFromLocation.ts new file mode 100644 index 000000000..d85c71564 --- /dev/null +++ b/packages/launch-params/src/retrieveFromLocation.ts @@ -0,0 +1,10 @@ +import { parse } from './parse.js'; +import type { LaunchParams } from './types.js'; + +/** + * Attempts to extract launch parameters from the current window location hash. + * @throws {Error} window.location.hash contains invalid data. + */ +export function retrieveFromLocation(): LaunchParams { + return parse(window.location.hash.slice(1)); +} diff --git a/packages/launch-params/src/retrieveFromPerformance.ts b/packages/launch-params/src/retrieveFromPerformance.ts new file mode 100644 index 000000000..1f9c3c95d --- /dev/null +++ b/packages/launch-params/src/retrieveFromPerformance.ts @@ -0,0 +1,23 @@ +import { parse } from './parse.js'; +import { getFirstNavigationEntry } from './getFirstNavigationEntry.js'; +import type { LaunchParams } from './types.js'; + +/** + * Attempts to read launch parameters using window.performance data. + * @throws {Error} Unable to get first navigation entry. + * @throws {Error} First navigation entry does not contain hash part. + * @throws {TypeError} Unable to parse value. + */ +export function retrieveFromPerformance(): LaunchParams { + const navigationEntry = getFirstNavigationEntry(); + if (!navigationEntry) { + throw new Error('Unable to get first navigation entry.'); + } + + const hashMatch = navigationEntry.name.match(/#(.*)/); + if (!hashMatch) { + throw new Error('First navigation entry does not contain hash part.'); + } + + return parse(hashMatch[1]); +} diff --git a/packages/launch-params/src/retrieveLaunchData.ts b/packages/launch-params/src/retrieveLaunchData.ts new file mode 100644 index 000000000..dd0e7d0b1 --- /dev/null +++ b/packages/launch-params/src/retrieveLaunchData.ts @@ -0,0 +1,32 @@ +import { saveToStorage } from './storage.js'; +import { computeLaunchData, type ComputeLaunchDataOptions } from './computeLaunchData.js'; +import type { LaunchData } from './types.js'; + +const WINDOW_KEY = 'tmajsLaunchData'; + +export type RetrieveLaunchDataOptions = ComputeLaunchDataOptions; + +/** + * Returns launch data information. Function ignores passed options in case, it was already + * called. It caches the last returned value. + */ +export function retrieveLaunchData(options?: RetrieveLaunchDataOptions): LaunchData { + // Return previously cached value. + const cached = (window as any)[WINDOW_KEY]; + if (cached) { + return cached; + } + + // Get current launch data. + const launchData = computeLaunchData(options); + + // To prevent the additional computation of launch data and possible break of the code + // logic, we store this data in the window. Several calls of retrieveLaunchData will surely + // break something. + (window as any)[WINDOW_KEY] = launchData; + + // Save launch parameters in the session storage. We will need them during page reloads. + saveToStorage(launchData.launchParams); + + return launchData; +} diff --git a/packages/launch-params/src/serialize.ts b/packages/launch-params/src/serialize.ts index 5fc0282d3..3e1c1b653 100644 --- a/packages/launch-params/src/serialize.ts +++ b/packages/launch-params/src/serialize.ts @@ -7,7 +7,14 @@ import type { LaunchParams } from './types.js'; * @param value - launch parameters. */ export function serialize(value: LaunchParams): string { - const { initDataRaw, themeParams, platform, version } = value; + const { + initDataRaw, + themeParams, + platform, + version, + showSettings, + botInline, + } = value; const params = new URLSearchParams(); @@ -18,5 +25,13 @@ export function serialize(value: LaunchParams): string { params.set('tgWebAppThemeParams', serializeThemeParams(themeParams)); params.set('tgWebAppVersion', version); + if (typeof showSettings === 'boolean') { + params.set('tgWebAppShowSettings', showSettings ? '1' : '0'); + } + + if (typeof botInline === 'boolean') { + params.set('tgWebAppBotInline', botInline ? '1' : '0'); + } + return params.toString(); } diff --git a/packages/launch-params/src/storage.ts b/packages/launch-params/src/storage.ts index 2e1ff6a89..2d8a2c7c1 100644 --- a/packages/launch-params/src/storage.ts +++ b/packages/launch-params/src/storage.ts @@ -5,25 +5,28 @@ import type { LaunchParams } from './types.js'; const SESSION_STORAGE_KEY = 'telegram-mini-apps-launch-params'; /** - * Attempts to extract launch parameters directly from the sessionStorage. + * Attempts to extract launch parameters directly from the session storage. + * @returns Launch parameters in case, they were stored before or null, if there is no launch + * parameters key in the session storage. + * @throws {Error} Data stored in the session storage is invalid. */ export function retrieveFromStorage(): LaunchParams | null { const raw = sessionStorage.getItem(SESSION_STORAGE_KEY); - if (raw) { - try { - return parse(raw); - // eslint-disable-next-line no-empty - } catch (e) { - } - } - return null; + return raw + // We are not handling the error on purpose as long as we are waiting for data stored by + // this session storage key to contain the valid launch parameters. + ? parse(raw) + : null; } /** - * Saves specified launch params to session storage. + * Saves specified launch parameters in the session storage. * @param value - launch params to save. */ export function saveToStorage(value: LaunchParams): void { + // TODO: We probably don't need serialize here. We used it only to correctly serialize Date + // values which being converted strings. To solve the problem we could improve date parser + // to allows parsing such invalid (Dates, converted to strings) values. sessionStorage.setItem(SESSION_STORAGE_KEY, serialize(value)); } diff --git a/packages/launch-params/src/types.ts b/packages/launch-params/src/types.ts index 2229f176f..adb251af3 100644 --- a/packages/launch-params/src/types.ts +++ b/packages/launch-params/src/types.ts @@ -46,4 +46,26 @@ export interface LaunchParams { * Mini App palette settings. */ themeParams: ThemeParams; + + /** + * True if Mini App is currently launched in inline mode. + */ + botInline?: boolean; + + /** + * True if application is required to show the Settings Button. + */ + showSettings?: boolean; +} + +export interface LaunchData { + /** + * Was current application reloaded. + */ + isPageReload: boolean; + + /** + * Current application launch parameters. + */ + launchParams: LaunchParams; } diff --git a/packages/launch-params/tests/computeLaunchData.ts b/packages/launch-params/tests/computeLaunchData.ts new file mode 100644 index 000000000..ebb009d08 --- /dev/null +++ b/packages/launch-params/tests/computeLaunchData.ts @@ -0,0 +1,420 @@ +import type { SpyInstance } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { toSearchParams } from 'test-utils'; + +import { computeLaunchData } from '../src/index.js'; + +/** + * Mocks session storage launch params. + */ +function mockSessionStorageLaunchParams(launchParams: string) { + vi + .spyOn(sessionStorage, 'getItem') + .mockImplementation(() => launchParams); +} + +/** + * Mocks performance to make it imitate page reload. + */ +function mockPageReload() { + if (!vi.isMockFunction(performance.getEntriesByType)) { + vi + .spyOn(performance, 'getEntriesByType') + .mockImplementation(() => [{ type: 'reload' }] as any); + return; + } + + const asMock = performance.getEntriesByType as unknown as SpyInstance; + const implementation = asMock.getMockImplementation(); + + asMock.mockImplementation(() => { + const values = implementation ? implementation() : []; + + if (values.length > 0) { + values[0].type = 'reload'; + } else { + values.push({ type: 'reload' }); + } + + return values; + }); +} + +/** + * Mocks location launch params. + */ +function mockLocationLaunchParams(launchParams: string) { + vi + .spyOn(window.location, 'hash', 'get') + .mockImplementation(() => `#${launchParams}`); +} + +/** + * Mocks performance launch params. + */ +function mockPerformanceLaunchParams(launchParams: string) { + if (!vi.isMockFunction(performance.getEntriesByType)) { + vi + .spyOn(performance, 'getEntriesByType') + .mockImplementation(() => [{ name: `#${launchParams}` }] as any); + return; + } + + const asMock = performance.getEntriesByType as unknown as SpyInstance; + const implementation = asMock.getMockImplementation(); + + asMock.mockImplementation(() => { + const values = implementation ? implementation() : []; + + if (values.length > 0) { + values[0].name = `#${launchParams}`; + } else { + values.push({ name: `#${launchParams}` }); + } + + return values; + }); +} + +/** + * Mocks current window object such way, it makes script think it is currently being launched + * in iframe. + */ +function mockIframe() { + vi + .spyOn(window, 'top', 'get') + .mockImplementation(() => { + throw new Error('access violation'); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('computeLaunchData', () => { + describe('launch parameters from the session storage extracted', () => { + describe('launch parameters from location extracted', () => { + describe('iframe environment', () => { + it('should return launch parameters from location and true, if page was reloaded', () => { + mockIframe(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockLocationLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + mockPageReload(); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + + it('should return launch parameters from location and true, if location launch parameters init data hash is the same as in the session storage launch parameters', () => { + mockIframe(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'abc', + }), + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockLocationLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'abc', + }), + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + initData: { + authDate: new Date(333000), + hash: 'abc', + }, + initDataRaw: 'auth_date=333&hash=abc', + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + + it('should return launch parameters from location and false, if location launch parameters init data hash differs from the session storage launch parameters', () => { + mockIframe(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 1, + hash: 'aaa', + }), + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockLocationLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'bbb', + }), + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + initData: { + authDate: new Date(333000), + hash: 'bbb', + }, + initDataRaw: 'auth_date=333&hash=bbb', + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: false, + }); + }); + }); + + describe('non-iframe environment', () => { + it('should return launch parameters from location and true', () => { + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockLocationLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + }); + }); + + describe('launch parameters from performance extracted', () => { + describe('iframe environment', () => { + it('should return launch parameters from performance and true, if page was reloaded', () => { + mockIframe(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockPerformanceLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + mockPageReload(); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + + it('should return launch parameters from performance and true, if performance launch parameters init data hash is the same as in the session storage launch parameters', () => { + mockIframe(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'abc', + }), + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockPerformanceLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'abc', + }), + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + initData: { + authDate: new Date(333000), + hash: 'abc', + }, + initDataRaw: 'auth_date=333&hash=abc', + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + + it('should return launch parameters from performance and false, if performance launch parameters init data hash differs from the session storage launch parameters', () => { + mockIframe(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 1, + hash: 'aaa', + }), + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockPerformanceLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'bbb', + }), + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + initData: { + authDate: new Date(333000), + hash: 'bbb', + }, + initDataRaw: 'auth_date=333&hash=bbb', + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: false, + }); + }); + }); + + describe('non-iframe environment', () => { + it('should return launch parameters from performance and true', () => { + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + mockPerformanceLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + }); + }); + + describe('launch parameters from location and performance are missing', () => { + it('should return launch parameters from the session storage and true if page was reloaded', () => { + mockPageReload(); + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppData: toSearchParams({ + auth_date: 333, + hash: 'abc', + }), + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + initData: { + authDate: new Date(333000), + hash: 'abc', + }, + initDataRaw: 'auth_date=333&hash=abc', + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }); + }); + + it('should throw if page was not reloaded', () => { + mockSessionStorageLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '7.0', + tgWebAppPlatform: 'macos', + })); + expect(() => computeLaunchData()).toThrow('Unable to retrieve current launch parameters, which must exist.'); + }); + }); + }); + + describe('launch parameters from the session storage are missing', () => { + it('should return launch parameters from location if they exist and false', () => { + mockLocationLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + themeParams: {}, + version: '6.0', + platform: 'web', + }, + isPageReload: false, + }); + }); + + it('should return launch parameters from performance if they exist and false', () => { + mockPerformanceLaunchParams(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.0', + tgWebAppPlatform: 'web', + })); + expect(computeLaunchData()).toStrictEqual({ + launchParams: { + themeParams: {}, + version: '6.0', + platform: 'web', + }, + isPageReload: false, + }); + }); + + it('should throw error in case, function was unable to extract any launch parameters', () => { + expect(() => computeLaunchData()).toThrow('Unable to retrieve any launch parameters.'); + }); + }); +}); diff --git a/packages/launch-params/tests/computePageReload.ts b/packages/launch-params/tests/computePageReload.ts new file mode 100644 index 000000000..55b98e525 --- /dev/null +++ b/packages/launch-params/tests/computePageReload.ts @@ -0,0 +1,28 @@ +import { describe, vi, expect, it, afterEach } from 'vitest'; +import { mockPerformanceGetEntriesByType } from 'test-utils'; + +import { computePageReload } from '../src/computePageReload.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('computePageReload', () => { + it('should return null if window.performance does not have any navigation entries', () => { + mockPerformanceGetEntriesByType(); + expect(computePageReload()).toBeNull(); + }); + + it('should return true if first navigation entry has type "reload"', () => { + mockPerformanceGetEntriesByType([{ type: 'reload' }] as any); + expect(computePageReload()).toBe(true); + }); + + it('should return false if first navigation entry has type different from "reload"', () => { + mockPerformanceGetEntriesByType([{ type: 'navigation' }] as any); + expect(computePageReload()).toBe(false); + + mockPerformanceGetEntriesByType([{ type: 'something else' }] as any); + expect(computePageReload()).toBe(false); + }); +}); diff --git a/packages/launch-params/tests/getFirstNavigationEntry.ts b/packages/launch-params/tests/getFirstNavigationEntry.ts new file mode 100644 index 000000000..744e2e67e --- /dev/null +++ b/packages/launch-params/tests/getFirstNavigationEntry.ts @@ -0,0 +1,21 @@ +import { describe, vi, expect, it, afterEach } from 'vitest'; +import { mockPerformanceGetEntriesByType } from 'test-utils'; + +import { getFirstNavigationEntry } from '../src/getFirstNavigationEntry.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getFirstNavigationEntry', () => { + it('should return null if window.performance does not have any navigation entries', () => { + mockPerformanceGetEntriesByType(); + expect(getFirstNavigationEntry()).toBeNull(); + }); + + it('should return the first navigation entry in case, window.performance has some', () => { + const entry = {}; + mockPerformanceGetEntriesByType([entry] as any); + expect(getFirstNavigationEntry()).toBe(entry); + }); +}); diff --git a/packages/launch-params/tests/launchParams.ts b/packages/launch-params/tests/launchParams.ts new file mode 100644 index 000000000..bbe5c7102 --- /dev/null +++ b/packages/launch-params/tests/launchParams.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { toSearchParams } from 'test-utils'; + +import { launchParams } from '../src/index.js'; + +describe('launchParams', () => { + describe('botInline', () => { + it('should extract "tgWebAppBotInline" and parse it as boolean', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppBotInline: true, + })), + ).toMatchObject({ + botInline: true, + }); + }); + }); + + describe('initData', () => { + it('should extract "tgWebAppData" property and parse it as InitData', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppData: toSearchParams({ + hash: 'myhash', + auth_date: 1, + }), + })), + ).toMatchObject({ + initData: { + hash: 'myhash', + authDate: new Date(1000), + }, + }); + }); + }); + + describe('initDataRaw', () => { + it('should extract "tgWebAppData" property and parse it as string', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppData: toSearchParams({ + hash: 'myhash', + auth_date: 1, + }), + })), + ).toMatchObject({ + initDataRaw: 'hash=myhash&auth_date=1', + }); + }); + }); + + describe('platform', () => { + it('should throw an error in case "tgWebAppPlatform" is missing', () => { + expect( + () => launchParams().parse(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + })), + ).toThrow(); + }); + + it('should extract "tgWebAppPlatform" and parse it as string', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + })), + ).toMatchObject({ + platform: 'webz', + }); + }); + }); + + describe('showSettings', () => { + it('should extract "tgWebAppShowSettings" and parse it as boolean', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppShowSettings: true, + })), + ).toMatchObject({ + showSettings: true, + }); + }); + }); + + describe('themeParams', () => { + it('should throw an error in case, "tgWebAppThemeParams" property is missing', () => { + expect( + () => launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppVersion: '6.9', + })), + ).toThrow(); + }); + + it('should extract "tgWebAppThemeParams" property and parse it as ThemeParams', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: { + bg_color: '#aaf132', + }, + tgWebAppVersion: '6.9', + })), + ).toMatchObject({ + themeParams: { + backgroundColor: '#aaf132', + }, + }); + }); + }); + + describe('version', () => { + it('should throw an error in case "tgWebAppVersion" is missing', () => { + expect( + () => launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + })), + ).toThrow(); + }); + + it('should extract "tgWebAppVersion" and parse it as string', () => { + expect( + launchParams().parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + })), + ).toMatchObject({ + version: '6.9', + }); + }); + }); +}); diff --git a/packages/launch-params/tests/parse.ts b/packages/launch-params/tests/parse.ts index 01a14b8ec..495ca1ddf 100644 --- a/packages/launch-params/tests/parse.ts +++ b/packages/launch-params/tests/parse.ts @@ -1,123 +1,147 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { toSearchParams } from 'test-utils'; import { parse } from '../src/index.js'; -import type { LaunchParams } from '../src/index.js'; -type ConstructLaunchParamsOptions = Partial>; - -/** - * Constructs search params representing launch parameters. - * @param lp - */ -function constructLaunchParams(lp: ConstructLaunchParamsOptions = {}): URLSearchParams { - const { - version, - themeParams, - platform, - initDataRaw, - } = lp; - const params = new URLSearchParams(); - - if (themeParams) { - const { - backgroundColor, - secondaryBackgroundColor, - buttonTextColor, - textColor, - linkColor, - hintColor, - buttonColor, - } = themeParams; - params.set('tgWebAppThemeParams', JSON.stringify({ - bg_color: backgroundColor, - secondary_bg_color: secondaryBackgroundColor, - button_text_color: buttonTextColor, - text_color: textColor, - link_color: linkColor, - hint_color: hintColor, - button_color: buttonColor, - })); - } - - if (version) { - params.set('tgWebAppVersion', version); - } - - if (platform) { - params.set('tgWebAppPlatform', platform); - } - - if (initDataRaw) { - params.set('tgWebAppData', initDataRaw); - } +describe('parse', () => { + describe('botInline', () => { + it('should extract "tgWebAppBotInline" and parse it as boolean', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppBotInline: true, + })), + ).toMatchObject({ + botInline: true, + }); + }); + }); - return params; -} + describe('initData', () => { + it('should extract "tgWebAppData" property and parse it as InitData', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppData: toSearchParams({ + hash: 'myhash', + auth_date: 1, + }), + })), + ).toMatchObject({ + initData: { + hash: 'myhash', + authDate: new Date(1000), + }, + }); + }); + }); -describe('parse.ts', () => { - describe('parse', () => { - it('should throw if tgWebAppVersion, tgWebAppPlatform or tgWebAppThemeParams are missing', () => { - expect(() => parse('')).toThrow(); - expect(() => parse(constructLaunchParams({ - version: '6.10', - }))).toThrow(); - expect(() => parse(constructLaunchParams({ - version: '6.10', - platform: 'macos', - }))).toThrow(); - expect(() => parse(constructLaunchParams({ - version: '6.10', - themeParams: {}, - }))).toThrow(); + describe('initDataRaw', () => { + it('should extract "tgWebAppData" property and parse it as string', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppData: toSearchParams({ + hash: 'myhash', + auth_date: 1, + }), + })), + ).toMatchObject({ + initDataRaw: 'hash=myhash&auth_date=1', + }); + }); + }); - expect(() => parse(constructLaunchParams({ - version: '6.10', - platform: 'macos', - themeParams: {}, - }))).not.toThrow(); + describe('platform', () => { + it('should throw an error in case "tgWebAppPlatform" is missing', () => { + expect( + () => parse(toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + })), + ).toThrow(); }); - describe('tgWebAppVersion', () => { - it('should map property to property with name "version"', () => { - const params = 'tgWebAppVersion=6.10&tgWebAppPlatform=macos&tgWebAppThemeParams={}'; - expect(parse(params)).toMatchObject({ version: '6.10' }); + it('should extract "tgWebAppPlatform" and parse it as string', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + })), + ).toMatchObject({ + platform: 'webz', }); }); + }); - describe('tgWebAppPlatform', () => { - it('should map property to property with name "platform"', () => { - const params = 'tgWebAppVersion=6.10&tgWebAppPlatform=macos&tgWebAppThemeParams={}'; - expect(parse(params)).toMatchObject({ platform: 'macos' }); + describe('showSettings', () => { + it('should extract "tgWebAppShowSettings" and parse it as boolean', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppShowSettings: true, + })), + ).toMatchObject({ + showSettings: true, }); }); + }); - describe('tgWebAppThemeParams', () => { - it('should map property to property with name "themeParams" applying theme params parser', () => { - const params = 'tgWebAppVersion=6.10&tgWebAppPlatform=macos&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23ffffff%22%7D'; - expect(parse(params)).toMatchObject({ - themeParams: { - backgroundColor: '#ffffff', - }, - }); - }); + describe('themeParams', () => { + it('should throw an error in case, "tgWebAppThemeParams" property is missing', () => { + expect( + () => parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppVersion: '6.9', + })), + ).toThrow(); }); - describe('tgWebAppData', () => { - it('should map property to property with name "initData" applying init data parser', () => { - const params = 'tgWebAppVersion=6.10&tgWebAppPlatform=macos&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23ffffff%22%7D&tgWebAppData=auth_date%3D1696440047%26hash%3Dabc'; - expect(parse(params)).toMatchObject({ - initData: { - authDate: new Date(1696440047000), - hash: 'abc', + it('should extract "tgWebAppThemeParams" property and parse it as ThemeParams', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: { + bg_color: '#aaf132', }, - }); + tgWebAppVersion: '6.9', + })), + ).toMatchObject({ + themeParams: { + backgroundColor: '#aaf132', + }, }); + }); + }); + + describe('version', () => { + it('should throw an error in case "tgWebAppVersion" is missing', () => { + expect( + () => parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + })), + ).toThrow(); + }); - it('should map property to property with name "initDataRaw" saving string format', () => { - const params = 'tgWebAppVersion=6.10&tgWebAppPlatform=macos&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23ffffff%22%7D&tgWebAppData=auth_date%3D1696440047%26hash%3Dabc'; - expect(parse(params)).toMatchObject({ - initDataRaw: 'auth_date=1696440047&hash=abc', - }); + it('should extract "tgWebAppVersion" and parse it as string', () => { + expect( + parse(toSearchParams({ + tgWebAppPlatform: 'webz', + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + })), + ).toMatchObject({ + version: '6.9', }); }); }); diff --git a/packages/launch-params/tests/retrieveCurrent.ts b/packages/launch-params/tests/retrieveCurrent.ts new file mode 100644 index 000000000..9974573a9 --- /dev/null +++ b/packages/launch-params/tests/retrieveCurrent.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + mockPerformanceGetEntriesByType, + mockWindowLocationHash, + toSearchParams, +} from 'test-utils'; + +import { retrieveCurrent } from '../src/retrieveCurrent.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('retrieveCurrent', () => { + it('should return launch parameters from performance in case they exist', () => { + mockPerformanceGetEntriesByType([{ + name: `#${toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppPlatform: 'web', + })}`, + }] as any); + + expect(retrieveCurrent()).toStrictEqual({ + themeParams: {}, + version: '6.9', + platform: 'web', + }); + }); + + it('should return launch parameters from location in case they exist', () => { + mockWindowLocationHash( + `#${toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppPlatform: 'web', + })}`, + ); + + expect(retrieveCurrent()).toStrictEqual({ + themeParams: {}, + version: '6.9', + platform: 'web', + }); + }); + + it('should return null in case both location and performance do not have launch parameters', () => { + expect(retrieveCurrent()).toBeNull(); + }); +}); diff --git a/packages/launch-params/tests/retrieveFromLocation.ts b/packages/launch-params/tests/retrieveFromLocation.ts new file mode 100644 index 000000000..e8387429d --- /dev/null +++ b/packages/launch-params/tests/retrieveFromLocation.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mockWindowLocationHash, toSearchParams } from 'test-utils'; + +import { retrieveFromLocation } from '../src/index.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('retrieveFromLocation', () => { + it('should return launch parameters from window.location.hash in case they exist', () => { + mockWindowLocationHash( + `#${toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppPlatform: 'web', + })}`, + ); + + expect(retrieveFromLocation()).toStrictEqual({ + themeParams: {}, + version: '6.9', + platform: 'web', + }); + }); + + it('should throw in case window.location.hash do not have valid launch parameters', () => { + expect(() => retrieveFromLocation()).toThrow(); + }); +}); diff --git a/packages/launch-params/tests/retrieveFromPerformance.ts b/packages/launch-params/tests/retrieveFromPerformance.ts new file mode 100644 index 000000000..21e1f715d --- /dev/null +++ b/packages/launch-params/tests/retrieveFromPerformance.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mockPerformanceGetEntriesByType, toSearchParams } from 'test-utils'; + +import { retrieveFromPerformance } from '../src/index.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('retrieveFromPerformance', () => { + it('should throw in case window.performance does not have navigation entry', () => { + mockPerformanceGetEntriesByType(); + expect(() => retrieveFromPerformance()).toThrow('Unable to get first navigation entry.'); + }); + + it('should throw in case first navigation entry does not contain hash part', () => { + mockPerformanceGetEntriesByType([{ name: 'https://mydomain.com' }] as any); + expect(() => retrieveFromPerformance()).toThrow('First navigation entry does not contain hash part.'); + }); + + it('should throw in case first navigation entry contains invalid data in hash part', () => { + mockPerformanceGetEntriesByType([{ name: 'https://mydomain.com#' }] as any); + expect(() => retrieveFromPerformance()).toThrow('Unable to parse value'); + }); + + it('should return launch parameters from the first navigation entry', () => { + mockPerformanceGetEntriesByType([{ + name: `#${toSearchParams({ + tgWebAppThemeParams: {}, + tgWebAppVersion: '6.9', + tgWebAppPlatform: 'web', + })}`, + }] as any); + expect(retrieveFromPerformance()).toStrictEqual({ + themeParams: {}, + version: '6.9', + platform: 'web', + }); + }); +}); diff --git a/packages/launch-params/tests/retrieveLaunchData.ts b/packages/launch-params/tests/retrieveLaunchData.ts new file mode 100644 index 000000000..4ca53e291 --- /dev/null +++ b/packages/launch-params/tests/retrieveLaunchData.ts @@ -0,0 +1,82 @@ +import type { SpyInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockWindow } from 'test-utils'; + +import * as computeLaunchDataModule from '../src/computeLaunchData.js'; +import * as storageModule from '../src/storage.js'; +import { retrieveLaunchData } from '../src/retrieveLaunchData.js'; + +vi.mock('../src/computeLaunchData.js', () => ({ + computeLaunchData: vi.fn(), +})); + +vi.mock('../src/storage.js', () => ({ + saveToStorage: vi.fn(), +})); + +let computeLaunchDataSpy: SpyInstance; +let saveToStorageSpy: SpyInstance; + +beforeEach(() => { + computeLaunchDataSpy = vi.spyOn(computeLaunchDataModule, 'computeLaunchData'); + saveToStorageSpy = vi.spyOn(storageModule, 'saveToStorage'); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('retrieveLaunchData', () => { + it('should return value stored in window.tmajsLaunchData in case it exists', () => { + mockWindow({ tmajsLaunchData: 'something cached' } as any); + expect(retrieveLaunchData()).toBe('something cached'); + }); + + it('should create property window.tmajsLaunchData and save the result of computeLaunchData function', () => { + mockWindow({} as any); + const launchData = { + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }; + computeLaunchDataSpy.mockImplementationOnce(() => launchData); + + retrieveLaunchData(); + expect((window as any).tmajsLaunchData).toStrictEqual(launchData); + }); + + it('should call saveToStorage function with launch data launch parameters to save data', () => { + mockWindow({} as any); + const launchData = { + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }; + computeLaunchDataSpy.mockImplementationOnce(() => launchData); + + retrieveLaunchData(); + expect(saveToStorageSpy).toBeCalledTimes(1); + expect(saveToStorageSpy).toHaveBeenCalledWith(launchData.launchParams); + }); + + it('should return result of computeLaunchData', () => { + mockWindow({} as any); + const launchData = { + launchParams: { + themeParams: {}, + version: '7.0', + platform: 'macos', + }, + isPageReload: true, + }; + computeLaunchDataSpy.mockImplementationOnce(() => launchData); + + expect(retrieveLaunchData()).toStrictEqual(launchData); + }); +}); diff --git a/packages/launch-params/tests/serialize.ts b/packages/launch-params/tests/serialize.ts new file mode 100644 index 000000000..943a3921e --- /dev/null +++ b/packages/launch-params/tests/serialize.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { toSearchParams } from 'test-utils'; + +import { serialize } from '../src/index.js'; + +describe('serialize', () => { + it('should convert launch params to search parameters in the order: tgWebAppData, tgWebAppPlatform, tgWebAppThemeParams, tgWebAppVersion, tgWebAppShowSettings, tgWebAppBotInline', () => { + expect(serialize({ + version: '6.3', + platform: 'web', + botInline: true, + showSettings: true, + initDataRaw: toSearchParams({ + auth_date: 13, + hash: 'abc123', + }), + themeParams: { + backgroundColor: '#aabbcc', + }, + })).toBe('tgWebAppData=auth_date%3D13%26hash%3Dabc123&tgWebAppPlatform=web&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23aabbcc%22%7D&tgWebAppVersion=6.3&tgWebAppShowSettings=1&tgWebAppBotInline=1'); + + expect(serialize({ + version: '6.3', + platform: 'web', + botInline: false, + showSettings: false, + initDataRaw: toSearchParams({ + auth_date: 13, + hash: 'abc123', + }), + themeParams: { + backgroundColor: '#aabbcc', + }, + })).toBe('tgWebAppData=auth_date%3D13%26hash%3Dabc123&tgWebAppPlatform=web&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23aabbcc%22%7D&tgWebAppVersion=6.3&tgWebAppShowSettings=0&tgWebAppBotInline=0'); + }); +}); diff --git a/packages/launch-params/tests/storage.ts b/packages/launch-params/tests/storage.ts index c24342ee9..b352197ff 100644 --- a/packages/launch-params/tests/storage.ts +++ b/packages/launch-params/tests/storage.ts @@ -1,119 +1,102 @@ -import type { SpyInstance } from 'vitest'; -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { retrieveFromStorage, saveToStorage } from '../src/index.js'; - -let sessionStorageSpy: SpyInstance<[], Storage>; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mockSessionStorageGetItem, mockSessionStorageSetItem, toSearchParams } from 'test-utils'; -beforeAll(() => { - sessionStorageSpy = vi.spyOn(window, 'sessionStorage', 'get'); -}); +import { retrieveFromStorage, saveToStorage } from '../src/index.js'; afterEach(() => { - sessionStorageSpy.mockReset(); + vi.restoreAllMocks(); }); -describe('storage.ts', () => { - describe('retrieveFromStorage', () => { - it('should use key "telegram-mini-apps-launch-params" to extract data from', () => { - const getItemSpy = vi.fn(() => ''); - sessionStorageSpy.mockImplementation(() => ({ - getItem: getItemSpy, - }) as any); +describe('retrieveFromStorage', () => { + it('should use key "telegram-mini-apps-launch-params" to extract data from', () => { + const getItemSpy = vi.fn(() => ''); + mockSessionStorageGetItem(getItemSpy); - retrieveFromStorage(); + retrieveFromStorage(); - expect(getItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params'); - }); + expect(getItemSpy).toHaveBeenCalledOnce(); + expect(getItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params'); + }); - it('should return launch params in case, saved value is valid. Return null otherwise', () => { - const getItemSpy = vi.fn(); - sessionStorageSpy.mockImplementation(() => ({ - getItem: getItemSpy, - }) as any); - - getItemSpy.mockImplementationOnce(() => 'abc'); - expect(retrieveFromStorage()).toBeNull(); - - getItemSpy.mockImplementationOnce(() => null); - expect(retrieveFromStorage()).toBeNull(); - - getItemSpy.mockImplementationOnce(() => 'tgWebAppData=query_id%3DAAHdF6IQAAAAAN0XohAOqR8k%26user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%257D%26auth_date%3D1691441944%26hash%3Da867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681&tgWebAppVersion=6.7&tgWebAppPlatform=tdesktop&tgWebAppBotInline=1&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D'); - - expect(retrieveFromStorage()).toStrictEqual({ - version: '6.7', - initData: { - queryId: 'AAHdF6IQAAAAAN0XohAOqR8k', - authDate: new Date(1691441944000), - hash: 'a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', - user: { - allowsWriteToPm: true, - id: 279058397, - firstName: 'Vladislav', - lastName: 'Kibenko', - username: 'vdkfrost', - languageCode: 'ru', - isPremium: true, - }, - }, - initDataRaw: 'query_id=AAHdF6IQAAAAAN0XohAOqR8k&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1691441944&hash=a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', - platform: 'tdesktop', - themeParams: { - backgroundColor: '#17212b', - buttonColor: '#5288c1', - buttonTextColor: '#ffffff', - hintColor: '#708499', - linkColor: '#6ab3f3', - secondaryBackgroundColor: '#232e3c', - textColor: '#f5f5f5', + it('should return launch params in case, session storage contains value by key "telegram-mini-apps-launch-params"', () => { + const getItemSpy = mockSessionStorageGetItem('tgWebAppData=query_id%3DAAHdF6IQAAAAAN0XohAOqR8k%26user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%257D%26auth_date%3D1691441944%26hash%3Da867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681&tgWebAppVersion=6.7&tgWebAppPlatform=tdesktop&tgWebAppBotInline=1&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D'); + + expect(retrieveFromStorage()).toStrictEqual({ + botInline: true, + version: '6.7', + initData: { + queryId: 'AAHdF6IQAAAAAN0XohAOqR8k', + authDate: new Date(1691441944000), + hash: 'a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', + user: { + allowsWriteToPm: true, + id: 279058397, + firstName: 'Vladislav', + lastName: 'Kibenko', + username: 'vdkfrost', + languageCode: 'ru', + isPremium: true, }, - }); - - expect(getItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params'); + }, + initDataRaw: 'query_id=AAHdF6IQAAAAAN0XohAOqR8k&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1691441944&hash=a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', + platform: 'tdesktop', + themeParams: { + backgroundColor: '#17212b', + buttonColor: '#5288c1', + buttonTextColor: '#ffffff', + hintColor: '#708499', + linkColor: '#6ab3f3', + secondaryBackgroundColor: '#232e3c', + textColor: '#f5f5f5', + }, }); - }); - describe('saveToStorage', () => { - it('should use key "telegram-mini-apps-launch-params" to save in session storage', () => { - const setItemSpy = vi.fn(); - sessionStorageSpy.mockImplementation(() => ({ - setItem: setItemSpy, - }) as any); + expect(getItemSpy).toHaveBeenCalledOnce(); + expect(getItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params'); + }); - saveToStorage({ - version: '6.10', - platform: 'macos', - themeParams: {}, - }); + it('should throw in case, session storage contains invalid value by key "telegram-mini-apps-launch-params"', () => { + const getItemSpy = mockSessionStorageGetItem('abc'); - expect(setItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params', expect.anything()); - }); + expect(() => retrieveFromStorage()).toThrow(); + expect(getItemSpy).toHaveBeenCalledOnce(); + expect(getItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params'); + }); +}); - it('should convert passed object to search params with fields "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams" and "tgWebAppData"', () => { - const setItemSpy = vi.fn(); - sessionStorageSpy.mockImplementation(() => ({ - setItem: setItemSpy, - }) as any); - - saveToStorage({ - version: '6.10', - platform: 'macos', - themeParams: { - backgroundColor: '#aaaaaa', - }, - initDataRaw: 'some-custom-data', - }); +describe('saveToStorage', () => { + it('should use key "telegram-mini-apps-launch-params" to save in the session storage', () => { + const setItemSpy = mockSessionStorageSetItem(); - expect(setItemSpy).toHaveBeenLastCalledWith(expect.anything(), 'tgWebAppData=some-custom-data&tgWebAppPlatform=macos&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23aaaaaa%22%7D&tgWebAppVersion=6.10'); + saveToStorage({ + version: '6.10', + platform: 'macos', + themeParams: {}, + }); - saveToStorage({ - version: '6.10', - platform: 'macos', - themeParams: { - backgroundColor: '#aaaaaa', - }, - }); + expect(setItemSpy).toHaveBeenCalledWith('telegram-mini-apps-launch-params', expect.anything()); + }); - expect(setItemSpy).toHaveBeenLastCalledWith(expect.anything(), 'tgWebAppPlatform=macos&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23aaaaaa%22%7D&tgWebAppVersion=6.10'); + it('should convert launch params to search parameters in the order: tgWebAppData, tgWebAppPlatform, tgWebAppThemeParams, tgWebAppVersion, tgWebAppShowSettings, tgWebAppBotInline', () => { + const setItemSpy = mockSessionStorageSetItem(); + + saveToStorage({ + version: '6.3', + platform: 'web', + botInline: false, + showSettings: false, + initDataRaw: toSearchParams({ + auth_date: 13, + hash: 'abc123', + }), + themeParams: { + backgroundColor: '#aabbcc', + }, }); + + expect(setItemSpy).toHaveBeenLastCalledWith( + expect.anything(), + 'tgWebAppData=auth_date%3D13%26hash%3Dabc123&tgWebAppPlatform=web&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%23aabbcc%22%7D&tgWebAppVersion=6.3&tgWebAppShowSettings=0&tgWebAppBotInline=0', + ); }); }); diff --git a/packages/parsing/src/ArrayValueParser.ts b/packages/parsing/src/ArrayValueParser.ts index 239354cc3..b43f41908 100644 --- a/packages/parsing/src/ArrayValueParser.ts +++ b/packages/parsing/src/ArrayValueParser.ts @@ -1,5 +1,5 @@ -import { ParsingError } from './ParsingError.js'; import { ValueParser } from './ValueParser.js'; +import { unexpectedTypeError } from './unexpectedTypeError.js'; import type { AnyParser, Parser, IsEmptyFunc } from './types.js'; import type { ValueParserOverrides, ParseResult } from './ValueParser.js'; @@ -45,16 +45,20 @@ function parseArray(value: unknown): unknown[] { } catch (e) { } } - - throw new ParsingError(value, { type: 'array' }); + throw unexpectedTypeError(); } export class ArrayValueParser extends ValueParser { private itemParser: Parser; - constructor(itemParser: AnyParser, isOptional: IsOptional, isEmpty: IsEmptyFunc) { - super(parseArray, isOptional, isEmpty); + constructor( + itemParser: AnyParser, + isOptional: IsOptional, + isEmpty: IsEmptyFunc, + type?: string, + ) { + super(parseArray, isOptional, isEmpty, type); this.itemParser = typeof itemParser === 'function' ? itemParser diff --git a/packages/parsing/src/ParseError.ts b/packages/parsing/src/ParseError.ts new file mode 100644 index 000000000..115f12305 --- /dev/null +++ b/packages/parsing/src/ParseError.ts @@ -0,0 +1,27 @@ +interface Options { + /** + * Type name. + */ + type?: string; + + /** + * Original occurred error. + */ + cause?: unknown; +} + +/** + * Error thrown in case, there was an error during parsing. + */ +export class ParseError extends Error { + /** + * Parser name. + */ + public readonly type?: string; + + constructor(public readonly value: unknown, { cause, type }: Options = {}) { + super(`Unable to parse value${type ? ` as ${type}` : ''}`, { cause }); + Object.setPrototypeOf(this, ParseError.prototype); + this.type = type; + } +} diff --git a/packages/parsing/src/ParseSchemaFieldError.ts b/packages/parsing/src/ParseSchemaFieldError.ts new file mode 100644 index 000000000..c588e2b41 --- /dev/null +++ b/packages/parsing/src/ParseSchemaFieldError.ts @@ -0,0 +1,21 @@ +interface Options { + /** + * Type name. + */ + type?: string; + + /** + * Original occurred error. + */ + cause?: unknown; +} + +/** + * Error thrown in case, there was an error during parse. + */ +export class ParseSchemaFieldError extends Error { + constructor(field: string, { cause, type }: Options = {}) { + super(`Unable to parse field "${field}"${type ? ` as ${type}` : ''}`, { cause }); + Object.setPrototypeOf(this, ParseSchemaFieldError.prototype); + } +} diff --git a/packages/parsing/src/ParsingError.ts b/packages/parsing/src/ParsingError.ts deleted file mode 100644 index 8d5763c14..000000000 --- a/packages/parsing/src/ParsingError.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface Options { - /** - * Type name. - */ - type?: string; - /** - * Original occurred error. - */ - error?: unknown; - /** - * Field name. - */ - field?: string; -} - -/** - * Error thrown in case, there was an error during parse. - */ -export class ParsingError extends Error { - constructor(value: unknown, { type, error, field }: Options = {}) { - super(`Unable to parse ${field ? `field "${field}"` : 'value'} as ${type}`, { - cause: { value, error }, - }); - Object.setPrototypeOf(this, ParsingError.prototype); - } -} diff --git a/packages/parsing/src/ValueParser.ts b/packages/parsing/src/ValueParser.ts index 14bd399b8..83c0d2fe3 100644 --- a/packages/parsing/src/ValueParser.ts +++ b/packages/parsing/src/ValueParser.ts @@ -1,5 +1,6 @@ import type { If } from '@tma.js/util-types'; +import { ParseError } from './ParseError.js'; import type { IsEmptyFunc, Parser } from './types.js'; /** @@ -46,15 +47,22 @@ export class ValueParser { protected parser: Parser, protected isOptional: IsOptional, protected isEmpty: IsEmptyFunc, + protected type?: string, ) { } parse(value: unknown): ParseResult { // In case, parsing result is specified as optional, and passed value is considered as empty, // we can return undefined. Otherwise, pass to parser. - return ( - this.isOptional && this.isEmpty(value) ? undefined : this.parser(value) - ) as ParseResult; + if (this.isOptional && this.isEmpty(value)) { + return undefined as ParseResult; + } + + try { + return this.parser(value) as ParseResult; + } catch (cause) { + throw new ParseError(value, { type: this.type, cause }); + } } optional(): OptionalResult { diff --git a/packages/parsing/src/createValueParserGenerator.ts b/packages/parsing/src/createValueParserGenerator.ts new file mode 100644 index 000000000..635317b0f --- /dev/null +++ b/packages/parsing/src/createValueParserGenerator.ts @@ -0,0 +1,35 @@ +import { isUndefined } from './isUndefined.js'; +import { ValueParser } from './ValueParser.js'; +import type { IsEmptyFunc, Parser } from './types.js'; + +export interface ValueParserGeneratorOptions { + /** + * Should return true if passed value is recognized empty for this parser. + * @default Function which returns true if value is undefined or null. + */ + isEmpty?: IsEmptyFunc; +} + +export interface CreateValueParserGeneratorOptions extends ValueParserGeneratorOptions { + /** + * Type name. + */ + type?: string; +} + +/** + * Creates function which generates new scalar value parser based on the specified one. + * @param parser - parser to use as basic. + * @param type - type name. + * @param genIsEmpty - isEmpty function for parser. + */ +export function createValueParserGenerator( + parser: Parser, + { + type, + isEmpty: genIsEmpty = isUndefined, + }: CreateValueParserGeneratorOptions = {}, +) { + // eslint-disable-next-line max-len + return ({ isEmpty = genIsEmpty }: ValueParserGeneratorOptions = {}) => new ValueParser(parser, false, isEmpty, type); +} diff --git a/packages/parsing/src/index.ts b/packages/parsing/src/index.ts index a06244414..7016f07a3 100644 --- a/packages/parsing/src/index.ts +++ b/packages/parsing/src/index.ts @@ -1,5 +1,6 @@ export * from './parsers/index.js'; export * from './ArrayValueParser.js'; -export * from './ParsingError.js'; +export * from './createValueParserGenerator.js'; +export * from './ParseSchemaFieldError.js'; export * from './types.js'; export * from './ValueParser.js'; diff --git a/packages/parsing/src/parsers/shared.ts b/packages/parsing/src/parseBySchema.ts similarity index 51% rename from packages/parsing/src/parsers/shared.ts rename to packages/parsing/src/parseBySchema.ts index d4c8506cb..743fb3020 100644 --- a/packages/parsing/src/parsers/shared.ts +++ b/packages/parsing/src/parseBySchema.ts @@ -1,47 +1,16 @@ -import { ParsingError } from '../ParsingError.js'; -import { ValueParser } from '../ValueParser.js'; -import { isUndefined } from '../isUndefined.js'; -import type { IsEmptyFunc, Parser, Schema } from '../types.js'; - -export interface CreateValueParserGenOptions { - /** - * Should return true if passed value is recognized empty for this parser. - * @default Function which returns true if value is undefined or null. - */ - isEmpty?: IsEmptyFunc; -} - -export type ValueParserGenOptions = CreateValueParserGenOptions; - -export function unknownTypeError() { - return new TypeError('Does not have any of expected types'); -} - -/** - * Creates function which generates new scalar value parser based on the specified one. - * @param parser - parser to use as basic. - * @param genIsEmpty - isEmpty function for parser. - */ -export function createValueParserGen( - parser: Parser, - { isEmpty: genIsEmpty = isUndefined }: CreateValueParserGenOptions = {}, -) { - return function scalarParserGen({ isEmpty = genIsEmpty }: ValueParserGenOptions = {}) { - return new ValueParser(parser, false, isEmpty); - }; -} +import { ParseSchemaFieldError } from './ParseSchemaFieldError.js'; +import { ParseError } from './ParseError.js'; +import type { Parser, Schema } from './types.js'; /** * Parses external value by specified schema. Functions iterates over each schema field * and uses getField function to get its value from the external source. * @param schema - object schema. * @param getField - function which gets external value by its field name. - * @param schemaType - schema type name. */ export function parseBySchema( schema: Schema, getField: (field: string) => unknown, - schemaType?: string, ): T { const result = {} as T; @@ -56,15 +25,15 @@ export function parseBySchema( let parser: Parser; // In case, definition has "type" property, then SchemaFieldDetailed was passed. - if ('type' in definition) { + if (typeof definition === 'function' || 'parse' in definition) { + // Otherwise we are working with either parser function or instance. + from = field; + parser = typeof definition === 'function' ? definition : definition.parse.bind(definition); + } else { const { type } = definition; from = definition.from || field; parser = typeof type === 'function' ? type : type.parse.bind(type); - } else { - // Otherwise we are working with either parser function or instance. - from = field; - parser = typeof definition === 'function' ? definition : definition.parse.bind(definition); } let parsedValue: unknown; @@ -73,10 +42,15 @@ export function parseBySchema( try { parsedValue = parser(originalValue); } catch (error) { - throw new ParsingError(originalValue, { - field: from, - type: schemaType, - error, + // If error is not instance of ParseError, we have nothing additional to do with the error. + if (!(error instanceof ParseError)) { + throw new ParseSchemaFieldError(from, { cause: error }); + } + + // Otherwise, we are going to rethrow the error with extended data. + throw new ParseSchemaFieldError(from, { + type: error.type, + cause: error, }); } diff --git a/packages/parsing/src/parsers/array.ts b/packages/parsing/src/parsers/array.ts index 45546f687..35a5caa36 100644 --- a/packages/parsing/src/parsers/array.ts +++ b/packages/parsing/src/parsers/array.ts @@ -3,7 +3,8 @@ import { isUndefined } from '../isUndefined.js'; /** * Parses incoming value as an array. + * @param type - parser type name. */ -export function array(): ArrayValueParser { - return new ArrayValueParser((value) => value, false, isUndefined); +export function array(type?: string): ArrayValueParser { + return new ArrayValueParser((value) => value, false, isUndefined, type); } diff --git a/packages/parsing/src/parsers/boolean.ts b/packages/parsing/src/parsers/boolean.ts index 68a9f9ea9..2bdc5dce1 100644 --- a/packages/parsing/src/parsers/boolean.ts +++ b/packages/parsing/src/parsers/boolean.ts @@ -1,10 +1,10 @@ -import { ParsingError } from '../ParsingError.js'; -import { unknownTypeError, createValueParserGen } from './shared.js'; +import { createValueParserGenerator } from '../createValueParserGenerator.js'; +import { unexpectedTypeError } from '../unexpectedTypeError.js'; /** * Returns parser to parse value as boolean. */ -export const boolean = createValueParserGen((value) => { +export const boolean = createValueParserGenerator((value) => { if (typeof value === 'boolean') { return value; } @@ -18,5 +18,7 @@ export const boolean = createValueParserGen((value) => { return false; } - throw new ParsingError(value, { type: 'boolean', error: unknownTypeError() }); + throw unexpectedTypeError(); +}, { + type: 'boolean', }); diff --git a/packages/parsing/src/parsers/date.ts b/packages/parsing/src/parsers/date.ts index fee5c3989..93a6b797c 100644 --- a/packages/parsing/src/parsers/date.ts +++ b/packages/parsing/src/parsers/date.ts @@ -1,20 +1,15 @@ -import { ParsingError } from '../ParsingError.js'; +import { createValueParserGenerator } from '../createValueParserGenerator.js'; import { number } from './number.js'; -import { createValueParserGen } from './shared.js'; const num = number(); /** * Returns parser to parse value as Date. */ -export const date = createValueParserGen((value) => { - if (value instanceof Date) { - return value; - } - - try { - return new Date(num.parse(value) * 1000); - } catch (cause) { - throw new ParsingError(value, { type: 'Date', error: cause }); - } +export const date = createValueParserGenerator((value) => ( + value instanceof Date + ? value + : new Date(num.parse(value) * 1000) +), { + type: 'Date', }); diff --git a/packages/parsing/src/parsers/index.ts b/packages/parsing/src/parsers/index.ts index 6e49b2c54..917522377 100644 --- a/packages/parsing/src/parsers/index.ts +++ b/packages/parsing/src/parsers/index.ts @@ -5,5 +5,4 @@ export * from './json.js'; export * from './number.js'; export * from './rgb.js'; export * from './searchParams.js'; -export { createValueParserGen } from './shared.js'; export * from './string.js'; diff --git a/packages/parsing/src/parsers/json.ts b/packages/parsing/src/parsers/json.ts index 84b96dac8..a054aa9e3 100644 --- a/packages/parsing/src/parsers/json.ts +++ b/packages/parsing/src/parsers/json.ts @@ -1,12 +1,12 @@ -import { ParsingError } from '../ParsingError.js'; -import { parseBySchema, unknownTypeError } from './shared.js'; import { ValueParser } from '../ValueParser.js'; -import type { Schema, IsEmptyFunc } from '../types.js'; import { isUndefined } from '../isUndefined.js'; +import { unexpectedTypeError } from '../unexpectedTypeError.js'; +import { parseBySchema } from '../parseBySchema.js'; +import type { Schema, IsEmptyFunc } from '../types.js'; interface Options { /** - * Described type name. + * Type name. */ type?: string; @@ -34,11 +34,7 @@ export function json(schema: Schema, options: Options = {}): ValueParser(schema: Schema, options: Options = {}): ValueParser formattedValue[field]); - }, false, isEmpty); + }, false, isEmpty, type); } diff --git a/packages/parsing/src/parsers/number.ts b/packages/parsing/src/parsers/number.ts index 4cfd88c54..2353148ba 100644 --- a/packages/parsing/src/parsers/number.ts +++ b/packages/parsing/src/parsers/number.ts @@ -1,10 +1,10 @@ -import { ParsingError } from '../ParsingError.js'; -import { createValueParserGen, unknownTypeError } from './shared.js'; +import { createValueParserGenerator } from '../createValueParserGenerator.js'; +import { unexpectedTypeError } from '../unexpectedTypeError.js'; /** * Returns parser to parse value as number. */ -export const number = createValueParserGen((value) => { +export const number = createValueParserGenerator((value) => { if (typeof value === 'number') { return value; } @@ -17,5 +17,7 @@ export const number = createValueParserGen((value) => { } } - throw new ParsingError(value, { type: 'number', error: unknownTypeError() }); + throw unexpectedTypeError(); +}, { + type: 'number', }); diff --git a/packages/parsing/src/parsers/rgb.ts b/packages/parsing/src/parsers/rgb.ts index b23e2f1a4..fcf0f28b2 100644 --- a/packages/parsing/src/parsers/rgb.ts +++ b/packages/parsing/src/parsers/rgb.ts @@ -1,19 +1,14 @@ import { toRGB } from '@tma.js/colors'; import type { RGB } from '@tma.js/colors'; -import { ParsingError } from '../ParsingError.js'; import { string } from './string.js'; -import { createValueParserGen } from './shared.js'; +import { createValueParserGenerator } from '../createValueParserGenerator.js'; const str = string(); /** * Returns parser to parse value as RGB color. */ -export const rgb = createValueParserGen((value) => { - try { - return toRGB(str.parse(value)); - } catch (cause) { - throw new ParsingError(value, { type: 'RGB', error: cause }); - } +export const rgb = createValueParserGenerator((value) => toRGB(str.parse(value)), { + type: 'rgb', }); diff --git a/packages/parsing/src/parsers/searchParams.ts b/packages/parsing/src/parsers/searchParams.ts index 8bb634976..072992ac6 100644 --- a/packages/parsing/src/parsers/searchParams.ts +++ b/packages/parsing/src/parsers/searchParams.ts @@ -1,8 +1,8 @@ -import { ParsingError } from '../ParsingError.js'; -import { parseBySchema, unknownTypeError } from './shared.js'; import { ValueParser } from '../ValueParser.js'; -import type { Schema, IsEmptyFunc } from '../types.js'; import { isUndefined } from '../isUndefined.js'; +import { unexpectedTypeError } from '../unexpectedTypeError.js'; +import { parseBySchema } from '../parseBySchema.js'; +import type { Schema, IsEmptyFunc } from '../types.js'; interface Options { /** @@ -30,7 +30,7 @@ export function searchParams(schema: Schema, options: Options = {}): Value return new ValueParser((value) => { if (typeof value !== 'string' && !(value instanceof URLSearchParams)) { - throw new ParsingError(value, { type, error: unknownTypeError() }); + throw unexpectedTypeError(); } const params = typeof value === 'string' ? new URLSearchParams(value) : value; @@ -39,5 +39,5 @@ export function searchParams(schema: Schema, options: Options = {}): Value const paramValue = params.get(field); return paramValue === null ? undefined : paramValue; }); - }, false, isEmpty); + }, false, isEmpty, type); } diff --git a/packages/parsing/src/parsers/string.ts b/packages/parsing/src/parsers/string.ts index a0aa2697a..09e9ad0f9 100644 --- a/packages/parsing/src/parsers/string.ts +++ b/packages/parsing/src/parsers/string.ts @@ -1,12 +1,14 @@ -import { ParsingError } from '../ParsingError.js'; -import { createValueParserGen, unknownTypeError } from './shared.js'; +import { createValueParserGenerator } from '../createValueParserGenerator.js'; +import { unexpectedTypeError } from '../unexpectedTypeError.js'; /** * Returns parser to parse value as string. */ -export const string = createValueParserGen((value) => { +export const string = createValueParserGenerator((value) => { if (typeof value === 'string' || typeof value === 'number') { return value.toString(); } - throw new ParsingError(value, { type: 'string', error: unknownTypeError() }); + throw unexpectedTypeError(); +}, { + type: 'string', }); diff --git a/packages/parsing/src/unexpectedTypeError.ts b/packages/parsing/src/unexpectedTypeError.ts new file mode 100644 index 000000000..ce91f47e3 --- /dev/null +++ b/packages/parsing/src/unexpectedTypeError.ts @@ -0,0 +1,6 @@ +/** + * Creates instance of TypeError stating, that value has unexpected type. + */ +export function unexpectedTypeError(): TypeError { + return new TypeError('Value has unexpected type'); +} diff --git a/packages/parsing/tests/parsers/searchParams.ts b/packages/parsing/tests/parsers/searchParams.ts index 196aa2ccc..253c3ea9e 100644 --- a/packages/parsing/tests/parsers/searchParams.ts +++ b/packages/parsing/tests/parsers/searchParams.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; + import { date, searchParams, string } from '../../src/index.js'; describe('parsers', () => { @@ -14,7 +15,24 @@ describe('parsers', () => { it('should throw an error in case, passed value does not contain required field presented in schema', () => { const parser = searchParams({ prop: string() }); - expect(() => parser.parse('abc=123')).toThrowError('Unable to parse field "prop"'); + + try { + parser.parse('abc=123'); + } catch (e) { + expect(e).toMatchObject({ + message: 'Unable to parse value', + cause: { + message: 'Unable to parse field "prop" as string', + cause: { + message: 'Unable to parse value as string', + cause: { + message: 'Value has unexpected type', + }, + }, + }, + }); + } + expect.assertions(1); }); it('should not throw an error in case, passed value does not contain optional field presented in schema', () => { @@ -25,9 +43,52 @@ describe('parsers', () => { expect(parser.parse('prop=abc')).toEqual({ prop: 'abc' }); }); + it('should use parser with unspecified type', () => { + const parser = searchParams<{ prop: unknown }>({ + prop: () => { + throw new Error('Just an error'); + }, + }); + + try { + parser.parse('prop='); + } catch (e) { + expect(e).toMatchObject({ + message: 'Unable to parse value', + cause: { + message: 'Unable to parse field "prop"', + cause: { + message: 'Just an error', + }, + }, + }); + } + expect.assertions(1); + }); + it('should throw an error in case, passed value contains field of different type presented in schema', () => { const parser = searchParams({ prop: date() }); - expect(() => parser.parse('prop=abc')).toThrowError('Unable to parse field "prop"'); + + try { + parser.parse('prop=abc'); + } catch (e) { + expect(e).toMatchObject({ + message: 'Unable to parse value', + cause: { + message: 'Unable to parse field "prop" as Date', + cause: { + message: 'Unable to parse value as Date', + cause: { + message: 'Unable to parse value as number', + cause: { + message: 'Value has unexpected type', + }, + }, + }, + }, + }); + } + expect.assertions(1); }); it('should correctly parse built-in types', () => { diff --git a/packages/sdk/src/components/ThemeParams/ThemeParams.ts b/packages/sdk/src/components/ThemeParams/ThemeParams.ts index 39fda6818..1d0f9cd4b 100644 --- a/packages/sdk/src/components/ThemeParams/ThemeParams.ts +++ b/packages/sdk/src/components/ThemeParams/ThemeParams.ts @@ -11,23 +11,35 @@ import type { ThemeParamsEvents, ThemeParamsState } from './types.js'; function prepareThemeParams(value: ThemeParamsType): ThemeParamsState { const { + accentTextColor = null, backgroundColor = null, - buttonTextColor = null, buttonColor = null, + buttonTextColor = null, + destructiveTextColor = null, + headerBackgroundColor = null, hintColor = null, linkColor = null, - textColor = null, secondaryBackgroundColor = null, + sectionBackgroundColor = null, + sectionHeaderTextColor = null, + subtitleTextColor = null, + textColor = null, } = value; return { + accentTextColor, backgroundColor, - buttonTextColor, buttonColor, + buttonTextColor, + destructiveTextColor, + headerBackgroundColor, hintColor, linkColor, - textColor, secondaryBackgroundColor, + sectionBackgroundColor, + sectionHeaderTextColor, + subtitleTextColor, + textColor, }; } diff --git a/packages/sdk/src/env.ts b/packages/sdk/src/env.ts index 03ae505b9..cb74e9539 100644 --- a/packages/sdk/src/env.ts +++ b/packages/sdk/src/env.ts @@ -1,17 +1,22 @@ -import { retrieveLaunchParams } from './launch-params.js'; +import { retrieveLaunchData } from '@tma.js/launch-params'; /** * Returns true in case, current environment is Telegram Mini Apps. - * - * `isTWA` utilizes such function as `retrieveLaunchParams`, which attempts to retrieve - * launch parameters from the current environment. - * @see retrieveLaunchParams */ -export function isTWA(): boolean { +export function isTMA(): boolean { try { - retrieveLaunchParams(); + retrieveLaunchData(); return true; } catch (e) { return false; } } + +/** + * Returns true in case, current environment is Telegram Mini Apps. + * @see computeLaunchData + * @deprecated Use `isTMA` + */ +export function isTWA(): boolean { + return isTMA(); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 638211a71..dea7ba07a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,6 +2,5 @@ export * from './components/index.js'; export * from './errors/index.js'; export * from './init/index.js'; export * from './env.js'; -export * from './launch-params.js'; export * from './types.js'; export * from './url.js'; diff --git a/packages/sdk/src/init/init.ts b/packages/sdk/src/init/init.ts index a7f849965..b88c96462 100644 --- a/packages/sdk/src/init/init.ts +++ b/packages/sdk/src/init/init.ts @@ -5,12 +5,7 @@ import { on, } from '@tma.js/bridge'; import { withTimeout } from '@tma.js/utils'; -import type { LaunchParams } from '@tma.js/launch-params'; -import { - parse as parseLaunchParams, - saveToStorage as saveLaunchParamsToStorage, - retrieveFromStorage, -} from '@tma.js/launch-params'; +import { parse, retrieveLaunchData } from '@tma.js/launch-params'; import { CloudStorage, @@ -33,42 +28,9 @@ import { createViewport, createWebApp, createRequestIdGenerator, createClosingBehavior, } from './creators/index.js'; -import { retrieveLaunchParams } from '../launch-params.js'; import type { InitOptions, InitResult } from './types.js'; -/** - * Returns true in case, current session was created due to native location reload. - */ -function isNativePageReload(): boolean { - return ( - window - .performance - .getEntriesByType('navigation') as PerformanceNavigationTiming[] - ).some((entry) => entry.type === 'reload'); -} - -/** - * Returns true if current page was reloaded. - * @param launchParamsFromStorage - launch parameters from sessionStorage. - * @param currentLaunchParams - actual launch parameters. - */ -function computePageReload( - launchParamsFromStorage: LaunchParams | null, - currentLaunchParams: LaunchParams, -): boolean { - // To check if page was reloaded, we should check if previous init data hash equals to the - // current one. Nevertheless, there are some cases, when init data is missing. For example, - // when app was launched via KeyboardButton. In this case we try to use the native way of - // checking if current page was reloaded (which could still return incorrect result). - // Issue: https://github.com/Telegram-Mini-Apps/issues/issues/12 - if (!launchParamsFromStorage) { - return false; - } - - return launchParamsFromStorage.initData?.hash === currentLaunchParams.initData?.hash; -} - /** * Represents actual init function. * @param options - init options. @@ -80,8 +42,8 @@ async function actualInit(options: InitOptions = {}): Promise { acceptScrollbarStyle = true, acceptCustomStyles = acceptScrollbarStyle, targetOrigin, + launchParams: launchParamsOption, debug = false, - launchParams: optionsLaunchParams, } = options; // Set global settings. @@ -93,19 +55,12 @@ async function actualInit(options: InitOptions = {}): Promise { setTargetOrigin(targetOrigin); } - // Get Mini App launch params and save them to session storage, so they will be accessible from - // anywhere. - const launchParamsFromStorage = retrieveFromStorage(); - const launchParams = optionsLaunchParams instanceof URLSearchParams || typeof optionsLaunchParams === 'string' - ? parseLaunchParams(optionsLaunchParams) - : retrieveLaunchParams(); - - saveLaunchParamsToStorage(launchParams); - - // Compute if page was reloaded. We will need it to decide if SDK components should be restored - // or created from scratch. - const isPageReload = isNativePageReload() - || computePageReload(launchParamsFromStorage, launchParams); + // Retrieve launch data. + const { launchParams, isPageReload } = retrieveLaunchData({ + currentLaunchParams: typeof launchParamsOption === 'string' || launchParamsOption instanceof URLSearchParams + ? parse(launchParamsOption) + : launchParamsOption, + }); const { initData, diff --git a/packages/sdk/src/launch-params.ts b/packages/sdk/src/launch-params.ts deleted file mode 100644 index 39a579a84..000000000 --- a/packages/sdk/src/launch-params.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { parse, retrieveFromStorage, type LaunchParams } from '@tma.js/launch-params'; - -/** - * Attempts to extract launch params from window.location.hash. In case, window.location.hash - * lacks of valid data, function attempts to extract launch params from the sessionStorage. - */ -export function retrieveLaunchParams(): LaunchParams { - let error: unknown | undefined; - - // Try to extract Mini App data from hash. This block of code covers usual flow, when - // application was firstly opened by the user and its hash always contains required parameters. - try { - return parse(window.location.hash.slice(1)); - } catch (e) { - error = e; - } - - // Mini Apps allows reloading current page. In this case, window.location.reload() will be - // called which means, that init will be called again. As the result, current window - // location will lose Mini App data. To solve this problem, we are extracting launch - // params saved previously. - const fromStorage = retrieveFromStorage(); - if (fromStorage) { - return fromStorage; - } - - throw new Error('Unable to extract launch params', { cause: error }); -} diff --git a/packages/sdk/tests/launch-params.ts b/packages/sdk/tests/launch-params.ts deleted file mode 100644 index e577a77ae..000000000 --- a/packages/sdk/tests/launch-params.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; -import type { SpyInstance } from 'vitest'; - -import { retrieveLaunchParams } from '../src/index.js'; - -let sessionStorageSpy: SpyInstance<[], Storage>; -let windowSpy: SpyInstance<[], typeof globalThis & Window>; - -beforeAll(() => { - sessionStorageSpy = vi.spyOn(window, 'sessionStorage', 'get'); - windowSpy = vi.spyOn(window, 'window', 'get'); -}); - -afterEach(() => { - sessionStorageSpy.mockReset(); - windowSpy.mockReset(); -}); - -describe('launch-params.ts', () => { - describe('retrieveLaunchParams', () => { - it('should successfully extract launch parameters from window.location.hash', () => { - const launchParams = 'tgWebAppData=query_id%3DAAHdF6IQAAAAAN0XohAOqR8k%26user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%257D%26auth_date%3D1691441944%26hash%3Da867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681&tgWebAppVersion=6.7&tgWebAppPlatform=tdesktop&tgWebAppBotInline=1&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D'; - windowSpy.mockImplementation(() => ({ - location: { - hash: `#${launchParams}`, - }, - }) as any); - - expect(retrieveLaunchParams()).toStrictEqual({ - version: '6.7', - initData: { - queryId: 'AAHdF6IQAAAAAN0XohAOqR8k', - authDate: new Date(1691441944000), - hash: 'a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', - user: { - allowsWriteToPm: true, - id: 279058397, - firstName: 'Vladislav', - lastName: 'Kibenko', - username: 'vdkfrost', - languageCode: 'ru', - isPremium: true, - }, - }, - initDataRaw: 'query_id=AAHdF6IQAAAAAN0XohAOqR8k&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1691441944&hash=a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', - platform: 'tdesktop', - themeParams: { - backgroundColor: '#17212b', - buttonColor: '#5288c1', - buttonTextColor: '#ffffff', - hintColor: '#708499', - linkColor: '#6ab3f3', - secondaryBackgroundColor: '#232e3c', - textColor: '#f5f5f5', - }, - }); - }); - - it('should use retrieveFromStorage from @tma.js/launch-params package in case, extraction from window.location.hash was unsuccessful', () => { - windowSpy.mockImplementation(() => ({ - location: { - hash: '', - }, - }) as any); - - sessionStorageSpy.mockImplementation(() => ({ - getItem() { - return 'tgWebAppData=query_id%3DAAHdF6IQAAAAAN0XohAOqR8k%26user%3D%257B%2522id%2522%253A279058397%252C%2522first_name%2522%253A%2522Vladislav%2522%252C%2522last_name%2522%253A%2522Kibenko%2522%252C%2522username%2522%253A%2522vdkfrost%2522%252C%2522language_code%2522%253A%2522ru%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%257D%26auth_date%3D1691441944%26hash%3Da867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681&tgWebAppVersion=6.7&tgWebAppPlatform=tdesktop&tgWebAppBotInline=1&tgWebAppThemeParams=%7B%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D'; - }, - }) as any); - - expect(retrieveLaunchParams()).toStrictEqual({ - version: '6.7', - initData: { - queryId: 'AAHdF6IQAAAAAN0XohAOqR8k', - authDate: new Date(1691441944000), - hash: 'a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', - user: { - allowsWriteToPm: true, - id: 279058397, - firstName: 'Vladislav', - lastName: 'Kibenko', - username: 'vdkfrost', - languageCode: 'ru', - isPremium: true, - }, - }, - initDataRaw: 'query_id=AAHdF6IQAAAAAN0XohAOqR8k&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1691441944&hash=a867b5c7c9944dee3890edffd8cd89244eeec7a3d145f1681', - platform: 'tdesktop', - themeParams: { - backgroundColor: '#17212b', - buttonColor: '#5288c1', - buttonTextColor: '#ffffff', - hintColor: '#708499', - linkColor: '#6ab3f3', - secondaryBackgroundColor: '#232e3c', - textColor: '#f5f5f5', - }, - }); - }); - - it('should throw an error if function was unable to extract data from sessionStorage and window.location.hash', () => { - windowSpy.mockImplementation(() => ({ location: { hash: '' } }) as any); - sessionStorageSpy.mockImplementation(() => ({ - getItem() { - return null; - }, - }) as any); - expect(() => retrieveLaunchParams()).toThrow(); - }); - }); -}); diff --git a/packages/test-utils/.eslintrc.cjs b/packages/test-utils/.eslintrc.cjs new file mode 100644 index 000000000..63d117348 --- /dev/null +++ b/packages/test-utils/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ['custom/base'], +}; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 000000000..8bb768c41 --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,14 @@ +{ + "name": "test-utils", + "private": true, + "version": "0.0.1", + "description": "", + "main": "src/index.ts", + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "tsconfig": "workspace:*", + "eslint-config-custom": "workspace:*" + } +} diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts new file mode 100644 index 000000000..c49ae2008 --- /dev/null +++ b/packages/test-utils/src/index.ts @@ -0,0 +1,4 @@ +export * from './performance'; +export * from './session-storage'; +export * from './toSearchParams'; +export * from './window'; diff --git a/packages/test-utils/src/performance.ts b/packages/test-utils/src/performance.ts new file mode 100644 index 000000000..5aba2d855 --- /dev/null +++ b/packages/test-utils/src/performance.ts @@ -0,0 +1,15 @@ +import { vi } from 'vitest'; + +import { formatImplementation, type MockImplementation } from './utils'; + +/** + * Mocks performance.getEntriesByType. + * @param impl - method implementation. + */ +export function mockPerformanceGetEntriesByType( + impl: MockImplementation = [], +) { + return vi + .spyOn(performance, 'getEntriesByType') + .mockImplementation(formatImplementation(impl)); +} diff --git a/packages/test-utils/src/session-storage.ts b/packages/test-utils/src/session-storage.ts new file mode 100644 index 000000000..2ce7e70b9 --- /dev/null +++ b/packages/test-utils/src/session-storage.ts @@ -0,0 +1,28 @@ +import { vi } from 'vitest'; + +import { formatImplementation, type MockImplementation } from './utils'; + +/** + * Mocks sessionStorage.getItem. + * @param impl - method implementation. + */ +export function mockSessionStorageGetItem( + impl: MockImplementation = null, +) { + return vi + .spyOn(sessionStorage, 'getItem') + .mockImplementation(formatImplementation(impl)); +} + +/** + * Mocks sessionStorage.setItem. + * @param impl - method implementation. + */ +export function mockSessionStorageSetItem(impl?: () => void) { + const spy = vi.spyOn(sessionStorage, 'setItem'); + if (impl) { + spy.mockImplementation(impl); + } + + return spy; +} diff --git a/packages/test-utils/src/toSearchParams.ts b/packages/test-utils/src/toSearchParams.ts new file mode 100644 index 000000000..d3483adaa --- /dev/null +++ b/packages/test-utils/src/toSearchParams.ts @@ -0,0 +1,23 @@ +/** + * Creates search parameters from the specified JSON object. + * @param json - JSON object. + */ +export function toSearchParams(json: Record): string { + const params = new URLSearchParams(); + + Object.entries(json).forEach(([key, value]) => { + if (typeof value === 'object') { + params.set(key, JSON.stringify(value)); + return; + } + + if (typeof value === 'boolean') { + params.set(key, String(Number(value))); + return; + } + + params.set(key, String(value)); + }); + + return params.toString(); +} diff --git a/packages/test-utils/src/utils.ts b/packages/test-utils/src/utils.ts new file mode 100644 index 000000000..2b1f64155 --- /dev/null +++ b/packages/test-utils/src/utils.ts @@ -0,0 +1,11 @@ +export type MockImplementation = T | (() => T); + +/** + * Formats mock implementation. + * @param impl - mock implementation. + */ +export function formatImplementation(impl: MockImplementation): () => T { + return typeof impl === 'function' + ? impl as any + : () => impl; +} diff --git a/packages/test-utils/src/window.ts b/packages/test-utils/src/window.ts new file mode 100644 index 000000000..651e22d49 --- /dev/null +++ b/packages/test-utils/src/window.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest'; + +import { formatImplementation, type MockImplementation } from './utils'; + +export type Wnd = Window & typeof globalThis; + +/** + * Mocks window getter. + * @param impl - window getter implementation. + */ +export function mockWindow(impl: MockImplementation) { + return vi + .spyOn(global, 'window', 'get') + .mockImplementation(formatImplementation(impl)); +} + +/** + * Mocks window.location.hash getter. + * @param impl - hash getter implementation. + */ +export function mockWindowLocationHash( + impl: MockImplementation = '', +) { + return vi + .spyOn(window.location, 'hash', 'get') + .mockImplementation(formatImplementation(impl)); +} diff --git a/packages/test-utils/tsconfig.eslint.json b/packages/test-utils/tsconfig.eslint.json new file mode 100644 index 000000000..4144216dd --- /dev/null +++ b/packages/test-utils/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} \ No newline at end of file diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 000000000..7eeb5fa13 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "tsconfig/esnext-dom.json", + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/theme-params/src/serialize.ts b/packages/theme-params/src/serialize.ts index 00b80d179..0fa26a272 100644 --- a/packages/theme-params/src/serialize.ts +++ b/packages/theme-params/src/serialize.ts @@ -1,27 +1,29 @@ +import type { RGB } from '@tma.js/colors'; + import type { ThemeParams } from './types.js'; /** - * Converts theme params to its initial representation. - * @param value - theme parameters. + * Converts palette key from local representation to representation sent from the Telegram + * application. + * @param key - palette key. */ -export function serialize(value: ThemeParams): string { - const { - linkColor, - secondaryBackgroundColor, - hintColor, - textColor, - buttonTextColor, - buttonColor, - backgroundColor, - } = value; +function convertKey(key: string): string { + return key + // Convert camel case to snake case. + .replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`) + // Replace all "background" strings to "bg". + .replace(/(^|_)background/, (_, prefix) => `${prefix}bg`); +} - return JSON.stringify({ - bg_color: backgroundColor, - button_color: buttonColor, - button_text_color: buttonTextColor, - hint_color: hintColor, - link_color: linkColor, - secondary_bg_color: secondaryBackgroundColor, - text_color: textColor, - }); +/** + * Converts theme params to its initial representation. + * @param themeParams - theme parameters. + */ +export function serialize(themeParams: ThemeParams): string { + return JSON.stringify( + Object.entries(themeParams).reduce>((acc, [key, value]) => { + acc[convertKey(key)] = value; + return acc; + }, {}), + ); } diff --git a/packages/theme-params/src/themeParams.ts b/packages/theme-params/src/themeParams.ts index d751e2d2b..db91ab4f6 100644 --- a/packages/theme-params/src/themeParams.ts +++ b/packages/theme-params/src/themeParams.ts @@ -4,38 +4,32 @@ import type { ThemeParams } from './types.js'; const rgbOptional = rgb().optional(); +function field(from: string) { + return { + type: rgbOptional, + from, + }; +} + /** * Returns parser used to parse theme parameters. */ export function themeParams() { return json({ - backgroundColor: { - type: rgbOptional, - from: 'bg_color', - }, - buttonColor: { - type: rgbOptional, - from: 'button_color', - }, - buttonTextColor: { - type: rgbOptional, - from: 'button_text_color', - }, - hintColor: { - type: rgbOptional, - from: 'hint_color', - }, - linkColor: { - type: rgbOptional, - from: 'link_color', - }, - secondaryBackgroundColor: { - type: rgbOptional, - from: 'secondary_bg_color', - }, - textColor: { - type: rgbOptional, - from: 'text_color', - }, + accentTextColor: field('accent_text_color'), + backgroundColor: field('bg_color'), + buttonColor: field('button_color'), + buttonTextColor: field('button_text_color'), + destructiveTextColor: field('destructive_text_color'), + headerBackgroundColor: field('header_bg_color'), + hintColor: field('hint_color'), + linkColor: field('link_color'), + secondaryBackgroundColor: field('secondary_bg_color'), + sectionHeaderTextColor: field('section_header_text_color'), + sectionBackgroundColor: field('section_bg_color'), + subtitleTextColor: field('subtitle_text_color'), + textColor: field('text_color'), + }, { + type: 'ThemeParams', }); } diff --git a/packages/theme-params/src/types.ts b/packages/theme-params/src/types.ts index db9026b6a..fc332c8ad 100644 --- a/packages/theme-params/src/types.ts +++ b/packages/theme-params/src/types.ts @@ -1,14 +1,39 @@ import type { RGB } from '@tma.js/colors'; /** - * Application theme parameters. Defines palette used by the Telegram application. + * Application [theme parameters](https://docs.telegram-mini-apps.com/functionality/theming). + * Defines palette used by the Telegram application. */ export interface ThemeParams { + /** + * @since v6.10 + */ + accentTextColor?: RGB; backgroundColor?: RGB; buttonColor?: RGB; buttonTextColor?: RGB; + /** + * @since v6.10 + */ + destructiveTextColor?: RGB; + /** + * @since v6.10 + */ + headerBackgroundColor?: RGB; hintColor?: RGB; linkColor?: RGB; secondaryBackgroundColor?: RGB; + /** + * @since v6.10 + */ + sectionBackgroundColor?: RGB; + /** + * @since v6.10 + */ + sectionHeaderTextColor?: RGB; + /** + * @since v6.10 + */ + subtitleTextColor?: RGB; textColor?: RGB; } diff --git a/packages/theme-params/tests/parse.ts b/packages/theme-params/tests/parse.ts index 0812c4902..bf02d2870 100644 --- a/packages/theme-params/tests/parse.ts +++ b/packages/theme-params/tests/parse.ts @@ -4,50 +4,30 @@ import { parse } from '../src/index.js'; describe('parse.ts', () => { describe('parse', () => { - const mapping = [ + [ + ['accent_text_color', 'accentTextColor'], ['bg_color', 'backgroundColor'], ['button_color', 'buttonColor'], ['button_text_color', 'buttonTextColor'], + ['destructive_text_color', 'destructiveTextColor'], + ['header_bg_color', 'headerBackgroundColor'], ['hint_color', 'hintColor'], ['link_color', 'linkColor'], ['secondary_bg_color', 'secondaryBackgroundColor'], + ['section_header_text_color', 'sectionHeaderTextColor'], + ['section_bg_color', 'sectionBackgroundColor'], + ['subtitle_text_color', 'subtitleTextColor'], ['text_color', 'textColor'], - ]; - - mapping.forEach(([from, to]) => { + ].forEach(([from, to]) => { describe(to, () => { - it(`should omit if "${from}" property is missing`, () => { - expect(parse({})).toStrictEqual({}); - }); - it(`should throw if "${from}" property contains not a string in format "#RRGGBB"`, () => { expect(() => parse({ [from]: 999 })).toThrow(); }); - it(`should map source "${from}" property to result "${to}" property if source contains string in format "#RRGGBB"`, () => { + it(`should map to "${to}" property parsing it as string in "#RRGGBB" format`, () => { expect(parse({ [from]: '#aabbcc' })).toStrictEqual({ [to]: '#aabbcc' }); }); }); }); - - it('should correctly parse the entire value', () => { - expect(parse({ - bg_color: '#ffaabb', - button_color: '#233312', - button_text_color: '#ddaa21', - hint_color: '#da1122', - link_color: '#22314a', - text_color: '#31344a', - secondary_bg_color: '#ffaabb', - })).toStrictEqual({ - backgroundColor: '#ffaabb', - buttonColor: '#233312', - buttonTextColor: '#ddaa21', - hintColor: '#da1122', - linkColor: '#22314a', - textColor: '#31344a', - secondaryBackgroundColor: '#ffaabb', - }); - }); }); }); diff --git a/packages/theme-params/tests/serialize.ts b/packages/theme-params/tests/serialize.ts index 91dbed5c8..0091fb804 100644 --- a/packages/theme-params/tests/serialize.ts +++ b/packages/theme-params/tests/serialize.ts @@ -4,38 +4,30 @@ import { serialize } from '../src/index.js'; describe('serialize.ts', () => { describe('serialize', () => { - const mapping = [ - ['backgroundColor', 'bg_color'], - ['buttonColor', 'button_color'], - ['buttonTextColor', 'button_text_color'], - ['hintColor', 'hint_color'], - ['linkColor', 'link_color'], - ['secondaryBackgroundColor', 'secondary_bg_color'], - ['textColor', 'text_color'], - ]; - - mapping.forEach(([from, to]) => { - describe(to, () => { - it(`should be omitted if "${from}" property is missing`, () => { + [ + ['accent_text_color', 'accentTextColor'], + ['bg_color', 'backgroundColor'], + ['button_color', 'buttonColor'], + ['button_text_color', 'buttonTextColor'], + ['destructive_text_color', 'destructiveTextColor'], + ['header_bg_color', 'headerBackgroundColor'], + ['hint_color', 'hintColor'], + ['link_color', 'linkColor'], + ['secondary_bg_color', 'secondaryBackgroundColor'], + ['section_header_text_color', 'sectionHeaderTextColor'], + ['section_bg_color', 'sectionBackgroundColor'], + ['subtitle_text_color', 'subtitleTextColor'], + ['text_color', 'textColor'], + ].forEach(([to, from]) => { + describe(from, () => { + it(`should omit the "${to}" property in case this property is missing`, () => { expect(serialize({})).not.toMatch(`"${to}"`); }); - it(`should map source "${from}" property to result "${to}" property`, () => { + it(`should map this property to "${to}" property`, () => { expect(serialize({ [from]: '#aabbcc' })).toBe(`{"${to}":"#aabbcc"}`); }); }); }); - - it('should correctly parse the entire value', () => { - expect(serialize({ - backgroundColor: '#ffaabb', - buttonColor: '#233312', - buttonTextColor: '#ddaa21', - hintColor: '#da1122', - linkColor: '#22314a', - secondaryBackgroundColor: '#ffaabb', - textColor: '#31344a', - })).toBe('{"bg_color":"#ffaabb","button_color":"#233312","button_text_color":"#ddaa21","hint_color":"#da1122","link_color":"#22314a","secondary_bg_color":"#ffaabb","text_color":"#31344a"}'); - }); }); }); diff --git a/packages/theme-params/tests/themeParams.ts b/packages/theme-params/tests/themeParams.ts new file mode 100644 index 000000000..bf88adf82 --- /dev/null +++ b/packages/theme-params/tests/themeParams.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; + +import { themeParams } from '../src/index.js'; + +describe('themeParams.ts', () => { + describe('themeParams', () => { + [ + ['accent_text_color', 'accentTextColor'], + ['bg_color', 'backgroundColor'], + ['button_color', 'buttonColor'], + ['button_text_color', 'buttonTextColor'], + ['destructive_text_color', 'destructiveTextColor'], + ['header_bg_color', 'headerBackgroundColor'], + ['hint_color', 'hintColor'], + ['link_color', 'linkColor'], + ['secondary_bg_color', 'secondaryBackgroundColor'], + ['section_header_text_color', 'sectionHeaderTextColor'], + ['section_bg_color', 'sectionBackgroundColor'], + ['subtitle_text_color', 'subtitleTextColor'], + ['text_color', 'textColor'], + ].forEach(([from, to]) => { + describe(to, () => { + it(`should throw if "${from}" property contains not a string in format "#RRGGBB"`, () => { + expect(() => themeParams().parse({ [from]: 999 })).toThrow(); + }); + + it(`should map to "${to}" property parsing it as string in "#RRGGBB" format`, () => { + expect(themeParams().parse({ [from]: '#aabbcc' })).toStrictEqual({ [to]: '#aabbcc' }); + }); + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e5e328d3..68fd48169 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,9 @@ importers: eslint-config-custom: specifier: workspace:* version: link:../eslint-config-custom + test-utils: + specifier: workspace:* + version: link:../test-utils tsconfig: specifier: workspace:* version: link:../tsconfig @@ -204,6 +207,9 @@ importers: eslint-config-custom: specifier: workspace:* version: link:../eslint-config-custom + test-utils: + specifier: workspace:* + version: link:../test-utils tsconfig: specifier: workspace:* version: link:../tsconfig @@ -376,6 +382,15 @@ importers: specifier: workspace:* version: link:../tsconfig + packages/test-utils: + devDependencies: + eslint-config-custom: + specifier: workspace:* + version: link:../eslint-config-custom + tsconfig: + specifier: workspace:* + version: link:../tsconfig + packages/theme-params: dependencies: '@tma.js/colors': @@ -3194,6 +3209,7 @@ packages: /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + requiresBuild: true dependencies: is-arrayish: 0.2.1 dev: true @@ -4206,6 +4222,7 @@ packages: /is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + requiresBuild: true dev: true /is-async-function@2.0.0: @@ -4505,6 +4522,7 @@ packages: /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + requiresBuild: true dev: true /json-schema-traverse@0.4.1: @@ -4598,6 +4616,7 @@ packages: /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + requiresBuild: true dev: true /load-yaml-file@0.2.0: