diff --git a/.changeset/config.json b/.changeset/config.json index ffa835aad..513ff766e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,7 +8,10 @@ "baseBranch": "master", "updateInternalDependencies": "patch", "ignore": [ + "docs", "custom-playground", + "vue-template", + "svelte-template", "nextjs-template", "reactjs-template", "solidjs-template" diff --git a/.changeset/hip-birds-whisper.md b/.changeset/hip-birds-whisper.md new file mode 100644 index 000000000..fbf16c393 --- /dev/null +++ b/.changeset/hip-birds-whisper.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/sdk": minor +--- + +Add emoji status-related functionality. diff --git a/.changeset/hungry-mails-buy.md b/.changeset/hungry-mails-buy.md new file mode 100644 index 000000000..6f60ce023 --- /dev/null +++ b/.changeset/hungry-mails-buy.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/bridge": minor +--- + +Add methods and events connected with custom emoji set. diff --git a/apps/docs/.vitepress/packages.ts b/apps/docs/.vitepress/packages.ts index 2aa1ca12b..fd78cd614 100644 --- a/apps/docs/.vitepress/packages.ts +++ b/apps/docs/.vitepress/packages.ts @@ -109,6 +109,7 @@ export const packagesLinksGenerator = (prefix: string = '') => { ]), ], 'Utilities': [{ url: 'utils', page: false }, fromEntries([ + scope('emoji-status'), scope('links'), scope('privacy'), scope('uncategorized'), diff --git a/apps/docs/packages/telegram-apps-sdk/2-x/utils/emoji-status.md b/apps/docs/packages/telegram-apps-sdk/2-x/utils/emoji-status.md new file mode 100644 index 000000000..a2c1109e7 --- /dev/null +++ b/apps/docs/packages/telegram-apps-sdk/2-x/utils/emoji-status.md @@ -0,0 +1,56 @@ +# Emoji Status + +## `requestEmojiStatusAccess` + +To request access to user emoji status update, use the `requestEmojiStatusAccess` function: + +::: code-group + +```ts [Using isAvailable] +import { requestEmojiStatusAccess } from '@telegram-apps/sdk'; + +if (requestEmojiStatusAccess.isAvailable()) { + const status = await requestEmojiStatusAccess(); +} +``` + +```ts [Using ifAvailable] +import { requestEmojiStatusAccess } from '@telegram-apps/sdk'; + +const status = await requestEmojiStatusAccess.ifAvailable(); +``` + +::: + +## `setEmojiStatus` + +To set an emoji status on user's behalf, use the `setEmojiStatus` function. + +As the first argument, it accepts a custom emoji id. Optionally, you can pass the second +argument determining for how many seconds the status must be set. + +::: code-group + +```ts [Using isAvailable] +import { setEmojiStatus } from '@telegram-apps/sdk'; + +if (setEmojiStatus.isAvailable()) { + // Set for unlimited period of time. + await setEmojiStatus('5361800828313167608'); + + // Set for 1 day. + await setEmojiStatus('5361800828313167608', 86400); +} +``` + +```ts [Using ifAvailable] +import { setEmojiStatus } from '@telegram-apps/sdk'; + +// Set for unlimited period of time. +await setEmojiStatus.ifAvailable('5361800828313167608'); + +// Set for 1 day. +await setEmojiStatus.ifAvailable('5361800828313167608', 86400); +``` + +::: \ No newline at end of file diff --git a/apps/docs/platform/events.md b/apps/docs/platform/events.md index 7dcd137be..dc5b6de8b 100644 --- a/apps/docs/platform/events.md +++ b/apps/docs/platform/events.md @@ -191,6 +191,32 @@ Custom method invocation completed. | result | `unknown` | _Optional_. Method invocation result. | | error | `string` | _Optional_. Method invocation error code. | +### `emoji_status_access_requested` + +Available since: **v8.0** + +Access to set custom emoji status was requested. + +| Field | Type | Description | +|--------|----------|------------------------------------------------------------| +| status | `string` | Request status. Possible values: `allowed` or `cancelled`. | + +### `emoji_status_failed` + +Available since: **v8.0** + +Failed to set custom emoji status. + +| Field | Type | Description | +|-------|----------|-----------------------------------------------------------------------------------------| +| error | `string` | Emoji set failure reason. Possible values: `SUGGESTED_EMOJI_INVALID` or `USER_DECLINED` | + +### `emoji_status_set` + +Available since: **v8.0** + +Custom emoji status set. + ### `fullscreen_changed` Available since: **v8.0** diff --git a/apps/docs/platform/methods.md b/apps/docs/platform/methods.md index e30d11de4..537713e12 100644 --- a/apps/docs/platform/methods.md +++ b/apps/docs/platform/methods.md @@ -128,7 +128,7 @@ Opens the biometric access settings for bots. Useful when you need to request bi 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 +> 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` @@ -388,6 +388,12 @@ Requests the current content safe area information from Telegram. As a result, Telegram triggers the [**`content_safe_area_changed`**](events.md#content-safe-area-changed) event. +### `web_app_request_emoji_status_access` + +Available since: **v8.0** + +Shows a native popup requesting permission for the bot to manage user's emoji status. + ### `web_app_request_fullscreen` Available since: **v8.0** @@ -445,6 +451,17 @@ Updates the Mini App bottom bar background color. |-------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------| | color | `string` | The Mini App bottom bar background color in `#RRGGBB` format, or one of the values: `bg_color`, `secondary_bg_color` or `bottom_bar_bg_color` | +### `web_app_set_emoji_status` + +Available since: **v8.0** + +Opens a dialog allowing the user to set the specified custom emoji as their status. + +| Field | Type | Description | +|-----------------|----------|----------------------------------------------------| +| custom_emoji_id | `string` | Custom emoji identifier to set. | +| duration | `number` | _Optional_. The status expiration time in seconds. | + ### `web_app_set_header_color` Available since: **v6.1** diff --git a/packages/bridge/src/events/types/events.ts b/packages/bridge/src/events/types/events.ts index 247234081..32a3f8c5a 100644 --- a/packages/bridge/src/events/types/events.ts +++ b/packages/bridge/src/events/types/events.ts @@ -1,6 +1,6 @@ import type { RGB } from '@telegram-apps/types'; -import type { +import { PhoneRequestedStatus, InvoiceStatus, WriteAccessRequestedStatus, @@ -9,6 +9,8 @@ import type { BiometryTokenUpdateStatus, SafeAreaInsets, FullScreenErrorStatus, + EmojiStatusAccessRequestedStatus, + EmojiStatusFailedError, } from './misc.js'; /** @@ -150,6 +152,31 @@ export interface Events { */ error?: string; }; + /** + * Request to set custom emoji status was requested. + * @see https://docs.telegram-mini-apps.com/platform/events#emoji-status-access-requested + * @since v8.0 + */ + emoji_status_access_requested: { + /** + * Request status. + */ + status: EmojiStatusAccessRequestedStatus; + }; + /** + * Failed to set custom emoji status. + * @see https://docs.telegram-mini-apps.com/platform/events#emoji-status-failed + * @since v8.0 + */ + emoji_status_failed: { + error: EmojiStatusFailedError; + }; + /** + * Custom emoji status set. + * @see https://docs.telegram-mini-apps.com/platform/events#emoji-status-set + * @since v8.0 + */ + emoji_status_set: never; /** * App entered or exited fullscreen mode. * @since v8.0 diff --git a/packages/bridge/src/events/types/misc.ts b/packages/bridge/src/events/types/misc.ts index 2ef5d539f..8e74f23a7 100644 --- a/packages/bridge/src/events/types/misc.ts +++ b/packages/bridge/src/events/types/misc.ts @@ -7,6 +7,10 @@ export type InvoiceStatus = export type PhoneRequestedStatus = 'sent' | 'cancelled' | string; +export type EmojiStatusAccessRequestedStatus = 'allowed' | string; + +export type EmojiStatusFailedError = 'SUGGESTED_EMOJI_INVALID' | 'USER_DECLINED' | string; + export type WriteAccessRequestedStatus = 'allowed' | string; export type BiometryType = 'finger' | 'face' | string; diff --git a/packages/bridge/src/methods/supports.test.ts b/packages/bridge/src/methods/supports.test.ts index baffc17eb..95ac16e5f 100644 --- a/packages/bridge/src/methods/supports.test.ts +++ b/packages/bridge/src/methods/supports.test.ts @@ -118,7 +118,9 @@ describe.each<[ ]], ['8.0', [ 'web_app_request_fullscreen', - 'web_app_exit_fullscreen' + 'web_app_exit_fullscreen', + 'web_app_set_emoji_status', + 'web_app_request_emoji_status_access', ]], ])('%s', (version, methods) => { const higher = increaseVersion(version, 1); diff --git a/packages/bridge/src/methods/supports.ts b/packages/bridge/src/methods/supports.ts index 7582c62a2..f58b68c60 100644 --- a/packages/bridge/src/methods/supports.ts +++ b/packages/bridge/src/methods/supports.ts @@ -105,6 +105,8 @@ export function supports( case 'web_app_request_content_safe_area': case 'web_app_request_fullscreen': case 'web_app_exit_fullscreen': + case 'web_app_set_emoji_status': + case 'web_app_request_emoji_status_access': return versionLessOrEqual('8.0', paramOrVersion); default: return [ diff --git a/packages/bridge/src/methods/types/methods.ts b/packages/bridge/src/methods/types/methods.ts index 61088f1f0..4e838710e 100644 --- a/packages/bridge/src/methods/types/methods.ts +++ b/packages/bridge/src/methods/types/methods.ts @@ -287,6 +287,12 @@ export interface Methods { * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-content-safe-area */ web_app_request_content_safe_area: CreateMethodParams; + /** + * Shows a native popup requesting permission for the bot to manage user's emoji status. + * @since v8.0 + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-request-emoji-status-access + */ + web_app_request_emoji_status_access: CreateMethodParams; /** * Requests to open the mini app in fullscreen. * @since v8.0 @@ -344,6 +350,21 @@ export interface Methods { */ color: BottomBarColor; }>; + /** + * Opens a dialog allowing the user to set the specified custom emoji as their status. + * @since v8.0 + * @see https://docs.telegram-mini-apps.com/platform/methods#web-app-set-emoji-status + */ + web_app_set_emoji_status: CreateMethodParams<{ + /** + * Custom emoji identifier to set. + */ + custom_emoji_id: string; + /** + * The status expiration time in seconds. + */ + duration?: number; + }>; /** * Updates the Mini App header color. * @since v6.1 diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index dca30f8e3..a8a69a1bc 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -12,3 +12,4 @@ export const ERR_NOT_INITIALIZED = 'ERR_NOT_INITIALIZED'; export const ERR_NOT_SUPPORTED = 'ERR_NOT_SUPPORTED'; export const ERR_NOT_MOUNTED = 'ERR_NOT_MOUNTED'; export const ERR_FULLSCREEN_FAILED = 'ERR_FULLSCREEN_FAILED'; +export const ERR_EMOJI_STATUS_SET_FAILED = 'ERR_EMOJI_STATUS_SET_FAILED'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index fa7e8e82e..0d1204cc0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,6 +17,7 @@ export * from '@/scopes/components/settings-button/exports.js'; export * from '@/scopes/components/swipe-behavior/exports.js'; export * from '@/scopes/components/theme-params/exports.js'; export * from '@/scopes/components/viewport/exports.js'; +export * from '@/scopes/utilities/emoji-status/exports.js'; export * from '@/scopes/utilities/links/exports.js'; export * from '@/scopes/utilities/privacy/exports.js'; export * from '@/scopes/utilities/uncategorized/exports.js'; @@ -41,6 +42,7 @@ export { ERR_ALREADY_MOUNTING, ERR_ALREADY_REQUESTING, ERR_FULLSCREEN_FAILED, + ERR_EMOJI_STATUS_SET_FAILED, } from '@/errors.js'; export { init, type InitOptions } from '@/init.js'; diff --git a/packages/sdk/src/scopes/createMountFn.ts b/packages/sdk/src/scopes/createMountFn.ts index fea1c6d44..0fab7e60d 100644 --- a/packages/sdk/src/scopes/createMountFn.ts +++ b/packages/sdk/src/scopes/createMountFn.ts @@ -23,7 +23,7 @@ export function createMountFn( mount: (options?: AsyncOptions) => R | CancelablePromise, onMounted: (result: R) => void, isMounted: Signal, - promise: Signal | undefined>, + promise: Signal> | undefined>, error: Signal, ): (options?: AsyncOptions) => CancelablePromise { const noConcurrent = signalifyAsyncFn( diff --git a/packages/sdk/src/scopes/signalifyAsyncFn.ts b/packages/sdk/src/scopes/signalifyAsyncFn.ts index 8f6970f2e..068793435 100644 --- a/packages/sdk/src/scopes/signalifyAsyncFn.ts +++ b/packages/sdk/src/scopes/signalifyAsyncFn.ts @@ -1,26 +1,69 @@ import { batch, type Signal } from '@telegram-apps/signals'; -import { type AsyncOptions, CancelablePromise, type TypedError } from '@telegram-apps/bridge'; +import { CancelablePromise, If, type TypedError } from '@telegram-apps/bridge'; +import { AnyFn } from '@/types.js'; -type AllowedFn = (options?: AsyncOptions) => R | CancelablePromise; +type Signalified = (...args: Parameters) => If< + Cancelable, + CancelablePromise>, + PromiseLike> +>; /** * Function doing the following: * 1. Prevents the wrapped function from being called concurrently. * 2. Being called, updates the passed promise and error signals. + * + * As a result, the function returns a new one, returning a cancelable promise. * @param fn - function to wrap. * @param createPendingError - function that creates error in case of concurrent call * @param promise - signal containing the execution promise * @param error - signal containing the last call error. + * @param cancelable - is result cancelable. True by default. */ +export function signalifyAsyncFn( + fn: Fn, + createPendingError: () => TypedError, + promise: Signal>> | undefined>, + error: Signal, + cancelable?: true, +): Signalified; + +/** + * Function doing the following: + * 1. Prevents the wrapped function from being called concurrently. + * 2. Being called, updates the passed promise and error signals. + * + * As a result, the function returns a new one, returning a non-cancelable promise. + * @param fn - function to wrap. + * @param createPendingError - function that creates error in case of concurrent call + * @param promise - signal containing the execution promise + * @param error - signal containing the last call error. + * @param cancelable - is result cancelable. True by default. + */ +export function signalifyAsyncFn( + fn: Fn, + createPendingError: () => TypedError, + promise: Signal>> | undefined>, + error: Signal, + cancelable: false, +): Signalified; + // #__NO_SIDE_EFFECTS__ -export function signalifyAsyncFn, Result>( +export function signalifyAsyncFn( fn: Fn, createPendingError: () => TypedError, - promise: Signal | undefined>, + promise: + | Signal>> | undefined> + | Signal>> | undefined>, error: Signal, -): Fn { - return Object.assign((options?: AsyncOptions): CancelablePromise => { - return CancelablePromise + cancelable?: boolean, +): Signalified { + const PromiseConstructor = cancelable === undefined || cancelable + ? CancelablePromise + : Promise; + + return Object.assign((...args: Parameters): PromiseLike> => { + return PromiseConstructor .resolve() .then(async () => { // Check if the operation is currently not in progress. @@ -32,10 +75,12 @@ export function signalifyAsyncFn, Result>( // Start performing the wrapped function. batch(() => { - promise.set(CancelablePromise.resolve(fn(options))); + promise.set( + (PromiseConstructor as typeof Promise).resolve(fn(...args)) as unknown as any, + ); error.set(undefined); }); - let result: [completed: true, result: Result] | [completed: false, err: Error]; + let result: [completed: true, result: ReturnType] | [completed: false, err: Error]; try { result = [true, await promise()!]; } catch (e) { diff --git a/packages/sdk/src/scopes/utilities/emoji-status/exports.ts b/packages/sdk/src/scopes/utilities/emoji-status/exports.ts new file mode 100644 index 000000000..f6e44a75e --- /dev/null +++ b/packages/sdk/src/scopes/utilities/emoji-status/exports.ts @@ -0,0 +1,12 @@ +export { + requestEmojiStatusAccess, + requestEmojiStatusAccessError, + requestEmojiStatusAccessPromise, + isRequestingEmojiStatusAccess, +} from './requestEmojiStatusAccess.js'; +export { + setEmojiStatus, + isSettingEmojiStatus, + setEmojiStatusError, + setEmojiStatusPromise, +} from './setEmojiStatus.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/utilities/emoji-status/requestEmojiStatusAccess.ts b/packages/sdk/src/scopes/utilities/emoji-status/requestEmojiStatusAccess.ts new file mode 100644 index 000000000..dd8857899 --- /dev/null +++ b/packages/sdk/src/scopes/utilities/emoji-status/requestEmojiStatusAccess.ts @@ -0,0 +1,55 @@ +import { computed, signal } from '@telegram-apps/signals'; +import { type EmojiStatusAccessRequestedStatus, TypedError } from '@telegram-apps/bridge'; + +import { ERR_ALREADY_REQUESTING } from '@/errors.js'; +import { request } from '@/scopes/globals.js'; +import { wrapSafe } from '@/scopes/toolkit/wrapSafe.js'; +import { signalifyAsyncFn } from '@/scopes/signalifyAsyncFn.js'; + +const METHOD = 'web_app_request_emoji_status_access'; + +/** + * Signal containing the emoji status access request promise. + */ +export const requestEmojiStatusAccessPromise = signal | undefined>(); + +/** + * Signal containing the last emoji status access request error. + */ +export const requestEmojiStatusAccessError = signal(); + +/** + * Signal indicating if the emoji status access is currently being requested. + */ +export const isRequestingEmojiStatusAccess = computed(() => !!requestEmojiStatusAccessPromise()); + +/** + * Shows a native popup requesting permission for the bot to manage user's emoji status. + * @param options - additional options. + * @since Mini Apps v8.0 + * @throws {TypedError} ERR_ALREADY_REQUESTING + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @example + * if (requestEmojiStatusAccess.isAvailable()) { + * const status = await requestEmojiStatusAccess(); + * } + */ +export const requestEmojiStatusAccess = wrapSafe( + 'requestEmojiStatusAccess', + signalifyAsyncFn( + (): Promise => { + return request(METHOD, 'emoji_status_access_requested') + .then(r => r.status); + }, + () => new TypedError( + ERR_ALREADY_REQUESTING, + 'Emoji status access request is currently in progress', + ), + requestEmojiStatusAccessPromise, + requestEmojiStatusAccessError, + false, + ), + { isSupported: METHOD }, +); \ No newline at end of file diff --git a/packages/sdk/src/scopes/utilities/emoji-status/setEmojiStatus.ts b/packages/sdk/src/scopes/utilities/emoji-status/setEmojiStatus.ts new file mode 100644 index 000000000..552bedc3c --- /dev/null +++ b/packages/sdk/src/scopes/utilities/emoji-status/setEmojiStatus.ts @@ -0,0 +1,64 @@ +import { computed, signal } from '@telegram-apps/signals'; +import { TypedError } from '@telegram-apps/bridge'; + +import { ERR_ALREADY_REQUESTING, ERR_EMOJI_STATUS_SET_FAILED } from '@/errors.js'; +import { request } from '@/scopes/globals.js'; +import { wrapSafe } from '@/scopes/toolkit/wrapSafe.js'; +import { signalifyAsyncFn } from '@/scopes/signalifyAsyncFn.js'; + +const METHOD = 'web_app_set_emoji_status'; + +/** + * Signal containing the emoji status access request promise. + */ +export const setEmojiStatusPromise = signal | undefined>(); + +/** + * Signal containing the last emoji status access request error. + */ +export const setEmojiStatusError = signal(); + +/** + * Signal indicating if the emoji status set is currently being requested. + */ +export const isSettingEmojiStatus = computed(() => !!setEmojiStatusPromise()); + +/** + * Opens a dialog allowing the user to set the specified custom emoji as their status. + * @returns Promise with boolean value indicating if the status was set. + * @param options - additional options. + * @since Mini Apps v8.0 + * @throws {TypedError} ERR_ALREADY_REQUESTING + * @throws {TypedError} ERR_UNKNOWN_ENV + * @throws {TypedError} ERR_NOT_INITIALIZED + * @throws {TypedError} ERR_NOT_SUPPORTED + * @example + * if (setEmojiStatus.isAvailable()) { + * const statusSet = await setEmojiStatus('5361800828313167608'); + * } + */ +export const setEmojiStatus = wrapSafe( + 'setEmojiStatus', + signalifyAsyncFn( + async (customEmojiId: string, duration?: number): Promise => { + const result = await request(METHOD, ['emoji_status_set', 'emoji_status_failed'], { + params: { + custom_emoji_id: customEmojiId, + duration, + }, + }); + + if (result && 'error' in result) { + throw new TypedError(ERR_EMOJI_STATUS_SET_FAILED, 'Failed to set emoji status', result.error); + } + }, + () => new TypedError( + ERR_ALREADY_REQUESTING, + 'Emoji status set request is currently in progress', + ), + setEmojiStatusPromise, + setEmojiStatusError, + false, + ), + { isSupported: METHOD }, +); \ No newline at end of file