diff --git a/.changeset/healthy-dots-swim.md b/.changeset/healthy-dots-swim.md new file mode 100644 index 000000000..acefe9b6d --- /dev/null +++ b/.changeset/healthy-dots-swim.md @@ -0,0 +1,6 @@ +--- +"@telegram-apps/bridge": minor +"@telegram-apps/sdk": minor +--- + +Add Safe Area functionality diff --git a/apps/docs/packages/telegram-apps-sdk/2-x/components/viewport.md b/apps/docs/packages/telegram-apps-sdk/2-x/components/viewport.md index 5828c08af..d72a71013 100644 --- a/apps/docs/packages/telegram-apps-sdk/2-x/components/viewport.md +++ b/apps/docs/packages/telegram-apps-sdk/2-x/components/viewport.md @@ -176,4 +176,65 @@ if (exitFullscreen.isAvailable()) { } ``` +::: + +## Safe Area Insets + +The viewport component offers access to two types of insets: + +- **Safe area insets** +- **Content safe area insets** + +For more details on the differences between these inset types, visit the +[**Viewport**](../../../../platform/viewport.md) page. + +The component provides access to these insets through the following signals: + +::: code-group + +```ts [Variable] +// Objects with numeric properties "top", "bottom", "left" and "right". +viewport.safeAreaInsets(); +viewport.contentSafeAreaInsets(); + +// Numeric values. +viewport.safeAreaInsetTop(); +viewport.safeAreaInsetBottom(); +viewport.safeAreaInsetLeft(); +viewport.safeAreaInsetRight(); +viewport.contentSafeAreaInsetTop(); +viewport.contentSafeAreaInsetBottom(); +viewport.contentSafeAreaInsetLeft(); +viewport.contentSafeAreaInsetRight(); +``` + +```ts [Functions] +import { + viewportSafeAreaInsets, + viewportSafeAreaInsetTop, + viewportSafeAreaInsetBottom, + viewportSafeAreaInsetLeft, + viewportSafeAreaInsetRight, + viewportContentSafeAreaInsets, + viewportContentSafeAreaInsetTop, + viewportContentSafeAreaInsetBottom, + viewportContentSafeAreaInsetLeft, + viewportContentSafeAreaInsetRight, +} from '@telegram-apps/sdk'; + +// Objects with numeric properties "top", "bottom", "left" and "right". +viewportSafeAreaInsets(); +viewportContentSafeAreaInsets(); + +// Numeric values. +viewportSafeAreaInsetTop(); +viewportSafeAreaInsetBottom(); +viewportSafeAreaInsetLeft(); +viewportSafeAreaInsetRight(); +viewportContentSafeAreaInsetTop(); +viewportContentSafeAreaInsetBottom(); +viewportContentSafeAreaInsetLeft(); +viewportContentSafeAreaInsetRight(); +``` + ::: \ No newline at end of file diff --git a/apps/docs/platform/events.md b/apps/docs/platform/events.md index 5b6a687f7..7dcd137be 100644 --- a/apps/docs/platform/events.md +++ b/apps/docs/platform/events.md @@ -119,10 +119,10 @@ Biometry authentication request completed. This event usually occurs in a respon If authentication was successful, the event contains a token from the local secure storage. -| Field | Type | Description | -|--------|------------------------------|------------------------------------------------------------------------------------------------------------| -| status | `'failed'` or `'authorized'` | Authentication status. | -| token | `string` | _Optional_. Token from the local secure storage saved previously. Passed only if `status` is `authorized`. | +| Field | Type | Description | +|--------|----------|------------------------------------------------------------------------------------------------------------| +| status | `string` | Authentication status. Possible values: `failed` or `authorized`. | +| token | `string` | _Optional_. Token from the local secure storage saved previously. Passed only if `status` is `authorized`. | ### `biometry_info_received` @@ -130,14 +130,14 @@ Available since: **v7.2** Biometry settings were received. -| Field | Type | Description | -|------------------|------------------------|-------------------------------------------------------------------------------| -| available | `boolean` | Shows whether biometry is available. | -| access_requested | `boolean` | Shows whether permission to use biometrics has been requested. | -| access_granted | `boolean` | Shows whether permission to use biometrics has been granted. | -| device_id | `string` | A unique device identifier that can be used to match the token to the device. | -| token_saved | `boolean` | Show whether local secure storage contains previously saved token. | -| type | `'face'` or `'finger'` | The type of biometrics currently available on the device. | +| Field | Type | Description | +|------------------|-----------|------------------------------------------------------------------------------------------------| +| available | `boolean` | Shows whether biometry is available. | +| access_requested | `boolean` | Shows whether permission to use biometrics has been requested. | +| access_granted | `boolean` | Shows whether permission to use biometrics has been granted. | +| device_id | `string` | A unique device identifier that can be used to match the token to the device. | +| token_saved | `boolean` | Show whether local secure storage contains previously saved token. | +| type | `string` | The type of biometrics currently available on the device. Possible values: `face` or `finger`. | ### `biometry_token_updated` @@ -145,9 +145,9 @@ Available since: **v7.2** Biometry token was updated. -| Field | Type | Description | -|--------|------------------------|----------------| -| status | `updated` or `removed` | Update status. | +| Field | Type | Description | +|--------|----------|---------------------------------------------------------| +| status | `string` | Update status. Possible values: `updated` or `removed`. | ### `clipboard_text_received` @@ -160,17 +160,36 @@ Telegram application attempted to extract text from clipboard. | req_id | `string` | Passed during the [web_app_read_text_from_clipboard](methods.md#web-app-read-text-from-clipboard) method invocation `req_id` value. | | data | `string` or `null` | _Optional_. Data extracted from the clipboard. The returned value will have the type `string` only in the case, application has access to the clipboard. | +### `content_safe_area_changed` + +Available since: **v8.0** + +This event occurs whenever the content safe area changes in the user's Telegram app. For instance, +when a user switches to landscape mode. + +The **safe area** ensures that content does not overlap with Telegram's UI elements. + +The **content safe area** is a subset of the device's safe area, specifically covering Telegram's +UI. + +| Field | Type | Description | +|--------|----------|--------------------------------------------------------------------------------------------------| +| top | `number` | The top inset in pixels, representing the space to avoid at the top of the content area | +| bottom | `number` | The bottom inset in pixels, representing the space to avoid at the bottom of the content area | +| left | `number` | The left inset in pixels, representing the space to avoid on the left side of the content area | +| right | `number` | The right inset in pixels, representing the space to avoid on the right side of the content area | + ### `custom_method_invoked` Available since: **v6.9** Custom method invocation completed. -| Field | Type | Description | -|--------|-----------|--------------------------------------------------| -| req_id | `string` | Unique identifier of this invocation. | -| result | `unknown` | _Optional_. Method invocation successful result. | -| error | `string` | _Optional_. Method invocation error code. | +| Field | Type | Description | +|--------|-----------|-------------------------------------------| +| req_id | `string` | Unique identifier of this invocation. | +| result | `unknown` | _Optional_. Method invocation result. | +| error | `string` | _Optional_. Method invocation error code. | ### `fullscreen_changed` @@ -178,9 +197,9 @@ Available since: **v8.0** Occurs whenever the mini app enters or exits the fullscreen mode. -| Field | Type | Description | -|---------------|-----------|--------------------------------------| -| is_fullscreen | `boolean` | Is application currently fullscreen. | +| Field | Type | Description | +|---------------|-----------|---------------------------------------------------------------| +| is_fullscreen | `boolean` | Indicates if the application is currently in fullscreen mode. | ### `fullscreen_failed` @@ -188,91 +207,18 @@ Available since: **v8.0** Occurs whenever the mini app enters or exits the fullscreen mode. - - - - - - - - - - - - - - - - - -
FieldTypeDescription
error - string - - Fullscreen mode status error. -
    -
  • - UNSUPPORTED, fullscreen mode is not supported on this device or platform -
  • -
  • - ALREADY_FULLSCREEN, the Mini App is already in fullscreen mode -
  • -
-
+| Field | Type | Description | +|-------|----------|---------------------------------------------------------------------------------------| +| error | `string` | Fullscreen mode status error. Possible values: `UNSUPPORTED` or `ALREADY_FULLSCREEN`. | ### `invoice_closed` An invoice was closed. - - - - - - - - - - - - - - - - - - - - - - - -
FieldTypeDescription
slug - string - - Passed during the  - - web_app_open_invoice -   - method invocation slug value. -
status - string - - Invoice status. Values: -
    -
  • - paid, invoice was paid -
  • -
  • - failed, invoice failed -
  • -
  • - pending, invoice is currently pending -
  • -
  • - cancelled, invoice was cancelled -
  • -
