Skip to content

Commit

Permalink
Merge pull request #331 from Telegram-Mini-Apps/feature/qr-scanner-ca…
Browse files Browse the repository at this point in the history
…pture

Fix incorrect `QRScanner.open()` behavior. Add `capture` option
  • Loading branch information
heyqbnk authored May 29, 2024
2 parents b3f4e93 + 5c95270 commit c8faf73
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 132 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-fans-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tma.js/sdk": minor
---

Improve `QRScanner.open()` method. Improve typings for the `request` function.
19 changes: 14 additions & 5 deletions apps/docs/packages/tma-js-sdk/components/popup.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@ To open a popup, it is required to call the `open` method specifying popup prope
message, and a list of up to 3 buttons.

```typescript
popup.open({
title: 'Hello!',
message: 'Here is a test message.',
buttons: [{ id: 'my-id', type: 'default', text: 'Default text' }]
});
popup
.open({
title: 'Hello!',
message: 'Here is a test message.',
buttons: [{ id: 'my-id', type: 'default', text: 'Default text' }],
})
.then(buttonId => {
console.log(
buttonId === null
? 'User did not click any button'
: `User clicked a button with ID "${buttonId}"`
);
});

console.log(popup.isOpened); // true
```

Expand Down
26 changes: 22 additions & 4 deletions apps/docs/packages/tma-js-sdk/components/qr-scanner.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,36 @@ const qrScanner = initQRScanner();

## Opening and Closing

To open the QR scanner, the developer should use the `open` method, which accepts optional text
displayed to a user. As a result, the method returns a promise that will be resolved in case some QR
was scanned. It could also return `null` in case the scanner was closed.
To open the QR scanner, the developer should use the `open` method:

```typescript
scanner.open('Scan the barcode').then((content) => {
scanner.open('Scan QR code').then((content) => {
console.log(content);
// Output: 'some-data=22l&app=93...'
});
console.log(scanner.isOpened); // true
```

As a result, the method returns a promise that will be resolved in case some QR
was scanned. It may also resolve `null` if the scanner was closed.

It is allowed to pass an object with optional properties `text` and `capture` responsible
for displaying a text in QR scanner and determining if scanned QR should be captured and promise
should be fulfilled.

```ts
scanner.open({
text: 'Scan QR code',
capture({ data }) {
// Capture QRs contanining Telegram user link.
return data.startsWith('https://t.me');
}
}).then((qr) => {
// May be something like 'https://t.me/heyqbnk' or null.
console.log(qr);
});
```

To close the scanner, use the `close` method:

