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.
-
-
-
- Field
- Type
- Description
-
-
-
-
-
- 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.
-
-
-
- Field
- Type
- Description
-
-
-
-
-
- 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 (
+
+
+
+
+
+
+ );
+};