Skip to content

feat: migrate to direct links #115

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 16 commits into from
Dec 1, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/sdk/src/provider/bridge/bridge-gateway.ts
Original file line number Diff line number Diff line change
@@ -78,10 +78,15 @@ export class BridgeGateway {
url.searchParams.append('to', receiver);
url.searchParams.append('ttl', (ttl || this.defaultTtl).toString());
url.searchParams.append('topic', topic);
await fetch(url, {

const response = await fetch(url, {
method: 'post',
body: Base64.encode(message)
});

if (!response.ok) {
throw new TonConnectError(`Bridge send failed, status ${response.status}`);
}
}

public pause(): void {
20 changes: 18 additions & 2 deletions packages/sdk/src/provider/bridge/bridge-provider.ts
Original file line number Diff line number Diff line change
@@ -344,9 +344,25 @@ export class BridgeProvider implements HTTPProvider {
const urlToWrap = this.generateRegularUniversalLink('about:blank', message);
const linkParams = urlToWrap.split('?')[1]!;

const startattach = 'tonconnect-' + encodeTelegramUrlParameters(linkParams);
const startapp = 'tonconnect-' + encodeTelegramUrlParameters(linkParams);

// TODO: Remove this line after all dApps and the wallets-list.json have been updated
const updatedUniversalLink = this.convertToDirectLink(universalLink);

const url = new URL(updatedUniversalLink);
url.searchParams.append('startapp', startapp);
return url.toString();
}

// TODO: Remove this method after all dApps and the wallets-list.json have been updated
private convertToDirectLink(universalLink: string): string {
const url = new URL(universalLink);
url.searchParams.append('startattach', startattach);

if (url.searchParams.has('attach')) {
url.searchParams.delete('attach');
url.pathname += '/start';
}

return url.toString();
}

3 changes: 2 additions & 1 deletion packages/sdk/src/provider/provider.ts
Original file line number Diff line number Diff line change
@@ -29,7 +29,8 @@ interface BaseProvider {
closeConnection(): void;
disconnect(): Promise<void>;
sendRequest<T extends RpcMethod>(
request: WithoutId<AppRequest<T>>
request: WithoutId<AppRequest<T>>,
onRequestSent?: () => void
): Promise<WithoutId<WalletResponse<T>>>;
listen(eventsCallback: (e: WithoutIdDistributive<WalletEvent>) => void): void;
}
8 changes: 6 additions & 2 deletions packages/sdk/src/ton-connect.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TonConnectError } from 'src/errors';
import { Account, WalletConnectionSource, Wallet, WalletConnectionSourceHTTP } from 'src/models';
import { Account, Wallet, WalletConnectionSource, WalletConnectionSourceHTTP } from 'src/models';
import { SendTransactionRequest, SendTransactionResponse } from 'src/models/methods';
import { ConnectAdditionalRequest } from 'src/models/methods/connect/connect-additional-request';
import { WalletInfo } from 'src/models/wallet/wallet-info';
@@ -72,8 +72,12 @@ export interface ITonConnect {
/**
* Asks connected wallet to sign and send the transaction.
* @param transaction transaction to send.
* @param onRequestSent (optional) will be called after the transaction is sent to the wallet.
* @returns signed transaction boc that allows you to find the transaction in the blockchain.
* If user rejects transaction, method will throw the corresponding error.
*/
sendTransaction(transaction: SendTransactionRequest): Promise<SendTransactionResponse>;
sendTransaction(
transaction: SendTransactionRequest,
onRequestSent?: () => void
): Promise<SendTransactionResponse>;
}
7 changes: 5 additions & 2 deletions packages/sdk/src/ton-connect.ts
Original file line number Diff line number Diff line change
@@ -226,11 +226,13 @@ export class TonConnect implements ITonConnect {
/**
* Asks connected wallet to sign and send the transaction.
* @param transaction transaction to send.
* @param onRequestSent (optional) will be called after the transaction is sent to the wallet.
* @returns signed transaction boc that allows you to find the transaction in the blockchain.
* If user rejects transaction, method will throw the corresponding error.
*/
public async sendTransaction(
transaction: SendTransactionRequest
transaction: SendTransactionRequest,
onRequestSent?: () => void
): Promise<SendTransactionResponse> {
this.checkConnection();
checkSendTransactionSupport(this.wallet!.device.features, {
@@ -247,7 +249,8 @@ export class TonConnect implements ITonConnect {
valid_until: validUntil,
from,
network
})
}),
onRequestSent
);

if (sendTransactionParser.isError(response)) {
6 changes: 5 additions & 1 deletion packages/sdk/src/utils/url.ts
Original file line number Diff line number Diff line change
@@ -10,7 +10,11 @@ export function addPathToUrl(url: string, path: string): string {
return removeUrlLastSlash(url) + '/' + path;
}

export function isTelegramUrl(link: string): boolean {
export function isTelegramUrl(link: string | undefined): link is string {
if (!link) {
return false;
}

const url = new URL(link);
return url.protocol === 'tg:' || url.hostname === 't.me';
}
6 changes: 3 additions & 3 deletions packages/ui/src/app/assets/i18n/en.json
Original file line number Diff line number Diff line change
@@ -44,8 +44,8 @@
"wallets": "Wallets",
"mobileUniversalModal": {
"connectYourWallet": "Connect your wallet",
"openWalletOnTelegramOrSelect": "Open Wallet on Telegram or select your wallet to connect",
"openWalletOnTelegram": "Open Wallet on Telegram",
"openWalletOnTelegramOrSelect": "Open Wallet in Telegram or select your wallet to connect",
"openWalletOnTelegram": "Open Wallet in Telegram",
"openLink": "Open Link",
"scan": "Scan with your mobile wallet"
},
@@ -66,7 +66,7 @@
"dontHaveExtension": "Seems you don't have installed {{ name }} browser extension",
"getWallet": "Get {{ name }}",
"continueOnDesktop": "Continue in {{ name }} on desktop…",
"openWalletOnTelegram": "Open Wallet on Telegram on desktop",
"openWalletOnTelegram": "Open Wallet in Telegram on desktop",
"connectionDeclined": "Connection declined"
},
"infoModal": {
4 changes: 2 additions & 2 deletions packages/ui/src/app/directives/android-back-handler.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Accessor, onCleanup } from 'solid-js';
import { getUserAgent } from 'src/app/utils/web-api';
import { createMacrotask, getUserAgent } from 'src/app/utils/web-api';

/**
* A directive that enhances the behavior of modal-like components on Android devices, ensuring
@@ -59,7 +59,7 @@ export default function androidBackHandler(
// Create a macrotask using `requestAnimationFrame()` to ensure that any pending microtasks,
// such as asynchronous operations from other developers (e.g., tracking wallet connection status
// and calling `history.pushState()), are completed before we proceed with cleaning up the history state.
new Promise(resolve => requestAnimationFrame(resolve)).then(() => {
createMacrotask(() => {
// If the current history state is the one that was added by this directive,
if (window.history.state?.[ROUTE_STATE_KEY] === true) {
// navigate back in the browser's history to clean up the state.
6 changes: 6 additions & 0 deletions packages/ui/src/app/styles/media.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getWindow } from 'src/app/utils/web-api';
import { isTmaPlatform } from 'src/app/utils/tma-api';

export type Device = 'mobile' | 'tablet' | 'desktop';

@@ -13,6 +14,11 @@ export function isDevice(device: keyof typeof maxWidth | 'desktop'): boolean {
return device === 'desktop';
}

// TODO: remove this check when weba will fix viewport width
if (isTmaPlatform('weba')) {
return true;
}

const width = window.innerWidth;

switch (device) {
15 changes: 10 additions & 5 deletions packages/ui/src/app/utils/copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
export function copyToClipboard(text: string): Promise<void> {
if (navigator?.clipboard) {
return navigator.clipboard.writeText(text);
}
import { TonConnectUIError } from 'src/errors';

export async function copyToClipboard(text: string): Promise<void> {
try {
if (!navigator?.clipboard) {
throw new TonConnectUIError('Clipboard API not available');
}

return await navigator.clipboard.writeText(text);
} catch (e) {}

fallbackCopyTextToClipboard(text);
return Promise.resolve();
}

function fallbackCopyTextToClipboard(text: string): void {
180 changes: 180 additions & 0 deletions packages/ui/src/app/utils/tma-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { getWindow } from 'src/app/utils/web-api';
import { TonConnectUIError } from 'src/errors';
import { logError } from 'src/app/utils/log';

type TmaPlatform = 'android' | 'ios' | 'macos' | 'tdesktop' | 'weba' | 'web' | 'unknown';

type TelegramWebviewProxy = {
postEvent(eventType: string, eventData: string): void;
};

declare global {
interface External {
notify: (message: string) => void;
}

interface Window {
TelegramWebviewProxy?: TelegramWebviewProxy;
}
}

let initParams: Record<string, string> = {};
try {
let locationHash = location.hash.toString();
initParams = urlParseHashParams(locationHash);
} catch (e) {}

let tmaPlatform: TmaPlatform = 'unknown';
if (initParams.tgWebAppPlatform) {
tmaPlatform = initParams.tgWebAppPlatform as TmaPlatform;
}

let webAppVersion = '6.0';
if (initParams.tgWebAppVersion) {
webAppVersion = initParams.tgWebAppVersion;
}

/**
* Returns true if the app is running in TMA on the specified platform.
* @param platforms
*/
export function isTmaPlatform(...platforms: TmaPlatform[]): boolean {
return platforms.includes(tmaPlatform);
}

/**
* Returns true if the app is running in TMA.
*/
export function isInTMA(): boolean {
return (
tmaPlatform !== 'unknown' ||
!!(getWindow() as { TelegramWebviewProxy: unknown } | undefined)?.TelegramWebviewProxy
);
}

/**
* Expand the app window.
*/
export function sendExpand(): void {
postEvent('web_app_expand', {});
}

/**
* Opens link in TMA or in new tab and returns a function that closes the tab.
* @param link
*/
export function sendOpenTelegramLink(link: string): void {
const url = new URL(link);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new TonConnectUIError(`Url protocol is not supported: ${url}`);
}
if (url.hostname !== 't.me') {
throw new TonConnectUIError(`Url host is not supported: ${url}`);
}

const pathFull = url.pathname + url.search;

if (isIframe() || versionAtLeast('6.1')) {
postEvent('web_app_open_tg_link', { path_full: pathFull });
} else {
// TODO: alias for openLinkBlank('https://t.me' + pathFull);, remove duplicated code
window.open('https://t.me' + pathFull, '_blank', 'noreferrer noopener');
}
}

function isIframe(): boolean {
try {
return window.parent != null && window !== window.parent;
} catch (e) {
return false;
}
}

function postEvent(eventType: 'web_app_open_tg_link', eventData: { path_full: string }): void;
function postEvent(eventType: 'web_app_expand', eventData: {}): void;
function postEvent(eventType: string, eventData: object): void {
try {
if (window.TelegramWebviewProxy !== undefined) {
window.TelegramWebviewProxy.postEvent(eventType, JSON.stringify(eventData));
} else if (window.external && 'notify' in window.external) {
window.external.notify(JSON.stringify({ eventType: eventType, eventData: eventData }));
} else if (isIframe()) {
const trustedTarget = '*';
const message = JSON.stringify({ eventType: eventType, eventData: eventData });
window.parent.postMessage(message, trustedTarget);
}

throw new TonConnectUIError(`Can't post event to TMA`);
} catch (e) {
logError(`Can't post event to parent window: ${e}`);
}
}

function urlParseHashParams(locationHash: string): Record<string, string> {
locationHash = locationHash.replace(/^#/, '');
let params: Record<string, string> = {};
if (!locationHash.length) {
return params;
}
if (locationHash.indexOf('=') < 0 && locationHash.indexOf('?') < 0) {
params._path = urlSafeDecode(locationHash);
return params;
}
let qIndex = locationHash.indexOf('?');
if (qIndex >= 0) {
let pathParam = locationHash.substr(0, qIndex);
params._path = urlSafeDecode(pathParam);
locationHash = locationHash.substr(qIndex + 1);
}
let query_params = urlParseQueryString(locationHash);
for (let k in query_params) {
params[k] = query_params[k]!;
}
return params;
}

function urlSafeDecode(urlencoded: string): string {
try {
urlencoded = urlencoded.replace(/\+/g, '%20');
return decodeURIComponent(urlencoded);
} catch (e) {
return urlencoded;
}
}

function urlParseQueryString(queryString: string): Record<string, string | null> {
let params: Record<string, string | null> = {};
if (!queryString.length) {
return params;
}
let queryStringParams = queryString.split('&');
let i, param, paramName, paramValue;
for (i = 0; i < queryStringParams.length; i++) {
param = queryStringParams[i]!.split('=');
paramName = urlSafeDecode(param[0]!);
paramValue = param[1] == null ? null : urlSafeDecode(param[1]);
params[paramName] = paramValue;
}
return params;
}

function versionCompare(v1: string | undefined, v2: string | undefined): 0 | 1 | -1 {
if (typeof v1 !== 'string') v1 = '';
if (typeof v2 !== 'string') v2 = '';
let v1List = v1.replace(/^\s+|\s+$/g, '').split('.');
let v2List = v2.replace(/^\s+|\s+$/g, '').split('.');
let a: number, i, p1, p2;
a = Math.max(v1List.length, v2List.length);
for (i = 0; i < a; i++) {
p1 = parseInt(v1List[i]!) || 0;
p2 = parseInt(v2List[i]!) || 0;
if (p1 === p2) continue;
if (p1 > p2) return 1;
return -1;
}
return 0;
}

function versionAtLeast(ver: string): boolean {
return versionCompare(webAppVersion, ver) >= 0;
}
Loading