```typescript
Expand Down
114 changes: 69 additions & 45 deletions packages/sdk/src/bridge/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,78 +3,102 @@ import type { ExecuteWithOptions, If, IsNever } from '@/types/index.js';

import { on } from '../events/listening/on.js';
import { postEvent as defaultPostEvent } from '../methods/postEvent.js';
import type {
MiniAppsEventListener,
MiniAppsEventName,
MiniAppsEventPayload,
} from '../events/types.js';
import type { MiniAppsEventName, MiniAppsEventPayload } from '../events/types.js';
import type { MiniAppsMethodName, MiniAppsMethodParams } from '../methods/types/index.js';
import { createCleanup } from '@/misc/createCleanup.js';

interface BasicOptions<Method extends MiniAppsMethodName, Event extends MiniAppsEventName>
extends ExecuteWithOptions {
/**
* Mini Apps method name.
*/
method: Method;
/**
* One or many tracked Mini Apps events.
*/
event: Event | Event[];
/**
* Should return true in case, this event should be captured. If not specified,
* request will be captured automatically.
*/
capture?: If<
IsNever<MiniAppsEventListener<Event>>,
() => boolean,
(payload: MiniAppsEventPayload<Event>) => boolean
>;
}
/**
* `request` method `capture` option.
* @see request
*/
export type RequestCapture<T extends MiniAppsEventName | MiniAppsEventName[]> =
T extends (infer U extends MiniAppsEventName)[]
? If<
IsNever<MiniAppsEventPayload<U>>,
() => boolean,
(payload: {
[K in U]: If<
IsNever<MiniAppsEventPayload<K>>,
{ event: K; },
{ event: K; payload: MiniAppsEventPayload<K> }
>
}[U]) => boolean
>
: T extends MiniAppsEventName
? If<
IsNever<MiniAppsEventPayload<T>>,
() => boolean,
(payload: MiniAppsEventPayload<T>) => boolean
>
: never;

/**
* `request` method options.
* @see request
*/
export type RequestOptions<Method extends MiniAppsMethodName, Event extends MiniAppsEventName> =
& BasicOptions<Method, Event>
export type RequestOptions<
Method extends MiniAppsMethodName,
Event extends MiniAppsEventName | MiniAppsEventName[]
> = {
/**
* Mini Apps method name.
*/
method: Method;
/**
* Tracked Mini Apps events.
*/
event: Event;
/**
* Should return true in case, this event should be captured. If not specified,
* request will be captured automatically.
*/
capture?: RequestCapture<Event>;
}
& ExecuteWithOptions
& If<IsNever<MiniAppsMethodParams<Method>>, {}, {
/**
* List of method parameters.
*/
params: MiniAppsMethodParams<Method>
}>;

export type RequestResult<Event extends MiniAppsEventName | MiniAppsEventName[]> =
Event extends (infer T extends MiniAppsEventName)[]
? MiniAppsEventPayload<T>
: Event extends MiniAppsEventName
? MiniAppsEventPayload<Event>
: never;

/**
* Calls specified Mini Apps method and captures one of the specified events. Returns promise
* which will be resolved in case, specified event was captured.
* @param options - method options.
*/
export async function request<Method extends MiniAppsMethodName, Event extends MiniAppsEventName>(
export async function request<
Method extends MiniAppsMethodName,
Event extends MiniAppsEventName | MiniAppsEventName[]
>(
options: RequestOptions<Method, Event>,
): Promise<MiniAppsEventPayload<Event>> {
let resolve: (payload: MiniAppsEventPayload<Event>) => void;
const promise = new Promise<MiniAppsEventPayload<Event>>((res) => {
): Promise<RequestResult<Event>> {
let resolve: (payload: RequestResult<Event>) => void;
const promise = new Promise<RequestResult<Event>>((res) => {
resolve = res;
});

const {
method,
event,
capture,
postEvent = defaultPostEvent,
timeout,
} = options;

const stoppers = (Array.isArray(event) ? event : [event]).map(
(ev) => on(ev, (payload: any) => {
return (!capture || capture(payload)) && resolve(payload);
}),
const { event, capture, timeout } = options;
const [, cleanup] = createCleanup(
...(Array.isArray(event) ? event : [event]).map(
(ev) => on(ev, (payload: any) => {
return (!capture || capture(payload)) && resolve(payload);
}),
)
);

try {
postEvent(method as any, (options as any).params);
(options.postEvent || defaultPostEvent)(options.method as any, (options as any).params);
return await (timeout ? withTimeout(promise, timeout) : promise);
} finally {
// After promise execution was completed, don't forget to remove all the listeners.
stoppers.forEach((stop) => stop());
cleanup();
}
}
29 changes: 25 additions & 4 deletions packages/sdk/src/components/QRScanner/QRScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { WithSupportsAndTrackableState } from '@/classes/WithSupportsAndTrackabl
import type { PostEvent } from '@/bridge/methods/postEvent.js';
import type { Version } from '@/version/types.js';

import type { QRScannerState } from './types.js';
import { QRScannerOpenOptions, QRScannerState } from './types.js';

// TODO: Usage

Expand Down Expand Up @@ -37,17 +37,30 @@ export class QRScanner extends WithSupportsAndTrackableState<QRScannerState, 'cl
return this.get('isOpened');
}

/**
* Opens scanner with specified title shown to user. Method returns promise
* with scanned QR content in case, it was scanned. It will contain null in
* case, scanner was closed.
* @param options - method options.
*/
async open(options?: QRScannerOpenOptions): Promise<string | null>;
/**
* Opens scanner with specified title shown to user. Method returns promise
* with scanned QR content in case, it was scanned. It will contain null in
* case, scanner was closed.
* @param text - title to display.
*/
async open(text?: string): Promise<string | null> {
async open(text?: string): Promise<string | null>;
async open(textOrOptions?: QRScannerOpenOptions | string): Promise<string | null> {
if (this.isOpened) {
throw new Error('QR scanner is already opened.');
}

const { text, capture }: QRScannerOpenOptions = (
typeof textOrOptions === 'string'
? { text: textOrOptions }
: textOrOptions
) || {};
this.isOpened = true;

try {
Expand All @@ -56,11 +69,19 @@ export class QRScanner extends WithSupportsAndTrackableState<QRScannerState, 'cl
event: ['qr_text_received', 'scan_qr_popup_closed'],
postEvent: this.postEvent,
params: { text },
capture(ev) {
return ev.event === 'scan_qr_popup_closed' || !capture || capture(ev.payload);
},
}) || {};

return result.data || null;
} finally {
const qr = result.data || null;
if (qr) {
this.close();
}
return qr;
} catch(e) {
this.isOpened = false;
throw e;
}
}
}
12 changes: 12 additions & 0 deletions packages/sdk/src/components/QRScanner/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StateEvents } from '@/classes/State/types.js';
import { RequestCapture } from '@/bridge/utils/request.js';

/**
* QRScanner internal state.
Expand All @@ -21,3 +22,14 @@ export type QRScannerEventName = keyof QRScannerEvents;
* QRScanner event listener.
*/
export type QRScannerEventListener<E extends QRScannerEventName> = QRScannerEvents[E];

export interface QRScannerOpenOptions {
/**
* Title to be displayed.
*/
text?: string;
/**
* Function, which should return true, if QR should be captured.
*/
capture?: RequestCapture<'qr_text_received'>;
}
10 changes: 8 additions & 2 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export * from '@/bridge/methods/types/index.js';
export { setTargetOrigin, targetOrigin } from '@/bridge/target-origin.js';
export { captureSameReq } from '@/bridge/utils/captureSameReq.js';
export { invokeCustomMethod } from '@/bridge/utils/invokeCustomMethod.js';
export { request, type RequestOptions } from '@/bridge/utils/request.js';
export {
request,
type RequestOptions,
type RequestCapture,
type RequestResult,
} from '@/bridge/utils/request.js';

/**
* Classnames.
Expand Down Expand Up @@ -81,7 +86,7 @@ export type {
Chat,
ChatType,
User,
InitDataParsed
InitDataParsed,
} from '@/components/InitData/types.js';

// Invoice.
Expand Down Expand Up @@ -139,6 +144,7 @@ export type {
QRScannerEventName,
QRScannerEvents,
QRScannerState,
QRScannerOpenOptions
} from '@/components/QRScanner/types.js';

// SettingsButton.
Expand Down
Loading

0 comments on commit c8faf73

Please sign in to comment.