diff --git a/.changeset/eleven-jobs-knock.md b/.changeset/eleven-jobs-knock.md new file mode 100644 index 000000000..97b0ec83d --- /dev/null +++ b/.changeset/eleven-jobs-knock.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/bridge": patch +--- + +Use `Maybe` from `@telegram-apps/toolkit` instead of a type from declarations' file diff --git a/.changeset/long-candles-confess.md b/.changeset/long-candles-confess.md new file mode 100644 index 000000000..aa2290385 --- /dev/null +++ b/.changeset/long-candles-confess.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/sdk": minor +--- + +Implement location manager. Implement `shareMessage`. Add support check to `biometryManager.mount()`. diff --git a/packages/bridge/src/events/types/events.ts b/packages/bridge/src/events/types/events.ts index 47bc76be0..2f24f6739 100644 --- a/packages/bridge/src/events/types/events.ts +++ b/packages/bridge/src/events/types/events.ts @@ -13,7 +13,7 @@ import type { EmojiStatusFailedError, HomeScreenStatus, } from './misc.js'; -import type { If, IsNever } from '@telegram-apps/toolkit'; +import type { If, IsNever, Maybe } from '@telegram-apps/toolkit'; /** * Map where key is known event name, and value is its listener. diff --git a/packages/bridge/src/util.d.ts b/packages/bridge/src/util.d.ts deleted file mode 100644 index ca5ed84e8..000000000 --- a/packages/bridge/src/util.d.ts +++ /dev/null @@ -1 +0,0 @@ -type Maybe = T | undefined | null; \ No newline at end of file diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 8ff977584..b7b710d8c 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -53,4 +53,9 @@ export const [ export const [ FullscreenFailedError, isFullscreenFailedError, -] = errorClass<[message: string]>('FullscreenFailedError', proxyMessage); \ No newline at end of file +] = errorClass<[message: string]>('FullscreenFailedError', proxyMessage); + +export const [ + ShareMessageError, + isShareMessageError, +] = errorClass<[error: string]>('ShareMessageError', proxyMessage); \ No newline at end of file diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6f5292c71..99aafce92 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,6 +5,7 @@ export * from '@/scopes/components/cloud-storage/exports.js'; export * from '@/scopes/components/haptic-feedback/exports.js'; export * from '@/scopes/components/init-data/exports.js'; export * from '@/scopes/components/invoice/exports.js'; +export * from '@/scopes/components/location-manager/exports.js'; export * from '@/scopes/components/main-button/exports.js'; export * from '@/scopes/components/mini-app/exports.js'; export * from '@/scopes/components/popup/exports.js'; diff --git a/packages/sdk/src/scopes/components/biometry/methods.ts b/packages/sdk/src/scopes/components/biometry/methods.ts index 543a97ef5..3bbafa6af 100644 --- a/packages/sdk/src/scopes/components/biometry/methods.ts +++ b/packages/sdk/src/scopes/components/biometry/methods.ts @@ -15,7 +15,6 @@ import { defineMountFn } from '@/scopes/defineMountFn.js'; import { createIsSupported } from '@/scopes/createIsSupported.js'; import { createWrapComplete } from '@/scopes/wrappers/createWrapComplete.js'; import { createWrapSupported } from '@/scopes/wrappers/createWrapSupported.js'; -import { createWrapBasic } from '@/scopes/wrappers/createWrapBasic.js'; import { defineNonConcurrentFn } from '@/scopes/defineNonConcurrentFn.js'; import { NotAvailableError } from '@/errors.js'; @@ -88,7 +87,6 @@ const [ }, ); -const wrapBasic = createWrapBasic(COMPONENT_NAME); const wrapSupported = createWrapSupported(COMPONENT_NAME, REQUEST_AUTH_METHOD); const wrapComplete = createWrapComplete(COMPONENT_NAME, tIsMounted[0], REQUEST_AUTH_METHOD); @@ -103,7 +101,7 @@ const wrapComplete = createWrapComplete(COMPONENT_NAME, tIsMounted[0], REQUEST_A * await mount(); * } */ -export const mount = wrapBasic('mount', mountFn); +export const mount = wrapSupported('mount', mountFn); export const [, mountPromise, isMounting] = tMountPromise; export const [, mountError] = tMountError; export const [_isMounted, isMounted] = tIsMounted; diff --git a/packages/sdk/src/scopes/components/location-manager/exports.ts b/packages/sdk/src/scopes/components/location-manager/exports.ts new file mode 100644 index 000000000..50ef63044 --- /dev/null +++ b/packages/sdk/src/scopes/components/location-manager/exports.ts @@ -0,0 +1,16 @@ +export { + type State as LocationManagerState, + isAccessGranted as isLocationManagerAccessGranted, + isAccessRequested as isLocationManagerAccessRequested, + requestLocationPromise, + isRequestingLocation, + requestLocationError, + isMounted as isLocationManagerMounted, + isMounting as isLocationManagerMounting, + mount as mountLocationManager, + mountError as locationManagerMountError, + requestLocation, + mountPromise as locationManagerMountPromise, + isAvailable as isLocationManagerAvailable, +} from './exports.variable.js'; +export * as locationManager from './exports.variable.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/location-manager/exports.variable.ts b/packages/sdk/src/scopes/components/location-manager/exports.variable.ts new file mode 100644 index 000000000..75a2d21c3 --- /dev/null +++ b/packages/sdk/src/scopes/components/location-manager/exports.variable.ts @@ -0,0 +1,15 @@ +export { + type State, + isAccessGranted, + isAccessRequested, + requestLocationPromise, + isRequestingLocation, + requestLocationError, + isMounted, + isMounting, + mount, + mountError, + requestLocation, + mountPromise, + isAvailable, +} from './location-manager.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/components/location-manager/location-manager.ts b/packages/sdk/src/scopes/components/location-manager/location-manager.ts new file mode 100644 index 000000000..370dcb08e --- /dev/null +++ b/packages/sdk/src/scopes/components/location-manager/location-manager.ts @@ -0,0 +1,173 @@ +import { isPageReload } from '@telegram-apps/navigation'; +import { getStorageValue, Maybe, setStorageValue } from '@telegram-apps/toolkit'; +import { AbortablePromise } from 'better-promises'; +import type { EventPayload } from '@telegram-apps/bridge'; +import type { Computed } from '@telegram-apps/signals'; + +import { defineMountFn } from '@/scopes/defineMountFn.js'; +import { request } from '@/globals.js'; +import { createWrapComplete } from '@/scopes/wrappers/createWrapComplete.js'; +import { createWrapSupported } from '@/scopes/wrappers/createWrapSupported.js'; +import { NotAvailableError } from '@/errors.js'; +import { defineNonConcurrentFn } from '@/scopes/defineNonConcurrentFn.js'; +import { signalCancel } from '@/scopes/signalCancel.js'; +import type { AsyncOptions } from '@/types.js'; +import { createComputed, createSignal } from '@/signals-registry.js'; + +const COMPONENT_NAME = 'locationManager'; +const CHECK_LOCATION_METHOD = 'web_app_check_location'; + +export interface State { + /** + * If true, indicates that location data tracking is available on the current device. + */ + available: boolean; + /** + * Indicates whether the app has previously requested permission to track location data. + */ + accessRequested: boolean; + /** + * Indicates whether the user has granted the app permission to track location data. + * + * If false and `accessRequested` is true may indicate that: + * + * - The user has simply canceled the permission popup. + * - The user has denied the app permission to track location data. + */ + accessGranted: boolean; +} + +type StorageValue = State; + +const state = createSignal({ + available: false, + accessGranted: false, + accessRequested: false, +}); + +function fromState(key: K): Computed { + return createComputed(() => state()[key]); +} + +/** + * Signal indicating whether the location data tracking is currently available. + */ +export const isAvailable = fromState('available'); + +/** + * Signal indicating whether the user has granted the app permission to track location data. + */ +export const isAccessGranted = fromState('accessGranted'); + +/** + * Signal indicating whether the app has previously requested permission to track location data. + */ +export const isAccessRequested = fromState('accessRequested'); + +/** + * Converts `location_checked` to some common shape. + * @param event - event payload. + * @see location_checked + */ +function eventToState(event: EventPayload<'location_checked'>): State { + console.log(event); + let available = false; + let accessRequested: Maybe; + let accessGranted: Maybe; + if (event.available) { + available = true; + accessRequested = event.access_requested; + accessGranted = event.access_granted; + } + return { + available, + accessGranted: accessGranted || false, + accessRequested: accessRequested || false, + }; +} + +const [ + mountFn, + tMountPromise, + tMountError, + tIsMounted, +] = defineMountFn( + COMPONENT_NAME, + (options?: AsyncOptions) => { + const s = isPageReload() && getStorageValue(COMPONENT_NAME); + return s + ? AbortablePromise.resolve(s) + : request('web_app_check_location', 'location_checked', options).then(eventToState); + }, + s => { + state.set(s); + setStorageValue(COMPONENT_NAME, s); + }, +); + +const wrapSupported = createWrapSupported(COMPONENT_NAME, CHECK_LOCATION_METHOD); +const wrapComplete = createWrapComplete(COMPONENT_NAME, tIsMounted[0], CHECK_LOCATION_METHOD); + +/** + * Mounts the location manager component. + * @since Mini Apps v8.0 + * @throws {FunctionNotAvailableError} The environment is unknown + * @throws {FunctionNotAvailableError} The SDK is not initialized + * @throws {FunctionNotAvailableError} The function is not supported + * @example + * if (mount.isAvailable()) { + * await mount(); + * } + */ +export const mount = wrapSupported('mount', mountFn); +export const [, mountPromise, isMounting] = tMountPromise; +export const [, mountError] = tMountError; +export const [_isMounted, isMounted] = tIsMounted; + +const [ + reqLocationFn, + tReqLocationPromise, + tReqLocationError, +] = defineNonConcurrentFn( + (options?: AsyncOptions) => { + return request('web_app_request_location', 'location_requested', options).then(data => { + if (!data.available) { + state.set({ ...state(), available: false }); + throw new NotAvailableError('Location data tracking is not available'); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { available, ...rest } = data; + return rest; + }); + }, + 'Location request is currently in progress', +); + + +/** + * Requests location data. + * @since Mini Apps v8.0 + * @returns Promise with location data. + * @throws {FunctionNotAvailableError} The environment is unknown + * @throws {FunctionNotAvailableError} The SDK is not initialized + * @throws {FunctionNotAvailableError} The function is not supported + * @throws {FunctionNotAvailableError} The parent component is not mounted + * @throws {ConcurrentCallError} Location request is currently in progress + * @throws {NotAvailableError} Location data tracking is not available + * @example + * if (requestLocation.isAvailable()) { + * const location = await requestLocation(); + * } + */ +export const requestLocation = wrapComplete('getLocation', reqLocationFn); +export const [, requestLocationPromise, isRequestingLocation] = tReqLocationPromise; +export const [, requestLocationError] = tReqLocationError; + + +/** + * Unmounts the component. + */ +export function unmount(): void { + signalCancel(requestLocationPromise); + _isMounted.set(false); +} \ No newline at end of file diff --git a/packages/sdk/src/scopes/utilities/uncategorized/exports.ts b/packages/sdk/src/scopes/utilities/uncategorized/exports.ts index a377518fa..7f8162745 100644 --- a/packages/sdk/src/scopes/utilities/uncategorized/exports.ts +++ b/packages/sdk/src/scopes/utilities/uncategorized/exports.ts @@ -1,5 +1,6 @@ export { getCurrentTime } from './getCurrentTime.js'; export { readTextFromClipboard } from './readTextFromClipboard.js'; export { sendData } from './sendData.js'; +export { shareMessage } from './shareMessage.js'; export { shareStory, type ShareStoryOptions } from './shareStory.js'; export { switchInlineQuery } from './switchInlineQuery.js'; \ No newline at end of file diff --git a/packages/sdk/src/scopes/utilities/uncategorized/shareMessage.test.ts b/packages/sdk/src/scopes/utilities/uncategorized/shareMessage.test.ts new file mode 100644 index 000000000..1dc442d55 --- /dev/null +++ b/packages/sdk/src/scopes/utilities/uncategorized/shareMessage.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, vi, it, expect } from 'vitest'; + +import { testSafety } from '@test-utils/predefined/testSafety.js'; +import { shareMessage } from '@/scopes/utilities/uncategorized/shareMessage.js'; +import { + mockMiniAppsEnv, + mockPostEvent, + resetPackageState, + setMaxVersion, +} from '@test-utils/utils.js'; +import { emitEvent } from '@telegram-apps/bridge'; +import { ShareMessageError } from '@/errors.js'; + +beforeEach(() => { + vi.restoreAllMocks(); + resetPackageState(); + mockPostEvent(); +}); + +describe('safety', () => { + testSafety(shareMessage, 'shareMessage', { minVersion: '8.0' }); +}); + +describe('safe', () => { + beforeEach(() => { + mockMiniAppsEnv(); + setMaxVersion(); + }); + + it('should call "web_app_send_prepared_message" with id specified', () => { + const spy = mockPostEvent(); + void shareMessage('ABC'); + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith('web_app_send_prepared_message', { id: 'ABC' }); + }); + + it('should resolve promise when "prepared_message_sent" event was received', async () => { + const promise = shareMessage('ABC'); + emitEvent('prepared_message_sent'); + await expect(promise).resolves.toBeUndefined(); + }); + + it('should reject promise when "prepared_message_failed" event was received', async () => { + const promise = shareMessage('ABC'); + emitEvent('prepared_message_failed', { error: 'My custom error' }); + await expect(promise).rejects.toStrictEqual(new ShareMessageError('My custom error')); + }); +}); \ No newline at end of file diff --git a/packages/sdk/src/scopes/utilities/uncategorized/shareMessage.ts b/packages/sdk/src/scopes/utilities/uncategorized/shareMessage.ts new file mode 100644 index 000000000..f3e3a6537 --- /dev/null +++ b/packages/sdk/src/scopes/utilities/uncategorized/shareMessage.ts @@ -0,0 +1,35 @@ +import type { AbortablePromise } from 'better-promises'; + +import { wrapSafe } from '@/scopes/wrappers/wrapSafe.js'; +import { request } from '@/globals.js'; +import { ShareMessageError } from '@/errors.js'; +import type { AsyncOptions } from '@/types.js'; + +const METHOD_NAME = 'web_app_send_prepared_message'; + +/** + * Opens a dialog allowing the user to share a message provided by the bot. + * @since Mini Apps v8.0 + * @throws {FunctionNotAvailableError} The function is not supported + * @throws {FunctionNotAvailableError} The environment is unknown + * @throws {FunctionNotAvailableError} The SDK is not initialized + * @throws {ShareMessageError} Message sharing failed. + * @example + * if (shareMessage.isAvailable()) { + * await shareMessage('bbhjSYgvck23'); + * } + */ +export const shareMessage = wrapSafe( + 'shareMessage', + (id: string, options?: AsyncOptions): AbortablePromise => { + return request(METHOD_NAME, ['prepared_message_failed', 'prepared_message_sent'], { + ...options, + params: { id }, + }).then(data => { + if (data && 'error' in data) { + throw new ShareMessageError(data.error); + } + }); + }, + { isSupported: METHOD_NAME }, +); \ No newline at end of file