-
+| Field | Type | Description | +|--------|----------|-----------------------------------------------------------------------------------------------------------| +| slug | `string` | Passed during the [web_app_open_invoice](methods.md#web-app-open-invoice) method invocation `slug` value. | +| status | `string` | Invoice status. Possible values: `paid`, `failed`, `pending` or `cancelled`. | ### `main_button_pressed` @@ -296,10 +242,6 @@ Application received phone access request status. |-----------|----------|-----------------------------------------------------------------------------------------------------------------------------------------| | button_id | `string` | _Optional_. Identifier of the clicked button. In case, the popup was closed without clicking any button, this property will be omitted. | -### `reload_iframe` - -Parent iframe requested current iframe reload. - ### `qr_text_received` Available since: **v6.4** @@ -310,6 +252,27 @@ The QR scanner scanned some QR and extracted its content. |-------|----------|-----------------------------------------| | data | `string` | _Optional_. Data extracted from the QR. | +### `reload_iframe` + +Parent iframe requested current iframe reload. + +### `safe_area_changed` + +Available since: **v8.0** + +This event occurs whenever the safe area changes in the user's Telegram app, such as when the user +switches to landscape mode. + +The **safe area** prevents content from overlapping with system UI elements like notches or +navigation bars. + +| Field | Type | Description | +|--------|----------|--------------------------------------------------------------------------------------------| +| top | `number` | The top inset in pixels, representing the space to avoid at the top of the screen | +| bottom | `number` | The bottom inset in pixels, representing the space to avoid at the bottom of the screen | +| left | `number` | The left inset in pixels, representing the space to avoid on the left side of the screen | +| right | `number` | The right inset in pixels, representing the space to avoid on the right side of the screen | + ### `scan_qr_popup_closed` Available since: **v6.4** @@ -332,12 +295,12 @@ developer to stylize the app scrollbar (but he is still able to do it himself). Available since: **v6.1** -Occurs when the [Settings Button](settings-button.md) was pressed. +Occurs whenever the [Settings Button](settings-button.md) was pressed. ### `theme_changed` -Occurs whenever [the theme](theming.md) was changed in the user's Telegram app ( -including switching to night mode). +Occurs whenever [the theme](theming.md) was changed in the user's Telegram app (including switching +to night mode). | Field | Type | Description | |--------------|--------------------------|--------------------------------------------------------------------------------------------------------| @@ -355,11 +318,10 @@ user started dragging the application or called the expansion method. | is_expanded | `boolean` | Is the viewport currently expanded. | | is_state_stable | `boolean` | Is the viewport current state stable and not going to change in the next moment. | -::: tip -Pay attention to the fact, that send rate of this method is not enough to smoothly resize the -application window. You should probably use a stable height instead of the current one, or handle -this problem in another way. -::: +> [!TIP] +> Pay attention to the fact, that send rate of this method is not enough to smoothly resize the +> application window. You should probably use a stable height instead of the current one, or handle +> this problem in another way. ### `write_access_requested` diff --git a/apps/docs/platform/methods.md b/apps/docs/platform/methods.md index 3fb154505..e30d11de4 100644 --- a/apps/docs/platform/methods.md +++ b/apps/docs/platform/methods.md @@ -118,7 +118,7 @@ Notifies parent iframe about the current iframe is going to reload. Available since: **v7.2** -Requests current biometry settings. +Requests the current biometry settings. ### `web_app_biometry_open_settings` @@ -127,12 +127,9 @@ Available since: **v7.2** Opens the biometric access settings for bots. Useful when you need to request biometrics access to users who haven't granted it yet. -::: info - -This method can be called only in response to user interaction with the Mini -App interface (e.g. a click inside the Mini App or on the main button) - -::: +> [!INFO] +> This method can be called only in response to user interaction with the Mini App interface +> (e.g. a click inside the Mini App or on the main button) ### `web_app_biometry_request_access` @@ -188,6 +185,12 @@ class [Message](https://core.telegram.org/bots/api#message). |-------|----------|----------------------------------------------------------------------| | data | `string` | Data to send to a bot. Should not have size of more than 4096 bytes. | +### `web_app_exit_fullscreen` + +Available since: **v8.0** + +Requests exiting the fullscreen mode for mini app. + ### `web_app_expand` [Expands](viewport.md) the Mini App. @@ -376,34 +379,44 @@ the [clipboard_text_received](events.md#clipboard-text-received) event. Notifies Telegram about current application is ready to be shown. This method will make Telegram to remove application loader and display Mini App. -### `web_app_request_fullscreen` +### `web_app_request_content_safe_area` Available since: **v8.0** -Requests full screen mode for mini app. +Requests the current content safe area information from Telegram. -### `web_app_exit_fullscreen` +As a result, Telegram triggers the +[**`content_safe_area_changed`**](events.md#content-safe-area-changed) event. + +### `web_app_request_fullscreen` Available since: **v8.0** -Requests exiting full screen mode for mini app. +Requests full screen mode for mini app. ### `web_app_request_phone` Available since: **v6.9** -[//]: # (TODO: Check if it is right. It probably requests other user phone.) - Requests access to current user's phone. +### `web_app_request_safe_area` + +Available since: **v8.0** + +Requests the current safe area information from Telegram. + +As a result, Telegram triggers the +[**`safe_area_changed`**](events.md#safe-area-changed) event. + ### `web_app_request_theme` -Requests current [theme](theming.md) from Telegram. As a result, Telegram will +Requests the current [theme](theming.md) from Telegram. As a result, Telegram will create [theme_changed](events.md#theme-changed) event. ### `web_app_request_viewport` -Requests current [viewport](viewport.md) information from Telegram. As a result, +Requests the current [viewport](viewport.md) information from Telegram. As a result, Telegram will create [viewport_changed](events.md#viewport-changed) event. ### `web_app_request_write_access` diff --git a/apps/docs/platform/viewport.md b/apps/docs/platform/viewport.md index 3ad266ba6..952436e44 100644 --- a/apps/docs/platform/viewport.md +++ b/apps/docs/platform/viewport.md @@ -12,6 +12,7 @@ Viewport data is defined by the following properties: - **`expansion`**: A boolean flag that is `true` when the Mini App has reached its maximum height. - **`fullscreen`**: A boolean flag indicating whether the application is displayed in fullscreen mode. +- **`safe area`**: An information describing the viewport content safe area and insets. ## Expanding @@ -48,4 +49,26 @@ This mode is particularly suitable for games and media-focused applications. To control fullscreen mode, Telegram Mini Apps provides such methods as [web_app_request_fullscreen](methods.md#web_app_request_fullscreen) -and [web_app_exit_fullscreen](methods.md#web_app_exit_fullscreen). \ No newline at end of file +and [web_app_exit_fullscreen](methods.md#web_app_exit_fullscreen). + +[//]: # (TODO: Learn more and write this section) +[//]: # (## Safe Area) + +[//]: # () +[//]: # (In mini apps, the **safe area** refers to the portion of the screen that is free from) + +[//]: # (obstructions like notches, status bars, navigation bars, or rounded screen edges. It ensures that) + +[//]: # (essential content is displayed properly and not hidden or truncated.) + +[//]: # () +[//]: # (Using the safe area is crucial for delivering a seamless user experience, especially on devices with) + +[//]: # (modern screen designs (e.g., iPhones with notches or Android devices with rounded corners).) + +[//]: # (Developers typically use CSS properties or platform-specific guidelines) + +[//]: # ((e.g., `env(safe-area-inset-*)` in CSS) to adjust the layout within the safe area boundaries, but) + +[//]: # (in Telegram Mini Apps, these values are passed manually from the Telegram application.) + diff --git a/packages/bridge/src/events/types/events.ts b/packages/bridge/src/events/types/events.ts index c6f0b096f..247234081 100644 --- a/packages/bridge/src/events/types/events.ts +++ b/packages/bridge/src/events/types/events.ts @@ -7,6 +7,7 @@ import type { BiometryAuthRequestStatus, BiometryType, BiometryTokenUpdateStatus, + SafeAreaInsets, FullScreenErrorStatus, } from './misc.js'; @@ -123,6 +124,13 @@ export interface Events { */ data?: string | null; }; + /** + * Occurs when the safe area for content changes + * (e.g., due to orientation change or screen adjustments). + * @since Mini Apps v8.0 + * @see https://docs.telegram-mini-apps.com/platform/events#content_safe_area_changed + * */ + content_safe_area_changed: SafeAreaInsets; /** * Custom method invocation completed. * @since v6.9 @@ -222,6 +230,13 @@ export interface Events { * @see https://docs.telegram-mini-apps.com/platform/events#reload-iframe */ reload_iframe: never; + /** + * Occurs whenever the device's safe area insets change + * (e.g., due to orientation change or screen adjustments). + * @since Mini Apps v8.0 + * @see https://docs.telegram-mini-apps.com/platform/events#safe_area_changed + * */ + safe_area_changed: SafeAreaInsets; /** * QR scanner was closed. * @since v6.4 diff --git a/packages/bridge/src/events/types/misc.ts b/packages/bridge/src/events/types/misc.ts index 47acfc24d..2ef5d539f 100644 --- a/packages/bridge/src/events/types/misc.ts +++ b/packages/bridge/src/events/types/misc.ts @@ -22,4 +22,11 @@ export type BiometryAuthRequestStatus = 'failed' | 'authorized' | string; export type FullScreenErrorStatus = | 'ALREADY_FULLSCREEN' | 'UNSUPPORTED' - | string; \ No newline at end of file + | string; + +export interface SafeAreaInsets { + top: number; + bottom: number; + left: number; + right: number; +} \ No newline at end of file diff --git a/packages/bridge/src/methods/supports.ts b/packages/bridge/src/methods/supports.ts index e67f1c877..7582c62a2 100644 --- a/packages/bridge/src/methods/supports.ts +++ b/packages/bridge/src/methods/supports.ts @@ -101,6 +101,8 @@ export function supports( case 'web_app_setup_secondary_button': case 'web_app_set_bottom_bar_color': return versionLessOrEqual('7.10', paramOrVersion); + case 'web_app_request_safe_area': + case 'web_app_request_content_safe_area': case 'web_app_request_fullscreen': case 'web_app_exit_fullscreen': return versionLessOrEqual('8.0', paramOrVersion); diff --git a/packages/bridge/src/methods/types/methods.ts b/packages/bridge/src/methods/types/methods.ts index 9265a41ab..61088f1f0 100644 --- a/packages/bridge/src/methods/types/methods.ts +++ b/packages/bridge/src/methods/types/methods.ts @@ -180,6 +180,12 @@ export interface Methods { */ data: string; }>; + /** + * Exits fullscreen mode for miniapp. + * @since v8.0 + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-exit-fullscreen + */ + web_app_exit_fullscreen: CreateMethodParams; /** * Expands the Mini App. * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-expand @@ -276,23 +282,29 @@ export interface Methods { */ web_app_ready: CreateMethodParams; /** - * Requests to open the mini app in fullscreen. + * Requests content safe area of the user's phone. * @since v8.0 - * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-fullscreen + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-content-safe-area */ - web_app_request_fullscreen: CreateMethodParams; + web_app_request_content_safe_area: CreateMethodParams; /** - * Exits fullscreen mode for miniapp. + * Requests to open the mini app in fullscreen. * @since v8.0 - * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-exit-fullscreen + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-fullscreen */ - web_app_exit_fullscreen: CreateMethodParams; + web_app_request_fullscreen: CreateMethodParams; /** * Requests access to current user's phone. * @since v6.9 * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-phone */ web_app_request_phone: CreateMethodParams; + /** + * Requests safe area of the user's phone. + * @since v8.0 + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-safe-area + */ + web_app_request_safe_area: CreateMethodParams; /** * Requests current theme from Telegram. As a result, Telegram will create `theme_changed` event. * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-theme diff --git a/packages/sdk/src/scopes/components/biometry/methods.ts b/packages/sdk/src/scopes/components/biometry/methods.ts index 063f494d9..ed1566827 100644 --- a/packages/sdk/src/scopes/components/biometry/methods.ts +++ b/packages/sdk/src/scopes/components/biometry/methods.ts @@ -210,7 +210,6 @@ export const mount = wrapBasic('mount', createMountFn( setState(result); }, isMounted, - state, mountPromise, mountError, )); diff --git a/packages/sdk/src/scopes/components/viewport/const.ts b/packages/sdk/src/scopes/components/viewport/const.ts new file mode 100644 index 000000000..5589d0461 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/const.ts @@ -0,0 +1,7 @@ +export const COMPONENT_NAME = 'viewport'; +export const REQUEST_FS_METHOD = 'web_app_request_fullscreen'; +export const FS_FAILED_EVENT = 'fullscreen_failed'; +export const FS_CHANGED_EVENT = 'fullscreen_changed'; +export const VIEWPORT_CHANGED_EVENT = 'viewport_changed'; +export const REQUEST_SAFE_AREA_METHOD = 'web_app_request_safe_area'; +export const REQUEST_CONTENT_SAFE_AREA_METHOD = 'web_app_request_content_safe_area'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/exports.ts b/packages/sdk/src/scopes/components/viewport/exports.ts index 92d12fbcf..fa3ec2775 100644 --- a/packages/sdk/src/scopes/components/viewport/exports.ts +++ b/packages/sdk/src/scopes/components/viewport/exports.ts @@ -20,14 +20,24 @@ export { stableHeight as viewportStableHeight, unmount as unmountViewport, width as viewportWidth, + safeAreaInsetBottom as viewportSafeAreaInsetBottom, + safeAreaInsetLeft as viewportSafeAreaInsetLeft, + safeAreaInsetRight as viewportSafeAreaInsetRight, + safeAreaInsetTop as viewportSafeAreaInsetTop, + safeAreaInsets as viewportSafeAreaInsets, + contentSafeAreaInsets as viewportContentSafeAreaInsets, + contentSafeAreaInsetTop as viewportContentSafeAreaInsetTop, + contentSafeAreaInsetBottom as viewportContentSafeAreaInsetBottom, + contentSafeAreaInsetLeft as viewportContentSafeAreaInsetLeft, + contentSafeAreaInsetRight as viewportContentSafeAreaInsetRight, } from './exports.variable.js'; export * as viewport from './exports.variable.js'; -export { - requestViewport, - type RequestViewportResult -} from './requestViewport.js'; +export { requestContentSafeAreaInsets } from './methods/static/requestContentSafeAreaInsets.js'; +export { requestSafeAreaInsets } from './methods/static/requestSafeAreaInsets.js'; +export { requestViewport, type RequestViewportResult } from './methods/static/requestViewport.js'; export type { State as ViewportState, GetCSSVarNameFn as ViewportGetCSSVarNameFn, + GetCSSVarNameKey as ViewportGetCSSVarNameKey, } from './types.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/exports.variable.ts b/packages/sdk/src/scopes/components/viewport/exports.variable.ts index e28a59f7d..752c39410 100644 --- a/packages/sdk/src/scopes/components/viewport/exports.variable.ts +++ b/packages/sdk/src/scopes/components/viewport/exports.variable.ts @@ -1,25 +1,32 @@ +export { requestFullscreen } from './methods/fullscreen/requestFullscreen.js'; +export { exitFullscreen } from './methods/fullscreen/exitFullscreen.js'; +export { mount } from './methods/mounting/mount.js'; +export { unmount } from './methods/mounting/unmount.js'; +export { bindCssVars } from './methods/bindCssVars.js'; +export { expand } from './methods/expand.js'; + +export { + contentSafeAreaInsetRight, + contentSafeAreaInsetLeft, + contentSafeAreaInsetBottom, + contentSafeAreaInsetTop, + contentSafeAreaInsets, +} from './signals/content-safe-area-insets.js'; +export { isCssVarsBound } from './signals/css-vars.js'; +export { stableHeight, width, height } from './signals/dimensions.js'; +export { isStable, isExpanded } from './signals/flags.js'; export { changeFullscreenError, changeFullscreenPromise, - height, - isMounted, - isStable, - isChangingFullscreen, - isMounting, - isExpanded, isFullscreen, - isCssVarsBound, - mountPromise, - mountError, - state, - stableHeight, - width, -} from './signals.js'; + isChangingFullscreen, +} from './signals/fullscreen.js'; +export { isMounted, isMounting, mountError, mountPromise } from './signals/mounting.js'; export { - bindCssVars, - exitFullscreen, - expand, - mount, - requestFullscreen, - unmount, -} from './methods.js'; \ No newline at end of file + safeAreaInsets, + safeAreaInsetTop, + safeAreaInsetRight, + safeAreaInsetLeft, + safeAreaInsetBottom, +} from './signals/safe-area-insets.js'; +export { state } from './signals/state.js'; diff --git a/packages/sdk/src/scopes/components/viewport/methods.ts b/packages/sdk/src/scopes/components/viewport/methods.ts deleted file mode 100644 index 895a3689f..000000000 --- a/packages/sdk/src/scopes/components/viewport/methods.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { - off, - on, - retrieveLaunchParams, - camelToKebab, - getStorageValue, - setStorageValue, - deleteCssVar, - setCssVar, - type EventListener, CancelablePromise, AsyncOptions, TypedError, -} from '@telegram-apps/bridge'; -import { isPageReload } from '@telegram-apps/navigation'; - -import { postEvent, request } from '@/scopes/globals.js'; -import { createMountFn } from '@/scopes/createMountFn.js'; -import { createWrapMounted } from '@/scopes/toolkit/createWrapMounted.js'; -import { createWrapBasic } from '@/scopes/toolkit/createWrapBasic.js'; -import { throwCssVarsBound } from '@/scopes/toolkit/throwCssVarsBound.js'; -import { ERR_FULLSCREEN_FAILED } from '@/errors.js'; -import { removeUndefined } from '@/utils/removeUndefined.js'; -import { createWrapComplete } from '@/scopes/toolkit/createWrapComplete.js'; -import { signalifyAsyncFn } from '@/scopes/signalifyAsyncFn.js'; - -import { requestViewport } from './requestViewport.js'; -import { - state, - mountError, - isMounted, - isCssVarsBound, - mountPromise, - changeFullscreenPromise, - changeFullscreenError, - isFullscreen, -} from './signals.js'; -import type { GetCSSVarNameFn } from './types.js'; -import type { State } from './types.js'; - -type StorageValue = State; - -const COMPONENT_NAME = 'viewport'; -const FS_REQUEST_METHOD_NAME = 'web_app_request_fullscreen'; -const FS_FAILED_EVENT_NAME = 'fullscreen_failed'; -const FS_CHANGED_EVENT_NAME = 'fullscreen_changed'; - -const wrapBasic = createWrapBasic(COMPONENT_NAME); -const wrapMounted = createWrapMounted(COMPONENT_NAME, isMounted); -const wrapFSComplete = createWrapComplete(COMPONENT_NAME, isMounted, FS_REQUEST_METHOD_NAME); - -/** - * Creates CSS variables connected with the current viewport. - * - * By default, created CSS variables names are following the pattern - * "--tg-theme-{name}", where - * {name} is a theme parameters key name converted from camel case to kebab - * case. - * - * Default variables: - * - `--tg-viewport-height` - * - `--tg-viewport-width` - * - `--tg-viewport-stable-height` - * - * Variables are being automatically updated if the viewport was changed. - * - * @param getCSSVarName - function, returning complete CSS variable name for - * the specified viewport property. - * @returns Function to stop updating variables. - * @throws {TypedError} ERR_UNKNOWN_ENV - * @throws {TypedError} ERR_VARS_ALREADY_BOUND - * @throws {TypedError} ERR_NOT_MOUNTED - * @throws {TypedError} ERR_NOT_INITIALIZED - * @example Using no arguments - * if (bindCssVars.isAvailable()) { - * bindCssVars(); - * } - * @example Using custom CSS vars generator - * if (bindCssVars.isAvailable()) { - * bindCssVars(key => `--my-prefix-${key}`); - * } - */ -export const bindCssVars = wrapMounted( - 'bindCssVars', - (getCSSVarName?: GetCSSVarNameFn): VoidFunction => { - isCssVarsBound() && throwCssVarsBound(); - - getCSSVarName ||= (prop) => `--tg-viewport-${camelToKebab(prop)}`; - const props = ['height', 'width', 'stableHeight'] as const; - - function actualize(): void { - props.forEach(prop => { - setCssVar(getCSSVarName!(prop), `${state()[prop]}px`); - }); - } - - actualize(); - state.sub(actualize); - isCssVarsBound.set(true); - - return () => { - props.forEach(deleteCssVar); - state.unsub(actualize); - isCssVarsBound.set(false); - }; - }, -); - -/** - * A method that expands the Mini App to the maximum available height. To find - * out if the Mini App is expanded to the maximum height, refer to the value of - * the `isExpanded`. - * @throws {TypedError} ERR_UNKNOWN_ENV - * @throws {TypedError} ERR_NOT_INITIALIZED - * @see isExpanded - * @example - * if (expand.isAvailable()) { - * expand(); - * } - */ -export const expand = wrapBasic('expand', (): void => { - postEvent('web_app_expand'); -}); - -/** - * Mounts the Viewport component. - * @throws {TypedError} ERR_UNKNOWN_ENV - * @throws {TypedError} ERR_NOT_INITIALIZED - * @throws {TypedError} ERR_ALREADY_MOUNTING - * @example - * if (mount.isAvailable() && !isMounting()) { - * await mount(); - * } - */ -export const mount = wrapBasic('mount', createMountFn( - COMPONENT_NAME, - (options) => { - // Try to restore the state using the storage. - const s = isPageReload() && getStorageValue(COMPONENT_NAME); - if (s) { - return s; - } - - // If the platform has a stable viewport, it means we could use the - // window global object properties. - const lp = retrieveLaunchParams(); - const isFullscreen = !!lp.fullscreen; - if (['macos', 'tdesktop', 'unigram', 'webk', 'weba', 'web'].includes(lp.platform)) { - const w = window; - return { - isExpanded: true, - isFullscreen, - height: w.innerHeight, - width: w.innerWidth, - stableHeight: w.innerHeight, - }; - } - - // We were unable to retrieve data locally. In this case, we are - // sending a request returning the viewport information. - return requestViewport(options).then(data => ({ - height: data.height, - isExpanded: data.isExpanded, - isFullscreen, - stableHeight: data.isStable ? data.height : state().stableHeight, - width: data.width, - })); - }, - () => { - on('viewport_changed', onViewportChanged); - on(FS_CHANGED_EVENT_NAME, onFullscreenChanged); - }, - isMounted, - state, - mountPromise, - mountError, -)); - -const onViewportChanged: EventListener<'viewport_changed'> = (data) => { - setState({ - height: data.height, - isExpanded: data.is_expanded, - stableHeight: data.is_state_stable ? data.height : undefined, - width: data.width, - }); -}; - -const onFullscreenChanged: EventListener<'fullscreen_changed'> = (data) => { - setState({ isFullscreen: data.is_fullscreen }); -}; - -function fsChangeGen( - method: string, - requestMethod: 'web_app_exit_fullscreen' | 'web_app_request_fullscreen', -) { - return wrapFSComplete(method, signalifyAsyncFn( - (options?: AsyncOptions): CancelablePromise => { - return request(requestMethod, [FS_CHANGED_EVENT_NAME, FS_FAILED_EVENT_NAME], options) - .then(result => { - if ('error' in result) { - if (result.error === 'ALREADY_FULLSCREEN') { - return true; - } - throw new TypedError(ERR_FULLSCREEN_FAILED, 'Fullscreen request failed', result.error); - } - return result.is_fullscreen; - }) - .then(result => { - isFullscreen() !== result && setState({ isFullscreen: result }); - }); - }, - () => new TypedError('abc'), - changeFullscreenPromise, - changeFullscreenError, - )); -} - -/** - * Requests fullscreen mode for the mini application. - * @since Mini Apps v8.0 - * @throws {TypedError} ERR_UNKNOWN_ENV - * @throws {TypedError} ERR_NOT_INITIALIZED - * @throws {TypedError} ERR_NOT_MOUNTED - * @throws {TypedError} ERR_NOT_SUPPORTED - * @throws {TypedError} ERR_FULLSCREEN_FAILED - * @example Using `isAvailable()` - * if (requestFullscreen.isAvailable() && !isChangingFullscreen()) { - * await requestFullscreen(); - * } - * @example Using `ifAvailable()` - * if (!isChangingFullscreen()) { - * await requestFullscreen.ifAvailable(); - * } - */ -export const requestFullscreen = fsChangeGen( - 'requestFullscreen', - FS_REQUEST_METHOD_NAME, -); - -/** - * Exits mini application fullscreen mode. - * @since Mini Apps v8.0 - * @throws {TypedError} ERR_UNKNOWN_ENV - * @throws {TypedError} ERR_NOT_INITIALIZED - * @throws {TypedError} ERR_NOT_MOUNTED - * @throws {TypedError} ERR_NOT_SUPPORTED - * @throws {TypedError} ERR_FULLSCREEN_FAILED - * @example Using `isAvailable()` - * if (exitFullscreen.isAvailable() && !isChangingFullscreen()) { - * await exitFullscreen(); - * } - * @example Using `ifAvailable()` - * if (!isChangingFullscreen()) { - * await exitFullscreen.ifAvailable(); - * } - */ -export const exitFullscreen = fsChangeGen( - 'exitFullscreen', - 'web_app_exit_fullscreen', -); - -function setState(s: Partial) { - const { height, stableHeight, width } = s; - - state.set({ - ...state(), - ...removeUndefined({ - ...s, - height: height ? truncate(height) : undefined, - width: width ? truncate(width) : undefined, - stableHeight: stableHeight ? truncate(stableHeight) : undefined, - }), - }); - setStorageValue(COMPONENT_NAME, state()); -} - -/** - * Formats value to make it stay in bounds [0, +Inf). - * @param value - value to format. - */ -function truncate(value: number): number { - return Math.max(value, 0); -} - -/** - * Unmounts the Viewport. - */ -export function unmount(): void { - // Cancel mount promise. - const promise = mountPromise(); - promise && promise.cancel(); - - // Remove event listeners. - off('viewport_changed', onViewportChanged); - off('fullscreen_changed', onFullscreenChanged); - - // Drop the mount flag. - isMounted.set(false); -} diff --git a/packages/sdk/src/scopes/components/viewport/methods/bindCssVars.test.ts b/packages/sdk/src/scopes/components/viewport/methods/bindCssVars.test.ts new file mode 100644 index 000000000..580f4676f --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/bindCssVars.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, describe, vi } from 'vitest'; + +import { testSafety } from '@test-utils/predefined/testSafety.js'; +import { resetPackageState } from '@test-utils/reset/reset.js'; +import { mockPostEvent } from '@test-utils/mockPostEvent.js'; + +import { isMounted } from '../signals/mounting.js'; +import { bindCssVars } from '@/scopes/components/viewport/methods/bindCssVars.js'; + +beforeEach(() => { + resetPackageState(); + vi.restoreAllMocks(); + mockPostEvent(); +}); + +describe('safety', () => { + testSafety(bindCssVars, 'bindCssVars', { + component: 'viewport', + isMounted, + }); +}); diff --git a/packages/sdk/src/scopes/components/viewport/methods/bindCssVars.ts b/packages/sdk/src/scopes/components/viewport/methods/bindCssVars.ts new file mode 100644 index 000000000..dcc3cbbe1 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/bindCssVars.ts @@ -0,0 +1,112 @@ +import { camelToKebab, deleteCssVar, setCssVar } from '@telegram-apps/bridge'; + +import { throwCssVarsBound } from '@/scopes/toolkit/throwCssVarsBound.js'; + +import { isCssVarsBound } from '../signals/css-vars.js'; +import { height, width, stableHeight } from '../signals/dimensions.js'; +import { + safeAreaInsetBottom, + safeAreaInsetTop, + safeAreaInsetRight, + safeAreaInsetLeft, +} from '../signals/safe-area-insets.js'; +import { + contentSafeAreaInsetBottom, + contentSafeAreaInsetTop, + contentSafeAreaInsetRight, + contentSafeAreaInsetLeft, +} from '../signals/content-safe-area-insets.js'; + +import { wrapMounted } from './wrappers.js'; +import type { GetCSSVarNameFn } from '../types.js'; + +/** + * Creates CSS variables connected with the current viewport. + * + * By default, created CSS variables names are following the pattern + * "--tg-theme-{name}", where + * {name} is a theme parameters key name converted from camel case to kebab + * case. + * + * Default variables: + * - `--tg-viewport-height` + * - `--tg-viewport-width` + * - `--tg-viewport-stable-height` + * - `--tg-viewport-content-safe-area-inset-top` + * - `--tg-viewport-content-safe-area-inset-bottom` + * - `--tg-viewport-content-safe-area-inset-left` + * - `--tg-viewport-content-safe-area-inset-right` + * - `--tg-viewport-safe-area-inset-top` + * - `--tg-viewport-safe-area-inset-bottom` + * - `--tg-viewport-safe-area-inset-left` + * - `--tg-viewport-safe-area-inset-right` + * + * Variables are being automatically updated if the viewport was changed. + * + * @param getCSSVarName - function, returning computed complete CSS variable name. The CSS + * variable will only be defined if the function returned non-empty string value. + * @returns Function to stop updating variables. + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_VARS_ALREADY_BOUND + * @throws {TypedError} ERR_NOT_MOUNTED + * @throws {TypedError} ERR_NOT_INITIALIZED + * @example Using no arguments + * if (bindCssVars.isAvailable()) { + * bindCssVars(); + * } + * @example Using custom CSS vars generator + * if (bindCssVars.isAvailable()) { + * bindCssVars(key => `--my-prefix-${key}`); + * } + */ +export const bindCssVars = wrapMounted( + 'bindCssVars', + (getCSSVarName?: GetCSSVarNameFn): VoidFunction => { + isCssVarsBound() && throwCssVarsBound(); + + getCSSVarName ||= (prop) => `--tg-viewport-${camelToKebab(prop)}`; + + const settings = ([ + ['height', height], + ['stableHeight', stableHeight], + ['width', width], + ['safeAreaInsetTop', safeAreaInsetTop], + ['safeAreaInsetBottom', safeAreaInsetBottom], + ['safeAreaInsetLeft', safeAreaInsetLeft], + ['safeAreaInsetRight', safeAreaInsetRight], + ['contentSafeAreaInsetTop', contentSafeAreaInsetTop], + ['contentSafeAreaInsetBottom', contentSafeAreaInsetBottom], + ['contentSafeAreaInsetLeft', contentSafeAreaInsetLeft], + ['contentSafeAreaInsetRight', contentSafeAreaInsetRight], + ] as const).reduce<[ + update: VoidFunction, + removeListener: VoidFunction, + cssVar: string + ][]>((acc, [key, signal]) => { + const cssVar = getCSSVarName(key); + if (cssVar) { + const update = () => { + setCssVar(cssVar, `${signal()}px`); + }; + acc.push([update, signal.sub(update), cssVar]); + } + return acc; + }, []); + + // Instantly set CSS variables. + settings.forEach(setting => { + setting[0](); + }); + isCssVarsBound.set(true); + + return () => { + settings.forEach(s => { + // Remove update listener. + s[1](); + // Remove CSS variable. + deleteCssVar(s[2]); + }); + isCssVarsBound.set(false); + }; + }, +); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/methods/expand.test.ts b/packages/sdk/src/scopes/components/viewport/methods/expand.test.ts new file mode 100644 index 000000000..8e2a32bba --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/expand.test.ts @@ -0,0 +1,19 @@ +import { beforeEach, describe, vi } from 'vitest'; + +import { testSafety } from '@test-utils/predefined/testSafety.js'; +import { resetPackageState } from '@test-utils/reset/reset.js'; +import { mockPostEvent } from '@test-utils/mockPostEvent.js'; + +import { expand } from '@/scopes/components/viewport/methods/expand.js'; + +beforeEach(() => { + resetPackageState(); + vi.restoreAllMocks(); + mockPostEvent(); +}); + +describe('safety', () => { + testSafety(expand, 'expand', { + component: 'viewport', + }); +}); diff --git a/packages/sdk/src/scopes/components/viewport/methods/expand.ts b/packages/sdk/src/scopes/components/viewport/methods/expand.ts new file mode 100644 index 000000000..c2f4701fd --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/expand.ts @@ -0,0 +1,19 @@ +import { postEvent } from '@/scopes/globals.js'; + +import { wrapBasic } from './wrappers.js'; + +/** + * A method that expands the Mini App to the maximum available height. To find + * out if the Mini App is expanded to the maximum height, refer to the value of + * the `isExpanded`. + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @see isExpanded + * @example + * if (expand.isAvailable()) { + * expand(); + * } + */ +export const expand = wrapBasic('expand', (): void => { + postEvent('web_app_expand'); +}); diff --git a/packages/sdk/src/scopes/components/viewport/methods/fullscreen/createFullscreenFn.ts b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/createFullscreenFn.ts new file mode 100644 index 000000000..8d26a9a69 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/createFullscreenFn.ts @@ -0,0 +1,50 @@ +import { signalifyAsyncFn } from '@/scopes/signalifyAsyncFn.js'; +import { type AsyncOptions, CancelablePromise, TypedError } from '@telegram-apps/bridge'; + +import { request } from '@/scopes/globals.js'; +import { ERR_ALREADY_REQUESTING, ERR_FULLSCREEN_FAILED } from '@/errors.js'; +import { createWrapComplete } from '@/scopes/toolkit/createWrapComplete.js'; + +import { + COMPONENT_NAME, + FS_CHANGED_EVENT, + FS_FAILED_EVENT, + REQUEST_FS_METHOD, +} from '../../const.js'; +import { + changeFullscreenError, + changeFullscreenPromise, + isFullscreen, +} from '../../signals/fullscreen.js'; +import { isMounted } from '../../signals/mounting.js'; +import { setState } from '@/scopes/components/viewport/signals/state.js'; + +const wrapComplete = createWrapComplete(COMPONENT_NAME, isMounted, REQUEST_FS_METHOD); + +export function createFullscreenFn( + method: string, + requestMethod: 'web_app_exit_fullscreen' | 'web_app_request_fullscreen', +) { + return wrapComplete(method, signalifyAsyncFn( + (options?: AsyncOptions): CancelablePromise => { + return request(requestMethod, [FS_CHANGED_EVENT, FS_FAILED_EVENT], options) + .then(result => { + if ('error' in result) { + if (result.error === 'ALREADY_FULLSCREEN') { + return true; + } + throw new TypedError(ERR_FULLSCREEN_FAILED, 'Fullscreen request failed', result.error); + } + return result.is_fullscreen; + }) + .then(value => { + value !== isFullscreen() && setState({ isFullscreen: value }); + }); + }, + () => { + return new TypedError(ERR_ALREADY_REQUESTING, 'Fullscreen mode change is already being requested'); + }, + changeFullscreenPromise, + changeFullscreenError, + )); +} diff --git a/packages/sdk/src/scopes/components/viewport/methods/fullscreen/exitFullscreen.test.ts b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/exitFullscreen.test.ts new file mode 100644 index 000000000..530ac9581 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/exitFullscreen.test.ts @@ -0,0 +1,22 @@ +import { beforeEach, describe, vi } from 'vitest'; + +import { testSafety } from '@test-utils/predefined/testSafety.js'; +import { resetPackageState } from '@test-utils/reset/reset.js'; +import { mockPostEvent } from '@test-utils/mockPostEvent.js'; + +import { isMounted } from '../../signals/mounting.js'; +import { exitFullscreen } from './exitFullscreen.js'; + +beforeEach(() => { + resetPackageState(); + vi.restoreAllMocks(); + mockPostEvent(); +}); + +describe('safety', () => { + testSafety(exitFullscreen, 'exitFullscreen', { + component: 'viewport', + minVersion: '8.0', + isMounted, + }); +}); diff --git a/packages/sdk/src/scopes/components/viewport/methods/fullscreen/exitFullscreen.ts b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/exitFullscreen.ts new file mode 100644 index 000000000..3bff94999 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/exitFullscreen.ts @@ -0,0 +1,20 @@ +import { createFullscreenFn } from './createFullscreenFn.js'; + +/** + * Exits mini application fullscreen mode. + * @since Mini Apps v8.0 + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_MOUNTED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @throws {TypedError} ERR_FULLSCREEN_FAILED + * @example Using `isAvailable()` + * if (exitFullscreen.isAvailable() && !isChangingFullscreen()) { + * await exitFullscreen(); + * } + * @example Using `ifAvailable()` + * if (!isChangingFullscreen()) { + * await exitFullscreen.ifAvailable(); + * } + */ +export const exitFullscreen = createFullscreenFn('exitFullscreen', 'web_app_exit_fullscreen'); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/methods/fullscreen/requestFullscreen.test.ts b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/requestFullscreen.test.ts new file mode 100644 index 000000000..e367785a8 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/requestFullscreen.test.ts @@ -0,0 +1,22 @@ +import { beforeEach, describe, vi } from 'vitest'; + +import { testSafety } from '@test-utils/predefined/testSafety.js'; +import { resetPackageState } from '@test-utils/reset/reset.js'; +import { mockPostEvent } from '@test-utils/mockPostEvent.js'; + +import { isMounted } from '../../signals/mounting.js'; +import { requestFullscreen } from './requestFullscreen.js'; + +beforeEach(() => { + resetPackageState(); + vi.restoreAllMocks(); + mockPostEvent(); +}); + +describe('safety', () => { + testSafety(requestFullscreen, 'requestFullscreen', { + component: 'viewport', + minVersion: '8.0', + isMounted, + }); +}); diff --git a/packages/sdk/src/scopes/components/viewport/methods/fullscreen/requestFullscreen.ts b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/requestFullscreen.ts new file mode 100644 index 000000000..218ddd150 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/fullscreen/requestFullscreen.ts @@ -0,0 +1,21 @@ +import { createFullscreenFn } from './createFullscreenFn.js'; +import { REQUEST_FS_METHOD } from '../../const.js'; + +/** + * Requests fullscreen mode for the mini application. + * @since Mini Apps v8.0 + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_MOUNTED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @throws {TypedError} ERR_FULLSCREEN_FAILED + * @example Using `isAvailable()` + * if (requestFullscreen.isAvailable() && !isChangingFullscreen()) { + * await requestFullscreen(); + * } + * @example Using `ifAvailable()` + * if (!isChangingFullscreen()) { + * await requestFullscreen.ifAvailable(); + * } + */ +export const requestFullscreen = createFullscreenFn('requestFullscreen', REQUEST_FS_METHOD); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/methods.test.ts b/packages/sdk/src/scopes/components/viewport/methods/mounting/mount.test.ts similarity index 65% rename from packages/sdk/src/scopes/components/viewport/methods.test.ts rename to packages/sdk/src/scopes/components/viewport/methods/mounting/mount.test.ts index afc21eca9..faeb48f8a 100644 --- a/packages/sdk/src/scopes/components/viewport/methods.test.ts +++ b/packages/sdk/src/scopes/components/viewport/methods/mounting/mount.test.ts @@ -5,10 +5,13 @@ import { testSafety } from '@test-utils/predefined/testSafety.js'; import { resetPackageState } from '@test-utils/reset/reset.js'; import { mockPostEvent } from '@test-utils/mockPostEvent.js'; import { mockMiniAppsEnv } from '@test-utils/mockMiniAppsEnv.js'; -import { setMaxVersion } from '@test-utils/setMaxVersion.js'; -import { mount, expand, bindCssVars, exitFullscreen, requestFullscreen } from './methods.js'; -import { isMounted } from './signals.js'; +import { mount } from '@/scopes/components/viewport/methods/mounting/mount.js'; +import { isMounted } from '@/scopes/components/viewport/signals/mounting.js'; +import { isFullscreen } from '@/scopes/components/viewport/signals/fullscreen.js'; +import { isExpanded } from '@/scopes/components/viewport/signals/flags.js'; +import { $version } from '@/scopes/globals.js'; +import { state } from '@/scopes/components/viewport/signals/state.js'; beforeEach(() => { resetPackageState(); @@ -16,24 +19,16 @@ beforeEach(() => { mockPostEvent(); }); -describe.each([ - ['mount', mount, undefined, undefined], - ['expand', expand, undefined, undefined], - ['bindCssVars', bindCssVars, isMounted, undefined], - ['exitFullscreen', exitFullscreen, isMounted, '8.0'], - ['requestFullscreen', requestFullscreen, isMounted, '8.0'], -] as const)('%s', (name, fn, isMounted, minVersion) => { - testSafety(fn, name, { +describe('safety', () => { + testSafety(mount, 'mount', { component: 'viewport', - minVersion, - isMounted, }); }); -describe('mount', () => { +describe('is safe', () => { beforeEach(() => { mockMiniAppsEnv(); - setMaxVersion(); + $version.set('7.9'); }); it('should set isMounted = true', async () => { @@ -49,43 +44,56 @@ describe('mount', () => { it('should use values from session storage key "tapps/viewport"', async () => { const storageState = { + contentSafeAreaInsets: { + bottom: 331, + left: 2, + right: 5, + top: 1, + }, + height: 333, isExpanded: true, isFullscreen: true, - height: 1000, - width: 2000, - stableHeight: 1000, + safeAreaInsets: { + bottom: 55, + left: 12, + right: 31, + top: 5, + }, + stableHeight: 12, + width: 444, }; const spy = mockSessionStorageGetItem(() => { return JSON.stringify(storageState); }); - const state = await mount(); + await mount(); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('tapps/viewport'); - expect(state).toEqual(storageState); + expect(state()).toEqual(storageState); }); it('should set isFullscreen false if session storage key "tapps/viewport" is not present', async () => { const spy = mockSessionStorageGetItem(() => null); - const state = await mount(); + await mount(); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('tapps/viewport'); - expect(state.isFullscreen).toBe(false); + expect(isFullscreen()).toBe(false); }); it('should set isExpanded true if session storage key "tapps/viewport" is not present', async () => { const spy = mockSessionStorageGetItem(() => null); - const state = await mount(); + await mount(); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('tapps/viewport'); - expect(state.isExpanded).toBe(true); + expect(isExpanded()).toBe(true); }); }); describe('first launch', () => { it('should set isFullscreen false', async () => { - expect((await mount()).isFullscreen).toBe(false) + await mount(); + expect(isFullscreen()).toBe(false); }); // TODO: Incorrect test. This value depends on the platform also. diff --git a/packages/sdk/src/scopes/components/viewport/methods/mounting/mount.ts b/packages/sdk/src/scopes/components/viewport/methods/mounting/mount.ts new file mode 100644 index 000000000..b61b26ba0 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/mounting/mount.ts @@ -0,0 +1,86 @@ +import { isPageReload } from '@telegram-apps/navigation'; +import { CancelablePromise, on, retrieveLaunchParams } from '@telegram-apps/bridge'; + +import { createMountFn } from '@/scopes/createMountFn.js'; + +import { wrapBasic } from '../wrappers.js'; +import { COMPONENT_NAME, FS_CHANGED_EVENT, VIEWPORT_CHANGED_EVENT } from '../../const.js'; +import { isMounted, mountPromise, mountError } from '../../signals/mounting.js'; +import { getStateFromStorage, setState } from '../../signals/state.js'; +import { safeAreaInsets } from '../../signals/safe-area-insets.js'; +import { contentSafeAreaInsets } from '../../signals/content-safe-area-insets.js'; +import { requestContentSafeAreaInsets } from '../static/requestContentSafeAreaInsets.js'; +import { requestSafeAreaInsets } from '../static/requestSafeAreaInsets.js'; +import { requestViewport } from '../static/requestViewport.js'; +import type { State } from '../../types.js'; + +import { onFullscreenChanged, onViewportChanged } from './shared.js'; + +/** + * Mounts the Viewport component. + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_ALREADY_MOUNTING + * @example + * if (mount.isAvailable() && !isMounting()) { + * await mount(); + * } + */ +export const mount = wrapBasic('mount', createMountFn( + COMPONENT_NAME, + (options) => { + return CancelablePromise.resolve().then(async () => { + // Try to restore the state using the storage. + const s = isPageReload() && getStateFromStorage(); + if (s) { + return s; + } + + // Request all insets. + const [ + retrievedSafeAreaInsets, + retrievedContentSafeAreaInsets, + ] = await CancelablePromise.all([ + requestSafeAreaInsets.ifAvailable(options) || safeAreaInsets(), + requestContentSafeAreaInsets.ifAvailable(options) || contentSafeAreaInsets(), + ]); + + // If the platform has a stable viewport, it means we could use the window global object + // properties. + const lp = retrieveLaunchParams(); + const shared = { + contentSafeAreaInsets: retrievedContentSafeAreaInsets, + isFullscreen: !!lp.fullscreen, + safeAreaInsets: retrievedSafeAreaInsets, + }; + if (['macos', 'tdesktop', 'unigram', 'webk', 'weba', 'web'].includes(lp.platform)) { + const w = window; + return { + ...shared, + height: w.innerHeight, + isExpanded: true, + stableHeight: w.innerHeight, + width: w.innerWidth, + }; + } + + // We were unable to retrieve data locally. In this case, we are + // sending a request returning the viewport information. + return requestViewport(options).then(data => ({ + ...shared, + height: data.height, + isExpanded: data.isExpanded, + stableHeight: data.isStable ? data.height : 0, + width: data.width, + })); + }); + }, + (result) => { + on(VIEWPORT_CHANGED_EVENT, onViewportChanged); + on(FS_CHANGED_EVENT, onFullscreenChanged); + setState(result); + }, + isMounted, + mountPromise, + mountError, +)); diff --git a/packages/sdk/src/scopes/components/viewport/methods/mounting/shared.ts b/packages/sdk/src/scopes/components/viewport/methods/mounting/shared.ts new file mode 100644 index 000000000..dda231e6d --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/mounting/shared.ts @@ -0,0 +1,18 @@ +import type { EventListener } from '@telegram-apps/bridge'; + +import { isFullscreen } from '../../signals/fullscreen.js'; +import { setState } from '../../signals/state.js'; + +export const onViewportChanged: EventListener<'viewport_changed'> = (data) => { + const { height } = data; + setState({ + isExpanded: data.is_expanded, + height, + width: data.width, + stableHeight: data.is_state_stable ? height : undefined, + }); +}; + +export const onFullscreenChanged: EventListener<'fullscreen_changed'> = (data) => { + isFullscreen.set(data.is_fullscreen); +}; diff --git a/packages/sdk/src/scopes/components/viewport/methods/mounting/unmount.ts b/packages/sdk/src/scopes/components/viewport/methods/mounting/unmount.ts new file mode 100644 index 000000000..346583cc3 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/mounting/unmount.ts @@ -0,0 +1,24 @@ +import { off } from '@telegram-apps/bridge'; + +import { FS_CHANGED_EVENT, VIEWPORT_CHANGED_EVENT } from '../../const.js'; +import { isMounted, mountPromise } from '../../signals/mounting.js'; + +import { onFullscreenChanged, onViewportChanged } from './shared.js'; + +/** + * Unmounts the Viewport. + */ +export function unmount(): void { + // Cancel mount promise. + const promise = mountPromise(); + promise && promise.cancel(); + + // TODO: Cancel all promises? + + // Remove event listeners. + off(VIEWPORT_CHANGED_EVENT, onViewportChanged); + off(FS_CHANGED_EVENT, onFullscreenChanged); + + // Drop the mount flag. + isMounted.set(false); +} \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/methods/static/requestContentSafeAreaInsets.ts b/packages/sdk/src/scopes/components/viewport/methods/static/requestContentSafeAreaInsets.ts new file mode 100644 index 000000000..8d77d43ab --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/static/requestContentSafeAreaInsets.ts @@ -0,0 +1,24 @@ +import type { AsyncOptions } from '@telegram-apps/bridge'; + +import { wrapSafe } from '@/scopes/toolkit/wrapSafe.js'; +import { request } from '@/scopes/globals.js'; + +import { REQUEST_CONTENT_SAFE_AREA_METHOD } from '../../const.js'; + +/** + * Requests the actual viewport content safe area insets information. + * @param options - request options. + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @since Mini Apps v8.0 + * @example + * if (requestContentSafeAreaInsets.isAvailable()) { + * const insets = await requestContentSafeAreaInsets(); + * } + */ +export const requestContentSafeAreaInsets = wrapSafe( + 'requestContentSafeAreaInsets', + (options?: AsyncOptions) => request(REQUEST_CONTENT_SAFE_AREA_METHOD, 'content_safe_area_changed', options), + { isSupported: REQUEST_CONTENT_SAFE_AREA_METHOD }, +); diff --git a/packages/sdk/src/scopes/components/viewport/methods/static/requestSafeAreaInsets.ts b/packages/sdk/src/scopes/components/viewport/methods/static/requestSafeAreaInsets.ts new file mode 100644 index 000000000..e9db29cc4 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/static/requestSafeAreaInsets.ts @@ -0,0 +1,24 @@ +import type { AsyncOptions } from '@telegram-apps/bridge'; + +import { wrapSafe } from '@/scopes/toolkit/wrapSafe.js'; +import { request } from '@/scopes/globals.js'; + +import { REQUEST_SAFE_AREA_METHOD } from '../../const.js'; + +/** + * Requests the actual viewport safe area insets information. + * @param options - request options. + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @since Mini Apps v8.0 + * @example + * if (requestSafeAreaInsets.isAvailable()) { + * const insets = await requestSafeAreaInsets(); + * } + */ +export const requestSafeAreaInsets = wrapSafe( + 'requestSafeAreaInsets', + (options?: AsyncOptions) => request(REQUEST_SAFE_AREA_METHOD, 'safe_area_changed', options), + { isSupported: REQUEST_SAFE_AREA_METHOD }, +); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/requestViewport.ts b/packages/sdk/src/scopes/components/viewport/methods/static/requestViewport.ts similarity index 65% rename from packages/sdk/src/scopes/components/viewport/requestViewport.ts rename to packages/sdk/src/scopes/components/viewport/methods/static/requestViewport.ts index dbcf97c34..712e24ce8 100644 --- a/packages/sdk/src/scopes/components/viewport/requestViewport.ts +++ b/packages/sdk/src/scopes/components/viewport/methods/static/requestViewport.ts @@ -1,7 +1,4 @@ -import type { - ExecuteWithOptions, - CancelablePromise, -} from '@telegram-apps/bridge'; +import type { CancelablePromise, AsyncOptions } from '@telegram-apps/bridge'; import { request as _request } from '@/scopes/globals.js'; @@ -16,13 +13,11 @@ export interface RequestViewportResult { * Requests viewport actual information from the Telegram application. * @param options - request options. * @example - * const viewport = await request({ - * timeout: 1000 - * }); + * if (requestViewport.isAvailable()) { + * const viewport = await requestViewport(); + * } */ -export function requestViewport( - options?: ExecuteWithOptions, -): CancelablePromise { +export function requestViewport(options?: AsyncOptions): CancelablePromise { return _request('web_app_request_viewport', 'viewport_changed', options).then(r => ({ height: r.height, width: r.width, diff --git a/packages/sdk/src/scopes/components/viewport/methods/wrappers.ts b/packages/sdk/src/scopes/components/viewport/methods/wrappers.ts new file mode 100644 index 000000000..3a292b745 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/methods/wrappers.ts @@ -0,0 +1,9 @@ +import { createWrapBasic } from '@/scopes/toolkit/createWrapBasic.js'; +import { createWrapMounted } from '@/scopes/toolkit/createWrapMounted.js'; + +import { isMounted } from '../signals/mounting.js'; +import { COMPONENT_NAME } from '../const.js'; + +export const wrapBasic = createWrapBasic(COMPONENT_NAME); + +export const wrapMounted = createWrapMounted(COMPONENT_NAME, isMounted); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals.ts b/packages/sdk/src/scopes/components/viewport/signals.ts deleted file mode 100644 index 98567bdf3..000000000 --- a/packages/sdk/src/scopes/components/viewport/signals.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { computed, type Computed, signal } from '@telegram-apps/signals'; -import { CancelablePromise } from '@telegram-apps/toolkit'; - -import type { State } from './types.js'; - -/* USUAL */ - -/** - * Complete component state. - */ -export const state = signal({ - height: 0, - width: 0, - isFullscreen: false, - isExpanded: false, - stableHeight: 0, -}); - -//#region Mount. - -/** - * Signal indicating if the component is currently mounted. - */ -export const isMounted = signal(false); - -/** - * Signal indicating if the component is currently mounting. - */ -export const isMounting = computed(() => !!mountPromise()); - -/** - * Signal containing the error occurred during mount. - */ -export const mountError = signal(undefined); - -/** - * Signal containing the mount process promise. - */ -export const mountPromise = signal | undefined>(); - -//#endregion - -/** - * True if CSS variables are currently bound. - */ -export const isCssVarsBound = signal(false); - -/* COMPUTED */ - -function createStateComputed(key: K): Computed { - return computed(() => state()[key]); -} - -/** - * The current height of the **visible area** of the Mini App. - * - * The application can display just the top part of the Mini App, with its - * lower part remaining outside the screen area. From this position, the user - * can "pull" the Mini App to its maximum height, while the bot can do the same - * by calling `expand` method. As the position of the Mini App changes, the - * current height value of the visible area will be updated in real time. - * - * Please note that the refresh rate of this value is not sufficient to - * smoothly follow the lower border of the window. It should not be used to pin - * interface elements to the bottom of the visible area. It's more appropriate - * to use the value of the `stableHeight` field for this purpose. - * - * @see stableHeight - */ -export const height = createStateComputed('height'); - -/** - * True if the Mini App is expanded to the maximum available height. Otherwise, - * if the Mini App occupies part of the screen and can be expanded to the full - * height using - * `expand` method. - * @see expand - */ -export const isExpanded = createStateComputed('isExpanded'); - -/** - * True if the current viewport height is stable and is not going to change in - * the next moment. - */ -export const isStable = computed(() => { - const s = state(); - return s.height === s.stableHeight; -}); - -/** - * The height of the visible area of the Mini App in its last stable state. - * - * The application can display just the top part of the Mini App, with its - * lower part remaining outside the screen area. From this position, the user - * can "pull" the Mini App to its maximum height, while the application can do - * the same by calling `expand` method. - * - * Unlike the value of `height`, the value of `stableHeight` does not change as - * the position of the Mini App changes with user gestures or during - * animations. The value of `stableHeight` will be updated after all gestures - * and animations are completed and the Mini App reaches its final size. - * - * @see height - */ -export const stableHeight = createStateComputed('stableHeight'); - -/** - * Currently visible area width. - */ -export const width = createStateComputed('width'); - -//#region Fullscreen mode. - -/** - * Signal indicating if the viewport is currently in fullscreen mode. - */ -export const isFullscreen = createStateComputed('isFullscreen'); - -/** - * Signal containing fullscreen request or exit promise. - */ -export const changeFullscreenPromise = signal>(); - -/** - * Signal indicating if the fullscreen mode request is currently in progress. - */ -export const isChangingFullscreen = computed(() => { - return !!changeFullscreenPromise(); -}); - -/** - * Signal containing an error received during the last fullscreen mode request. - */ -export const changeFullscreenError = signal(); - -//#endregion \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals/content-safe-area-insets.ts b/packages/sdk/src/scopes/components/viewport/signals/content-safe-area-insets.ts new file mode 100644 index 000000000..518334e96 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/content-safe-area-insets.ts @@ -0,0 +1,14 @@ +import { type Computed, computed } from '@telegram-apps/signals'; +import type { SafeAreaInsets } from '@telegram-apps/bridge'; + +import { signalFromState } from './state.js'; + +function fromState(key: keyof SafeAreaInsets): Computed { + return computed(() => contentSafeAreaInsets()[key]); +} + +export const contentSafeAreaInsets = signalFromState('contentSafeAreaInsets'); +export const contentSafeAreaInsetBottom = fromState('bottom'); +export const contentSafeAreaInsetLeft = fromState('left'); +export const contentSafeAreaInsetRight = fromState('right'); +export const contentSafeAreaInsetTop = fromState('top'); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals/css-vars.ts b/packages/sdk/src/scopes/components/viewport/signals/css-vars.ts new file mode 100644 index 000000000..bed6549c6 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/css-vars.ts @@ -0,0 +1,6 @@ +import { signal } from '@telegram-apps/signals'; + +/** + * True if CSS variables are currently bound. + */ +export const isCssVarsBound = signal(false); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals/dimensions.ts b/packages/sdk/src/scopes/components/viewport/signals/dimensions.ts new file mode 100644 index 000000000..0e01bfe63 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/dimensions.ts @@ -0,0 +1,41 @@ +import { signalFromState } from './state.js'; + +/** + * Signal containing the current height of the **visible area** of the Mini App. + * + * The application can display just the top part of the Mini App, with its + * lower part remaining outside the screen area. From this position, the user + * can "pull" the Mini App to its maximum height, while the bot can do the same + * by calling `expand` method. As the position of the Mini App changes, the + * current height value of the visible area will be updated in real time. + * + * Please note that the refresh rate of this value is not sufficient to + * smoothly follow the lower border of the window. It should not be used to pin + * interface elements to the bottom of the visible area. It's more appropriate + * to use the value of the `stableHeight` field for this purpose. + * + * @see stableHeight + */ +export const height = signalFromState('height'); + +/** + * Signal containing the height of the visible area of the Mini App in its last stable state. + * + * The application can display just the top part of the Mini App, with its + * lower part remaining outside the screen area. From this position, the user + * can "pull" the Mini App to its maximum height, while the application can do + * the same by calling `expand` method. + * + * Unlike the value of `height`, the value of `stableHeight` does not change as + * the position of the Mini App changes with user gestures or during + * animations. The value of `stableHeight` will be updated after all gestures + * and animations are completed and the Mini App reaches its final size. + * + * @see height + */ +export const stableHeight = signalFromState('stableHeight'); + +/** + * Signal containing the currently visible area width. + */ +export const width = signalFromState('width'); diff --git a/packages/sdk/src/scopes/components/viewport/signals/flags.ts b/packages/sdk/src/scopes/components/viewport/signals/flags.ts new file mode 100644 index 000000000..8067da1b4 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/flags.ts @@ -0,0 +1,18 @@ +import { computed } from '@telegram-apps/signals'; + +import { signalFromState } from './state.js'; +import { height, stableHeight } from './dimensions.js'; + +/** + * Signal indicating if the Mini App is expanded to the maximum available height. Otherwise, + * if the Mini App occupies part of the screen and can be expanded to the full + * height using `expand` method. + * @see expand + */ +export const isExpanded = signalFromState('isExpanded'); + +/** + * Signal indicating if the current viewport height is stable and is not going to change in + * the next moment. + */ +export const isStable = computed(() => height() === stableHeight()); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals/fullscreen.ts b/packages/sdk/src/scopes/components/viewport/signals/fullscreen.ts new file mode 100644 index 000000000..218f6839c --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/fullscreen.ts @@ -0,0 +1,26 @@ +import { computed, signal } from '@telegram-apps/signals'; +import type { CancelablePromise } from '@telegram-apps/bridge'; + +import { signalFromState } from './state.js'; + +/** + * Signal indicating if the viewport is currently in fullscreen mode. + */ +export const isFullscreen = signalFromState('isFullscreen'); + +/** + * Signal containing fullscreen request or exit promise. + */ +export const changeFullscreenPromise = signal>(); + +/** + * Signal indicating if the fullscreen mode request is currently in progress. + */ +export const isChangingFullscreen = computed(() => { + return !!changeFullscreenPromise(); +}); + +/** + * Signal containing an error received during the last fullscreen mode request. + */ +export const changeFullscreenError = signal(); diff --git a/packages/sdk/src/scopes/components/viewport/signals/mounting.ts b/packages/sdk/src/scopes/components/viewport/signals/mounting.ts new file mode 100644 index 000000000..a740158f6 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/mounting.ts @@ -0,0 +1,24 @@ +import { computed, signal } from '@telegram-apps/signals'; +import type { CancelablePromise } from '@telegram-apps/bridge'; + +import { State } from '../types.js'; + +/** + * Signal indicating if the component is currently mounted. + */ +export const isMounted = signal(false); + +/** + * Signal indicating if the component is currently mounting. + */ +export const isMounting = computed(() => !!mountPromise()); + +/** + * Signal containing the error occurred during mount. + */ +export const mountError = signal(undefined); + +/** + * Signal containing the mount process promise. + */ +export const mountPromise = signal | undefined>(); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals/safe-area-insets.ts b/packages/sdk/src/scopes/components/viewport/signals/safe-area-insets.ts new file mode 100644 index 000000000..15cce499b --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/safe-area-insets.ts @@ -0,0 +1,14 @@ +import { type Computed, computed } from '@telegram-apps/signals'; +import type { SafeAreaInsets } from '@telegram-apps/bridge'; + +import { signalFromState } from './state.js'; + +function fromState(key: keyof SafeAreaInsets): Computed { + return computed(() => safeAreaInsets()[key]); +} + +export const safeAreaInsets = signalFromState('safeAreaInsets'); +export const safeAreaInsetBottom = fromState('bottom'); +export const safeAreaInsetLeft = fromState('left'); +export const safeAreaInsetRight = fromState('right'); +export const safeAreaInsetTop = fromState('top'); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/viewport/signals/state.ts b/packages/sdk/src/scopes/components/viewport/signals/state.ts new file mode 100644 index 000000000..f5c3010e0 --- /dev/null +++ b/packages/sdk/src/scopes/components/viewport/signals/state.ts @@ -0,0 +1,54 @@ +import { computed, type Computed, signal } from '@telegram-apps/signals'; +import { getStorageValue, type SafeAreaInsets, setStorageValue } from '@telegram-apps/bridge'; + +import { removeUndefined } from '@/utils/removeUndefined.js'; + +import { COMPONENT_NAME } from '../const.js'; +import type { State } from '../types.js'; + +const initialInsets: SafeAreaInsets = { + left: 0, + top: 0, + bottom: 0, + right: 0, +}; + +function nonNegative(value: number): number { + return Math.max(value, 0); +} + +/** + * Signal containing the component complete state. + */ +export const state = signal({ + contentSafeAreaInsets: initialInsets, + height: 0, + isExpanded: false, + isFullscreen: false, + safeAreaInsets: initialInsets, + stableHeight: 0, + width: 0, +}); + +export function signalFromState(key: K): Computed { + return computed(() => state()[key]); +} + +export function setState(s: Partial) { + const { height, stableHeight, width } = s; + + state.set({ + ...state(), + ...removeUndefined({ + ...s, + height: height ? nonNegative(height) : undefined, + width: width ? nonNegative(width) : undefined, + stableHeight: stableHeight ? nonNegative(stableHeight) : undefined, + }), + }); + setStorageValue(COMPONENT_NAME, state()); +} + +export function getStateFromStorage() { + return getStorageValue(COMPONENT_NAME); +} diff --git a/packages/sdk/src/scopes/components/viewport/types.ts b/packages/sdk/src/scopes/components/viewport/types.ts index 677bba379..3532e7d24 100644 --- a/packages/sdk/src/scopes/components/viewport/types.ts +++ b/packages/sdk/src/scopes/components/viewport/types.ts @@ -1,15 +1,22 @@ +import type { SafeAreaInsets } from '@telegram-apps/bridge'; + export interface State { + contentSafeAreaInsets: SafeAreaInsets; height: number; isExpanded: boolean; isFullscreen: boolean; + safeAreaInsets: SafeAreaInsets; stableHeight: number; width: number; } -export interface GetCSSVarNameFn { - /** - * @param property - viewport property. - * @returns Computed complete CSS variable name. - */ - (property: 'width' | 'height' | 'stableHeight'): string; -} +type SafeAreaInsetCSSVarKey = `safeAreaInset${Capitalize}`; + +export type GetCSSVarNameKey = + | 'width' + | 'height' + | 'stableHeight' + | SafeAreaInsetCSSVarKey + | `content${Capitalize}` + +export type GetCSSVarNameFn = (key: GetCSSVarNameKey) => string | null | undefined | false; diff --git a/packages/sdk/src/scopes/createMountFn.ts b/packages/sdk/src/scopes/createMountFn.ts index 1b912ef8c..fea1c6d44 100644 --- a/packages/sdk/src/scopes/createMountFn.ts +++ b/packages/sdk/src/scopes/createMountFn.ts @@ -14,7 +14,6 @@ import { signalifyAsyncFn } from '@/scopes/signalifyAsyncFn.js'; * @param mount - function mounting the component * @param onMounted - function that will be called whenever mount was completed. * @param isMounted - signal containing the current mount completion state - * @param data - signal containing the current mount state * @param promise - signal containing the mount promise * @param error - signal containing the mount error */ @@ -24,10 +23,9 @@ export function createMountFn( mount: (options?: AsyncOptions) => R | CancelablePromise, onMounted: (result: R) => void, isMounted: Signal, - data: Signal, promise: Signal | undefined>, error: Signal, -): (options?: AsyncOptions) => CancelablePromise { +): (options?: AsyncOptions) => CancelablePromise { const noConcurrent = signalifyAsyncFn( mount, () => new TypedError( @@ -43,12 +41,10 @@ export function createMountFn( if (!isMounted()) { const result = await noConcurrent(options); batch(() => { - data.set(result); isMounted.set(true); onMounted(result); }); } - return data(); }); }; } diff --git a/packages/sdk/src/scopes/signalifyAsyncFn.ts b/packages/sdk/src/scopes/signalifyAsyncFn.ts index ad8333906..8f6970f2e 100644 --- a/packages/sdk/src/scopes/signalifyAsyncFn.ts +++ b/packages/sdk/src/scopes/signalifyAsyncFn.ts @@ -1,9 +1,5 @@ import { batch, type Signal } from '@telegram-apps/signals'; -import { - type AsyncOptions, - CancelablePromise, - type TypedError, -} from '@telegram-apps/toolkit'; +import { type AsyncOptions, CancelablePromise, type TypedError } from '@telegram-apps/bridge'; type AllowedFn = (options?: AsyncOptions) => R | CancelablePromise; diff --git a/packages/sdk/test-utils/reset/reset.ts b/packages/sdk/test-utils/reset/reset.ts index 02645c19f..0678dd3a6 100644 --- a/packages/sdk/test-utils/reset/reset.ts +++ b/packages/sdk/test-utils/reset/reset.ts @@ -11,6 +11,7 @@ import { resetMainButton } from '@test-utils/reset/resetMainButton.js'; import { resetMiniApp } from '@test-utils/reset/resetMiniApp.js'; import { resetPopup } from '@test-utils/reset/resetPopup.js'; import { resetQrScanner } from '@test-utils/reset/resetQrScanner.js'; +import { resetSafeArea } from "@test-utils/reset/resetSafeArea.js"; import { resetSecondaryButton } from '@test-utils/reset/resetSecondaryButton.js'; import { resetSettingsButton } from '@test-utils/reset/resetSettingsButton.js'; import { resetSwipeBehavior } from '@test-utils/reset/resetSwipeBehavior.js'; @@ -36,6 +37,7 @@ export function resetPackageState() { resetPopup, resetPrivacy, resetQrScanner, + resetSafeArea, resetSecondaryButton, resetSettingsButton, resetSwipeBehavior, diff --git a/packages/sdk/test-utils/reset/resetSafeArea.ts b/packages/sdk/test-utils/reset/resetSafeArea.ts new file mode 100644 index 000000000..e7fdcc81b --- /dev/null +++ b/packages/sdk/test-utils/reset/resetSafeArea.ts @@ -0,0 +1,23 @@ +import { resetSignal } from '@test-utils/reset/reset.js'; + +import { + state, + mountError, + isMounted, + isCssVarsBound, + isMounting, + inset, + contentInset, +} from '@/scopes/components/safe-area/signals.js'; + +export function resetSafeArea() { + [ + state, + mountError, + isMounted, + isCssVarsBound, + isMounting, + inset, + contentInset, + ].forEach(resetSignal); +} \ No newline at end of file diff --git a/packages/sdk/test-utils/reset/resetViewport.ts b/packages/sdk/test-utils/reset/resetViewport.ts index 78b9ef17b..fc1553404 100644 --- a/packages/sdk/test-utils/reset/resetViewport.ts +++ b/packages/sdk/test-utils/reset/resetViewport.ts @@ -1,39 +1,53 @@ import { resetSignal } from '@test-utils/reset/reset.js'; import { - state, - mountError, - isMounted, - isCssVarsBound, + contentSafeAreaInsets, + contentSafeAreaInsetTop, + contentSafeAreaInsetBottom, + contentSafeAreaInsetLeft, + contentSafeAreaInsetRight, +} from '@/scopes/components/viewport/signals/content-safe-area-insets.js'; +import { isCssVarsBound } from '@/scopes/components/viewport/signals/css-vars.js'; +import { height, stableHeight, width } from '@/scopes/components/viewport/signals/dimensions.js'; +import { isExpanded, isStable } from '@/scopes/components/viewport/signals/flags.js'; +import { isMounting, - width, - isExpanded, - height, - stableHeight, - isStable, - isChangingFullscreen, - isFullscreen, - changeFullscreenError, - changeFullscreenPromise, + mountError, mountPromise, -} from '@/scopes/components/viewport/signals.js'; + isMounted, +} from '@/scopes/components/viewport/signals/mounting.js'; +import { + safeAreaInsetBottom, + safeAreaInsetLeft, + safeAreaInsetRight, + safeAreaInsetTop, + safeAreaInsets, +} from '@/scopes/components/viewport/signals/safe-area-insets.js'; +import { state } from '@/scopes/components/viewport/signals/state.js'; + export function resetViewport() { [ - state, - mountError, - isMounted, + contentSafeAreaInsets, + contentSafeAreaInsetTop, + contentSafeAreaInsetBottom, + contentSafeAreaInsetLeft, + contentSafeAreaInsetRight, isCssVarsBound, - isMounting, - width, - isExpanded, height, stableHeight, + width, + isExpanded, isStable, - isChangingFullscreen, - isFullscreen, - changeFullscreenError, - changeFullscreenPromise, + isMounting, + mountError, mountPromise, + isMounted, + safeAreaInsetBottom, + safeAreaInsetLeft, + safeAreaInsetRight, + safeAreaInsetTop, + safeAreaInsets, + state, ].forEach(resetSignal); } \ No newline at end of file diff --git a/playgrounds/react/src/components/Page.tsx b/playgrounds/react/src/components/Page.tsx index 775f687b9..d149da3a5 100644 --- a/playgrounds/react/src/components/Page.tsx +++ b/playgrounds/react/src/components/Page.tsx @@ -1,8 +1,8 @@ import { useNavigate } from 'react-router-dom'; -import { backButton } from '@telegram-apps/sdk-react'; +import { backButton, safeArea, useSignal } from '@telegram-apps/sdk-react'; import { PropsWithChildren, useEffect } from 'react'; -export function Page({ children, back = true }: PropsWithChildren<{ +export function Page({children, back = true}: PropsWithChildren<{ /** * True if it is allowed to go back from this page. */ @@ -10,6 +10,9 @@ export function Page({ children, back = true }: PropsWithChildren<{ }>) { const navigate = useNavigate(); + const inset = useSignal(safeArea.inset); + const contentInset = useSignal(safeArea.contentInset); + useEffect(() => { if (back) { backButton.show(); @@ -20,5 +23,11 @@ export function Page({ children, back = true }: PropsWithChildren<{ backButton.hide(); }, [back]); - return <>{children}; + return
+ {children} +
; } \ No newline at end of file diff --git a/playgrounds/react/src/init.ts b/playgrounds/react/src/init.ts index ba1a93929..4ad919bcc 100644 --- a/playgrounds/react/src/init.ts +++ b/playgrounds/react/src/init.ts @@ -2,6 +2,7 @@ import { backButton, viewport, themeParams, + safeArea, miniApp, initData, $debug, @@ -15,6 +16,11 @@ export function init(debug: boolean): void { // Set @telegram-apps/sdk-react debug mode. $debug.set(debug); + // Add Eruda if needed. + debug && import('eruda') + .then((lib) => lib.default.init()) + .catch(console.error); + // Initialize special event handlers for Telegram Desktop, Android, iOS, etc. Also, configure // the package. initSDK(); @@ -24,7 +30,13 @@ export function init(debug: boolean): void { miniApp.mount(); themeParams.mount(); initData.restore(); - + + void safeArea.mount().then(() => { + safeArea.bindCssVars(); + }).catch((e: any) => { + console.error('Something went wrong mounting the safe area', e); + }); + void viewport.mount().then(() => { // Define components-related CSS variables. viewport.bindCssVars(); @@ -33,9 +45,4 @@ export function init(debug: boolean): void { }).catch((e: any) => { console.error('Something went wrong mounting the viewport', e); }); - - // Add Eruda if needed. - debug && import('eruda') - .then((lib) => lib.default.init()) - .catch(console.error); } \ No newline at end of file diff --git a/playgrounds/react/src/navigation/routes.tsx b/playgrounds/react/src/navigation/routes.tsx index 2bb13c924..cb1af34e8 100644 --- a/playgrounds/react/src/navigation/routes.tsx +++ b/playgrounds/react/src/navigation/routes.tsx @@ -5,6 +5,7 @@ import { InitDataPage } from '@/pages/InitDataPage.tsx'; import { LaunchParamsPage } from '@/pages/LaunchParamsPage.tsx'; import { ThemeParamsPage } from '@/pages/ThemeParamsPage.tsx'; import { TONConnectPage } from '@/pages/TONConnectPage/TONConnectPage'; +import { SafeAreaParamsPage } from "@/pages/SafeAreaParamsPage.tsx"; import { ViewportParamsPage } from "@/pages/ViewportParamsPage.tsx"; interface Route { @@ -20,6 +21,7 @@ export const routes: Route[] = [ { path: '/launch-params', Component: LaunchParamsPage, title: 'Launch Params' }, { path: '/theme-params', Component: ThemeParamsPage, title: 'Theme Params' }, { path: '/viewport-params', Component: ViewportParamsPage, title: 'Viewport Params' }, + { path: '/safe-area-params', Component: SafeAreaParamsPage, title: 'SafeArea Params' }, { path: '/ton-connect', Component: TONConnectPage, diff --git a/playgrounds/react/src/pages/IndexPage/IndexPage.tsx b/playgrounds/react/src/pages/IndexPage/IndexPage.tsx index 8f4b19626..2e49187d6 100644 --- a/playgrounds/react/src/pages/IndexPage/IndexPage.tsx +++ b/playgrounds/react/src/pages/IndexPage/IndexPage.tsx @@ -36,6 +36,9 @@ export const IndexPage: FC = () => { Theme Parameters + + Safe Area Parameters + Viewport Parameters diff --git a/playgrounds/react/src/pages/SafeAreaParamsPage.tsx b/playgrounds/react/src/pages/SafeAreaParamsPage.tsx new file mode 100644 index 000000000..17910cae4 --- /dev/null +++ b/playgrounds/react/src/pages/SafeAreaParamsPage.tsx @@ -0,0 +1,51 @@ +import { + safeAreaInset, + contentSafeAreaInset, + useSignal, +} from '@telegram-apps/sdk-react'; + +import {List} from '@telegram-apps/telegram-ui'; +import {FC, useEffect, useState} from 'react'; + +import {DisplayData, DisplayDataRow} from '@/components/DisplayData/DisplayData.tsx'; +import {Page} from '@/components/Page.tsx'; + +export const SafeAreaParamsPage: FC = () => { + const inset = useSignal(safeAreaInset); + const contentInset = useSignal(contentSafeAreaInset); + + const [safeAreaRows, setSafeAreaRows] = useState([]); + const [contentSafeAreaRows, setContentSafeAreaRows] = useState([]); + + const getRows = (fn: typeof inset): DisplayDataRow[] => { + return [ + {title: 'top', value: fn.top}, + {title: 'bottom', value: fn.bottom}, + {title: 'left', value: fn.left}, + {title: 'right', value: fn.right}, + ]; + } + + useEffect(() => { + setSafeAreaRows(getRows(inset)); + }, [inset]); + + useEffect(() => { + setContentSafeAreaRows(getRows(contentInset)); + }, [contentInset]); + + return ( + + + + + + + ); +};