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/.vitepress/packages.ts b/apps/docs/.vitepress/packages.ts index e40bf2cc9..4c0cda36f 100644 --- a/apps/docs/.vitepress/packages.ts +++ b/apps/docs/.vitepress/packages.ts @@ -101,6 +101,7 @@ export const packagesLinksGenerator = (prefix: string = "") => { scope('mini-app'), scope('popup'), scope('qr-scanner', 'QR Scanner'), + scope('safe-area'), scope('secondary-button'), scope('settings-button'), scope('swipe-behavior'), diff --git a/apps/docs/.vitepress/platform.ts b/apps/docs/.vitepress/platform.ts index 5438bdc22..374103263 100644 --- a/apps/docs/.vitepress/platform.ts +++ b/apps/docs/.vitepress/platform.ts @@ -18,6 +18,7 @@ export const platformLinksGenerator = (prefix: string = "") => { section("Functional Features", { "Closing Behavior": "closing-behavior", "Swipe Behavior": "swipe-behavior", + "Safe Area": "safe-area", "Haptic Feedback": "haptic-feedback", }), section("Apps Communication", { diff --git a/apps/docs/packages/telegram-apps-sdk/2-x/components/safe-area.md b/apps/docs/packages/telegram-apps-sdk/2-x/components/safe-area.md new file mode 100644 index 000000000..22bdb4afd --- /dev/null +++ b/apps/docs/packages/telegram-apps-sdk/2-x/components/safe-area.md @@ -0,0 +1,229 @@ +# Safe Area + +The 💠[component](../scopes.md) responsible for the Telegram Mini +Apps [safe area](../../../../platform/safe-area.md). + +## Mounting + +Before using the component, it is necessary to mount it to work with properly configured properties. +To do so, use the `mount` method. It will update the `isMounted` signal property. + +::: code-group + +```ts [Variable] +import { safeArea } from '@telegram-apps/sdk'; + +if (safeArea.mount.isAvailable()) { + safeArea.mount(); + safeArea.isMounted(); // true +} +``` + +```ts [Functions] +import { + mountSafeArea, + isSafeAreaMounted, +} from '@telegram-apps/sdk'; + +if (mountSafeArea.isAvailable()) { + mountSafeArea(); + isSafeAreaMounted(); // true +} +``` + +::: + +To unmount, use the `unmount` method: + +::: code-group + +```ts [Variable] +safeArea.unmount(); +safeArea.isMounted(); // false +``` + +```ts [Functions] +import { + unmountSafeArea, + isSafeAreaMounted, +} from '@telegram-apps/sdk'; + +unmountSafeArea(); +isSafeAreaMounted(); // false +``` + +::: + +## Binding CSS Variables + +To expose the `safeArea` properties via CSS variables, use the `bindCssVars` method. +The `isCssVarsBound` signal property is updated after the method is called. + +This method optionally accepts a function that transforms the values `top`, `bottom`, `left` +and `right` into CSS variable names. By default, values are converted to kebab case with the +prefix `--tg-safe-area-` and `--tg-content-safe-area`. + +::: code-group + +```ts [Variable] +import { safeArea } from '@telegram-apps/sdk'; + +if (safeArea.bindCssVars.isAvailable()) { + safeArea.bindCssVars(); + // Creates CSS variables like: + // --tg-safe-area-inset-top: 0px; + // --tg-safe-area-inset-bottom: 30px; + // --tg-safe-area-inset-left: 40px; + // --tg-safe-area-inset-right: 40px; + + // --tg-content-safe-area-inset-top: 40px; + // --tg-content-safe-area-inset-bottom: 0px; + // --tg-content-safe-area-inset-left: 0px; + // --tg-content-safe-area-inset-right: 0px; + + safeArea.bindCssVars((component, property) => `--my-prefix-${component}-${property}`); + // Creates CSS variables like: + // --my-prefix-safeArea-top: 0px; + // --my-prefix-safeArea-bottom: 30px; + // --my-prefix-safeArea-bottom: 40px; + // --my-prefix-safeArea-right: 40px; + + // --my-prefix-contentSafeArea-top: 40px; + // --my-prefix-contentSafeArea-bottom: 0px; + // --my-prefix-contentSafeArea-left: 0px; + // --my-prefix-contentSafeArea-right: 0px; + + safeArea.isCssVarsBound(); // true +} +``` + +```ts [Functions] +import { + bindSafeAreaCssVars, + isSafeAreaCssVarsBound, +} from '@telegram-apps/sdk'; + +if (bindSafeAreaCssVars.isAvailable()) { + bindSafeAreaCssVars(); + // Creates CSS variables like: + // --tg-safe-area-inset-top: 0px; + // --tg-safe-area-inset-bottom: 30px; + // --tg-safe-area-inset-left: 40px; + // --tg-safe-area-inset-right: 40px; + + // --tg-content-safe-area-inset-top: 40px; + // --tg-content-safe-area-inset-bottom: 0px; + // --tg-content-safe-area-inset-left: 0px; + // --tg-content-safe-area-inset-right: 0px; + + bindSafeAreaCssVars((component, property) => `--my-prefix-${component}-${property}`); + // Creates CSS variables like: + // --my-prefix-safeArea-top: 0px; + // --my-prefix-safeArea-bottom: 30px; + // --my-prefix-safeArea-bottom: 40px; + // --my-prefix-safeArea-right: 40px; + + // --my-prefix-contentSafeArea-top: 40px; + // --my-prefix-contentSafeArea-bottom: 0px; + // --my-prefix-contentSafeArea-left: 0px; + // --my-prefix-contentSafeArea-right: 0px; + + isSafeAreaCssVarsBound(); // true +} +``` + +::: + +## Types + +### SafeAreaInset + +Type representing `safe area` and `content safe area` paddings: + +```ts [Variable] +type SafeAreaInset = { + top: number, + bottom: number, + left: number, + right: number +}; +``` + +### State + +Type representing `full state` of `safe area`: + +```ts [Variable] +type State = { + inset: SafeAreaInset, + contentSafeArea: SafeAreaInset +}; +``` + +## Signals + +This section provides a complete list of signals related to the init data. + +## `inset` + +Return type: `SafeAreaInset` + +To get safeArea state, use the `inset` method. + +::: code-group + +```ts [Variable] +safeArea.inset(); // { top: 0, bottom: 30, left: 40, right: 40 } +``` + +```ts [Functions] +import { safeAreaInset } from '@telegram-apps/sdk'; + +safeAreaInset(); // { top: 0, bottom: 30, left: 40, right: 40 } +``` + +::: + +## `contentInset` + +To get contentSafeArea state, use the `contentInset` method. + +::: code-group + +```ts [Variable] +safeArea.contentInset(); // { top: 40, bottom: 0, left: 0, right: 0 } +``` + +```ts [Functions] +import { contentSafeAreaInset } from '@telegram-apps/sdk'; + +contentSafeAreaInset(); // { top: 40, bottom: 0, left: 0, right: 0 } +``` + +::: + +## `state` + +To get full safe area state, use the `state` method. + +::: code-group + +```ts [Variable] +safeArea.state(); +// { +// inset: { top: 0, bottom: 30, left: 40, right: 40 } +// contentInset: { top: 40, bottom: 0, left: 0, right: 0 } +// } +``` + +```ts [Functions] +import { safeAreaState } from '@telegram-apps/sdk'; + +safeAreaState(); +// { +// inset: { top: 0, bottom: 30, left: 40, right: 40 } +// contentInset: { top: 40, bottom: 0, left: 0, right: 0 } +// } +``` + +::: \ No newline at end of file diff --git a/apps/docs/platform/events.md b/apps/docs/platform/events.md index ada8f76d4..1610d5739 100644 --- a/apps/docs/platform/events.md +++ b/apps/docs/platform/events.md @@ -31,8 +31,8 @@ interface MessageJSON { Then, lets imagine how we could process an event from Telegram application: ```typescript -window.addEventListener('message', ({ data }) => { - const { eventType, eventData } = JSON.parse(data); +window.addEventListener('message', ({data}) => { + const {eventType, eventData} = JSON.parse(data); console.log(eventType, eventData); }); ``` @@ -84,7 +84,7 @@ package, which greatly eases integration. Here's how to use it: ```ts -import { on } from '@telegram-apps/sdk'; +import {on} from '@telegram-apps/sdk'; // Start listening to "viewport_changed" event. Returned value // is a function, which removes this event listener. @@ -239,8 +239,8 @@ Available since: **v6.9** Application received phone access request status. -| Field | Type | Description | -|--------|----------|-----------------------------------------| +| Field | Type | Description | +|--------|----------|----------------------------------------------------| | status | `string` | Request status. Can only be `sent` or `cancelled`. | ### `popup_closed` @@ -298,6 +298,33 @@ including switching to night mode). |--------------|--------------------------|--------------------------------------------------------------------------------------------------------| | theme_params | `Record` | Map where the key is a theme stylesheet key and value is the corresponding color in `#RRGGBB` format. | +### `safe_area_changed` + +Occurs whenever [the safe area](safe_area.md) was changed in the user's Telegram app. +For example, user switched to landscape mode. +Safe area helps to avoid overlap 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. Also available as the CSS variable var(--tg-safe-area-inset-top). | +| bottom | `number` | The bottom inset in pixels, representing the space to avoid at the bottom of the screen. Also available as the CSS variable var(--tg-safe-area-inset-bottom). | +| left | `number` | The left inset in pixels, representing the space to avoid on the left side of the screen. Also available as the CSS variable var(--tg-safe-area-inset-left). | +| right | `number` | The right inset in pixels, representing the space to avoid on the right side of the screen. Also available as the CSS variable var(--tg-safe-area-inset-right). | + +### `content_safe_area_changed` + +Occurs whenever [the content safe area](safe_area.md) was changed in the user's Telegram app. +For example, user switched to landscape mode. +Safe area helps to avoid overlap with avoiding overlap with Telegram UI elements. +Content Safe Area is inside Device Safe Area and only covers Telegram UI. + +| Field | Type | Description | +|--------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| top | `number` | The top inset in pixels, representing the space to avoid at the top of the content area. Also available as the CSS variable var(--tg-content-safe-area-inset-top). | +| bottom | `number` | The bottom inset in pixels, representing the space to avoid at the bottom of the content area. Also available as the CSS variable var(--tg-content-safe-area-inset-bottom). | +| left | `number` | The left inset in pixels, representing the space to avoid on the left side of the content area. Also available as the CSS variable var(--tg-content-safe-area-inset-left). | +| right | `number` | The right inset in pixels, representing the space to avoid on the right side of the content area. Also available as the CSS variable var(--tg-content-safe-area-inset-right). | + ### `viewport_changed` Occurs whenever the [viewport](viewport.md) has been changed. For example, when the diff --git a/apps/docs/platform/methods.md b/apps/docs/platform/methods.md index a141c1f7a..1ebfcb8ca 100644 --- a/apps/docs/platform/methods.md +++ b/apps/docs/platform/methods.md @@ -389,6 +389,20 @@ Requests access to current user's phone. Requests current [theme](theming.md) from Telegram. As a result, Telegram will create [theme_changed](events.md#theme-changed) event. +### `web_app_request_safe_area` + +Available since: **v8.0** + +Requests current [safe area](safe-area.md) information from Telegram. As a result, +Telegram will create [safe_area_changed](events.md#safe-area-changed) event. + +### `web_app_request_content_safe_area` + +Available since: **v8.0** + +Requests current [content safe area](content-safe-area.md) information from Telegram. As a result, +Telegram will create [content_safe_area_changed](events.md#content-safe-area-changed) event. + ### `web_app_request_viewport` Requests current [viewport](viewport.md) information from Telegram. As a result, diff --git a/apps/docs/platform/safe-area.md b/apps/docs/platform/safe-area.md new file mode 100644 index 000000000..f9a2a587b --- /dev/null +++ b/apps/docs/platform/safe-area.md @@ -0,0 +1,20 @@ +# Safe Area + +The **safe area** ensures your app's content on mobile screens is fully visible and usable +by preventing it from being obscured by device features like notches or bottom dock (navigation bars) and Telegram UI features like buttons in [full screen mode](full-screen.md). + +In TMA there are 2 different Safe Area objects: +- Safe Area Inset +- Content Safe Area Inset + +## Safe Area Inset + +This Safe Area represents paddings from **Device UI** like notches, bottom dock (navigation bars) and other possible **System UI** elements. + +![Safe Area Inset](/components/safe-area/inset.jpg) + +## Content Safe Area Inset + +This Safe Area represents paddings from **Telegram UI** like buttons in [full screen mode](full-screen.md). + +![Content Safe Area Inset](/components/safe-area/content-inset.jpg) \ No newline at end of file diff --git a/apps/docs/public/components/safe-area/content-inset.jpg b/apps/docs/public/components/safe-area/content-inset.jpg new file mode 100644 index 000000000..776e97281 Binary files /dev/null and b/apps/docs/public/components/safe-area/content-inset.jpg differ diff --git a/apps/docs/public/components/safe-area/inset.jpg b/apps/docs/public/components/safe-area/inset.jpg new file mode 100644 index 000000000..62a430e04 Binary files /dev/null and b/apps/docs/public/components/safe-area/inset.jpg differ diff --git a/packages/bridge/src/events/types/events.ts b/packages/bridge/src/events/types/events.ts index 514f0ebcf..ef910981b 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, + SafeAreaInset, } from './misc.js'; /** @@ -217,6 +218,20 @@ export interface Events { * @see https://docs.telegram-mini-apps.com/platform/events#settings-button-pressed */ settings_button_pressed: never; + /** + * Occurs when the device's safe area insets change + * (e.g., due to orientation change or screen adjustments). + * @since 8.0 + * @see https://docs.telegram-mini-apps.com/platform/events#safe_area_changed + * */ + safe_area_changed: SafeAreaInset; + /** + * Occurs when the safe area for content changes + * (e.g., due to orientation change or screen adjustments). + * @since 8.0 + * @see https://docs.telegram-mini-apps.com/platform/events#content_safe_area_changed + * */ + content_safe_area_changed: SafeAreaInset; /** * Occurs whenever theme settings are changed in the user's Telegram app * (including switching to night mode). diff --git a/packages/bridge/src/events/types/misc.ts b/packages/bridge/src/events/types/misc.ts index d44d4c9c0..0b295c81b 100644 --- a/packages/bridge/src/events/types/misc.ts +++ b/packages/bridge/src/events/types/misc.ts @@ -13,4 +13,6 @@ export type BiometryType = 'finger' | 'face' | string; export type BiometryTokenUpdateStatus = 'updated' | 'removed' | 'failed' | string; -export type BiometryAuthRequestStatus = 'failed' | 'authorized' | string; \ No newline at end of file +export type BiometryAuthRequestStatus = 'failed' | 'authorized' | string; + +export type SafeAreaInset = { 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 13bf19dc6..2f9774fba 100644 --- a/packages/bridge/src/methods/supports.ts +++ b/packages/bridge/src/methods/supports.ts @@ -101,6 +101,9 @@ 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': + return versionLessOrEqual('8.0', paramOrVersion); default: return [ 'iframe_ready', diff --git a/packages/bridge/src/methods/types/methods.ts b/packages/bridge/src/methods/types/methods.ts index 7a87a657f..f2528e9e4 100644 --- a/packages/bridge/src/methods/types/methods.ts +++ b/packages/bridge/src/methods/types/methods.ts @@ -281,6 +281,18 @@ export interface Methods { * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-phone */ web_app_request_phone: CreateParams; + /** + * 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: CreateParams; + /** + * Requests content safe area of the user's phone. + * @since v8.0 + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-content-safe-area + */ + web_app_request_content_safe_area: CreateParams; /** * 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/index.ts b/packages/sdk/src/index.ts index 26f1184b9..c2db707d1 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,6 +12,7 @@ export * from '@/scopes/components/main-button/exports.js'; export * from '@/scopes/components/mini-app/exports.js'; export * from '@/scopes/components/popup/exports.js'; export * from '@/scopes/components/qr-scanner/exports.js'; +export * from '@/scopes/components/safe-area/exports.js'; export * from '@/scopes/components/secondary-button/exports.js'; export * from '@/scopes/components/settings-button/exports.js'; export * from '@/scopes/components/swipe-behavior/exports.js'; diff --git a/packages/sdk/src/scopes/components/safe-area/exports.ts b/packages/sdk/src/scopes/components/safe-area/exports.ts new file mode 100644 index 000000000..8cbd8c2ad --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/exports.ts @@ -0,0 +1,21 @@ +export { + bindCssVars as bindSafeAreaCssVars, + isSupported as isSafeAreaSupported, + mount as mountSafeArea, + unmount as unmountSafeArea, +} from './methods.js'; +export { + inset as safeAreaInset, + contentInset as contentSafeAreaInset, + + isCssVarsBound as isSafeAreaCssVarsBound, + isMounted as isSafeAreaMounted, + isMounting as isSafeAreaMounting, + mountError as safeAreaMountError, + state as safeAreaState, +} from './signals.js'; +export type { + GetCSSVarNameFn as SafeAreaGetCSSVarNameFn, + State as SafeAreaState, +} from './types.js'; +export * as safeArea from './exports.variable.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/safe-area/exports.variable.ts b/packages/sdk/src/scopes/components/safe-area/exports.variable.ts new file mode 100644 index 000000000..8187e4ee5 --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/exports.variable.ts @@ -0,0 +1,2 @@ +export * from './methods.js'; +export * from './signals.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/safe-area/methods.test.ts b/packages/sdk/src/scopes/components/safe-area/methods.test.ts new file mode 100644 index 000000000..54566d688 --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/methods.test.ts @@ -0,0 +1,24 @@ +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 { mount, bindCssVars } from './methods.js'; +import { isMounted } from './signals.js'; + +beforeEach(() => { + resetPackageState(); + vi.restoreAllMocks(); + mockPostEvent(); +}); + +describe.each([ + ['mount', mount, undefined], + ['bindCssVars', bindCssVars, isMounted], +] as const)('%s', (name, fn, isMounted) => { + testSafety(fn, name, { + component: 'safeArea', + minVersion: '8.0', + isMounted, + }); +}); diff --git a/packages/sdk/src/scopes/components/safe-area/methods.ts b/packages/sdk/src/scopes/components/safe-area/methods.ts new file mode 100644 index 000000000..c1766fb95 --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/methods.ts @@ -0,0 +1,238 @@ +import { + camelToKebab, + deleteCssVar, + getStorageValue, + off, + on, + retrieveLaunchParams, + setCssVar, + setStorageValue, + supports, + type EventListener, + type MethodName, +} from '@telegram-apps/bridge'; +import {isPageReload} from '@telegram-apps/navigation'; +import {computed, type Signal} from '@telegram-apps/signals'; + +import {$version} from '@/scopes/globals.js'; +import {throwCssVarsBound} from '@/scopes/toolkit/throwCssVarsBound.js'; +import {createWrapComplete} from '@/scopes/toolkit/createWrapComplete.js'; +import {createWrapSupported} from '@/scopes/toolkit/createWrapSupported.js'; + +import { + contentInset, + isCssVarsBound, + initialValue, + inset, + isMounted, + state, +} from './signals.js'; +import {GetCSSVarNameFn, State} from './types.js'; +import {SafeAreaInset} from "@telegram-apps/bridge"; +import {createMountFn} from "@/scopes/createMountFn.js"; +import {isMounting, mountError} from "@/scopes/components/safe-area/signals.js"; +import {requestInsets} from "@/scopes/components/safe-area/requestSafeArea.js"; + +type StorageValue = State; + +const REQUEST_METHOD = 'web_app_request_safe_area'; +const REQUEST_CONTENT_METHOD = 'web_app_request_content_safe_area'; +const COMPONENT_NAME = 'safeArea'; + +const isSupportedSchema = { + any: [ + REQUEST_METHOD, + REQUEST_CONTENT_METHOD, + ] as MethodName[], +}; + +/** + * True if the Mini App component is supported. + */ +export const isSupported = computed(() => { + return isSupportedSchema.any.some(method => supports(method, $version())); +}); + +const wrapSupported = createWrapSupported(COMPONENT_NAME, isSupportedSchema); +const wrapComplete = createWrapComplete(COMPONENT_NAME, isMounted, isSupportedSchema); + +/** + * Creates CSS variables connected with the mini app. + * + * Default variables: + * - `--tg-safe-area-inset-top` + * - `--tg-safe-area-inset-bottom` + * - `--tg-safe-area-inset-left` + * - `--tg-safe-area-inset-right` + + * - `--tg-content-safe-area-inset-top` + * - `--tg-content-safe-area-inset-bottom` + * - `--tg-content-safe-area-inset-left` + * - `--tg-content-safe-area-inset-right` + * + * Variables are being automatically updated if theme parameters were changed. + * + * @param getCSSVarName - function, returning complete CSS variable name for the specified + * mini app key. + * @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((component, property) => `--my-prefix-${component}-${property}`); + * } + */ +export const bindCssVars = wrapComplete( + 'bindCssVars', + (getCSSVarName?: GetCSSVarNameFn): VoidFunction => { + isCssVarsBound() && throwCssVarsBound(); + + type Component = "safeArea" | "contentSafeArea"; + const props = ['top', 'bottom', 'left', 'right'] as const; + + getCSSVarName ||= (component, prop) => `--tg-${camelToKebab(component)}-${camelToKebab(prop)}`; + + function actualize(component: Component): void { + const fn = component === "safeArea" ? inset : contentInset; + props.forEach(prop => { + setCssVar(getCSSVarName!(component, prop), `${fn()[prop]}px`); + }); + } + + const actualizeSA = () => actualize("safeArea"); + const actualizeCSA = () => actualize("contentSafeArea"); + + actualizeSA(); + actualizeCSA(); + inset.sub(actualizeSA); + contentInset.sub(actualizeCSA); + isCssVarsBound.set(true); + + return () => { + props.forEach(deleteCssVar); + inset.unsub(actualizeSA); + contentInset.unsub(actualizeCSA); + isCssVarsBound.set(false); + }; + }, +); + +/** + * Mounts the component. + * + * This function restores the component state and is automatically saving it in the local storage + * if it changed. + * + * @since Mini Apps v8.0 + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @example + * if (mount.isAvailable()) { + * mount(); + * } + */ +export const mount = wrapSupported( + 'mount', + createMountFn( + COMPONENT_NAME, + options => { + if (isMounted()) return state(); + + // Try to restore the state using the storage. + // TODO: do not restore if orientation changed + if (isPageReload()) { + const storedState = getStorageValue(COMPONENT_NAME); + if (storedState) { + return storedState; + } + } + + // If the platform has a stable viewport, it means we could use the window global object + // properties. + if ([ + 'macos', + 'tdesktop', + 'unigram', + 'webk', + 'weba', + 'web', + ].includes(retrieveLaunchParams().platform)) { + return { + inset: initialValue, + contentInset: initialValue + }; + } + + // We were unable to retrieve data locally. In this case, we are sending + // a request returning the viewport information. + options.timeout ||= 1000; + return requestInsets(options); + }, + result => { + on('safe_area_changed', onSafeAreaChanged); + on('content_safe_area_changed', onContentSafeAreaChanged); + setGlobalState(result); + }, + {isMounted, isMounting, mountError}, + ), +); + +const onSafeAreaChanged: EventListener<'safe_area_changed'> = (data) => { + setSafeAreaState(data); +}; + +const onContentSafeAreaChanged: EventListener<'content_safe_area_changed'> = (data) => { + setContentSafeAreaState(data); +}; + +type methodName = 'inset' | 'contentInset'; + +function setSafeAreaState(safeArea: SafeAreaInset) { + setState('inset', inset, safeArea); +} + +function setContentSafeAreaState(safeArea: SafeAreaInset) { + setState('contentInset', contentInset, safeArea); +} + +function setState(fnName: methodName, fn: Signal, s: SafeAreaInset) { + fn.set({ + top: truncate(s.top), + bottom: truncate(s.bottom), + left: truncate(s.left), + right: truncate(s.right), + }); + setStorageValue(COMPONENT_NAME, { + inset: fnName === 'inset' ? fn() : state().inset, + contentInset: fnName === 'contentInset' ? fn() : state().contentInset, + }); +} + +function setGlobalState(state: State) { + setSafeAreaState(state.inset); + setContentSafeAreaState(state.contentInset); +} + +/** + * 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 component, removing the listeners, saving the component state in the local storage. + */ +export function unmount(): void { + off('safe_area_changed', onSafeAreaChanged); + off('content_safe_area_changed', onContentSafeAreaChanged); + isMounted.set(false); +} diff --git a/packages/sdk/src/scopes/components/safe-area/requestSafeArea.ts b/packages/sdk/src/scopes/components/safe-area/requestSafeArea.ts new file mode 100644 index 000000000..b52f5a0b9 --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/requestSafeArea.ts @@ -0,0 +1,53 @@ +import { + ExecuteWithOptions, + CancelablePromise, + SafeAreaInset, +} from '@telegram-apps/bridge'; + +import {request as _request} from '@/scopes/globals.js'; +import {State} from "@/scopes/components/safe-area/types.js"; + +export function requestInsets( + options?: ExecuteWithOptions +): CancelablePromise { + // @ts-expect-error incorrect linting here + return CancelablePromise.all([ + requestSafeArea(options), + requestContentSafeArea(options) + ]).then(([safeAreaInset, contentSafeAreaInset]) => { + return new CancelablePromise((resolve) => { + resolve({ + inset: safeAreaInset, + contentInset: contentSafeAreaInset + }); + }); + }); +} + +/** + * Requests safe area actual information from the Telegram application. + * @param options - request options. + * @example + * const viewport = await request({ + * timeout: 1000 + * }); + */ +export function requestSafeArea( + options?: ExecuteWithOptions, +): CancelablePromise { + return _request('web_app_request_safe_area', 'safe_area_changed', options); +} + +/** + * Requests content safe area actual information from the Telegram application. + * @param options - request options. + * @example + * const viewport = await request({ + * timeout: 1000 + * }); + */ +export function requestContentSafeArea( + options?: ExecuteWithOptions, +): CancelablePromise { + return _request('web_app_request_content_safe_area', 'content_safe_area_changed', options); +} \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/safe-area/signals.ts b/packages/sdk/src/scopes/components/safe-area/signals.ts new file mode 100644 index 000000000..91f0e6b99 --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/signals.ts @@ -0,0 +1,50 @@ +import {computed, signal} from '@telegram-apps/signals'; + +import {SafeAreaInset} from "@telegram-apps/bridge"; +import {State} from "@/scopes/components/safe-area/types.js"; + +export const initialValue: SafeAreaInset = { + top: 0, + bottom: 0, + left: 0, + right: 0, +} + +/** + * Signal with SafeAreaInset object state. + */ +export const inset = signal(initialValue); + +/** + * Signal with ContentSafeAreaInset object state. + */ +export const contentInset = signal(initialValue); + +/** + * True if the component is currently mounted. + */ +export const isMounted = signal(false); + +/** + * True if CSS variables are currently bound. + */ +export const isCssVarsBound = signal(false); + +/** + * True if the component is currently mounting. + */ +export const isMounting = signal(false); + +/** + * Error occurred while mounting the component. + */ +export const mountError = signal(undefined); + + +/** + * Complete component state. + */ +export const state = computed(() => ({ + inset: inset(), + contentInset: contentInset(), +})); \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/safe-area/types.ts b/packages/sdk/src/scopes/components/safe-area/types.ts new file mode 100644 index 000000000..e6c0e4352 --- /dev/null +++ b/packages/sdk/src/scopes/components/safe-area/types.ts @@ -0,0 +1,17 @@ +import { SafeAreaInset } from "@telegram-apps/bridge"; + +export interface State { + inset: SafeAreaInset; + contentInset: SafeAreaInset; +} + +type Component = "safeArea" | "contentSafeArea"; + +export interface GetCSSVarNameFn { + /** + * @param component - safe area or content safe area component + * @param property - safe area insets property + * @returns Computed complete CSS variable name. + */ + (component: Component, property: Extract): string; +} \ No newline at end of file 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/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 b840bac3b..0c1d9ec99 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"; interface Route { path: string; @@ -18,6 +19,7 @@ export const routes: Route[] = [ { path: '/init-data', Component: InitDataPage, title: 'Init Data' }, { path: '/theme-params', Component: ThemeParamsPage, title: 'Theme Params' }, { path: '/launch-params', Component: LaunchParamsPage, title: 'Launch 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 d927416a9..6413e3acd 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 + 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 ( + + + + + + + ); +};