diff --git a/VERSION_CODE b/VERSION_CODE index 4c009fb2f..bea0d09c4 100644 --- a/VERSION_CODE +++ b/VERSION_CODE @@ -1 +1 @@ -206 \ No newline at end of file +207 \ No newline at end of file diff --git a/app/Navigation.tsx b/app/Navigation.tsx index b28090dd0..56f5e9ddf 100644 --- a/app/Navigation.tsx +++ b/app/Navigation.tsx @@ -96,6 +96,8 @@ import { PendingTxsWatcher } from './components/PendingTxsWatcher'; import { TonconnectWatcher } from './components/TonconnectWatcher'; import { SessionWatcher } from './components/SessionWatcher'; import { MandatoryAuthSetupFragment } from './fragments/secure/MandatoryAuthSetupFragment'; +import { WebViewPreloader } from './components/WebViewPreloader'; +import { holdersUrl } from './engine/api/holders/fetchUserState'; const Stack = createNativeStackNavigator(); Stack.Navigator.displayName = 'MainStack'; @@ -469,6 +471,7 @@ export const Navigation = memo(() => { + ); diff --git a/app/analytics/mixpanel.ts b/app/analytics/mixpanel.ts index c4726c19e..238d6c9a4 100644 --- a/app/analytics/mixpanel.ts +++ b/app/analytics/mixpanel.ts @@ -13,6 +13,8 @@ export enum MixpanelEvent { HoldersEnrollment = 'holders_entrollment', HoldersInfo = 'holders_info', HoldersInfoClose = 'holders_info_close', + HoldersLoadingTime = 'holders_loading_time', + holdersLongLoadingTime = 'holders_long_loading_time', HoldersEnrollmentClose = 'holders_entrollment_close', HoldersClose = 'holders_close', Connect = 'connect', diff --git a/app/components/ScreenHeader.tsx b/app/components/ScreenHeader.tsx index 926c76ca1..504cb4872 100644 --- a/app/components/ScreenHeader.tsx +++ b/app/components/ScreenHeader.tsx @@ -128,7 +128,7 @@ export const ScreenHeader = memo(( Promise<{ uri: string }> }) => { const theme = useTheme(); + const toaster = useToaster(); const onShare = useCallback(async () => { let screenShot: { uri: string } | undefined; if (onScreenCapture) { screenShot = await onScreenCapture(); } - Share.open({ - title: t('receive.share.title'), - message: body, - url: screenShot?.uri, - }); + try { + await Share.open({ + title: t('receive.share.title'), + message: body, + url: screenShot?.uri, + }); + } catch { + toaster.show({ + type: 'error', + message: t('receive.share.error') + }); + } }, [body]); return ( diff --git a/app/components/WebViewPreloader.tsx b/app/components/WebViewPreloader.tsx new file mode 100644 index 000000000..11375e73a --- /dev/null +++ b/app/components/WebViewPreloader.tsx @@ -0,0 +1,14 @@ +import { memo } from "react"; +import { View } from "react-native"; +import WebView from "react-native-webview"; + +export const WebViewPreloader = memo(({ url }: { url: string }) => { + return ( + + + + ); +}); \ No newline at end of file diff --git a/app/components/browser/BrowserExtensions.tsx b/app/components/browser/BrowserExtensions.tsx index 2f485239a..9fb3eedc0 100644 --- a/app/components/browser/BrowserExtensions.tsx +++ b/app/components/browser/BrowserExtensions.tsx @@ -11,7 +11,7 @@ import { extractDomain } from "../../engine/utils/extractDomain"; import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import { useDimensions } from "@react-native-community/hooks"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { holdersUrl as resolveHoldersUrl } from '../../engine/api/holders/fetchAccountState'; +import { holdersUrl as resolveHoldersUrl } from '../../engine/api/holders/fetchUserState'; import { Typography } from "../styles"; import { ConnectedApp } from "../../engine/hooks/dapps/useTonConnectExtenstions"; diff --git a/app/components/products/HoldersAccountItem.tsx b/app/components/products/HoldersAccountItem.tsx index 6b1005d2a..aa5dcb5b3 100644 --- a/app/components/products/HoldersAccountItem.tsx +++ b/app/components/products/HoldersAccountItem.tsx @@ -7,7 +7,7 @@ import { useTypedNavigation } from "../../utils/useTypedNavigation"; import Animated from "react-native-reanimated"; import { useAnimatedPressedInOut } from "../../utils/useAnimatedPressedInOut"; import { useIsConnectAppReady, useJettonContent, usePrice, useTheme } from "../../engine/hooks"; -import { HoldersAccountState, holdersUrl } from "../../engine/api/holders/fetchAccountState"; +import { HoldersUserState, holdersUrl } from "../../engine/api/holders/fetchUserState"; import { GeneralHoldersAccount, GeneralHoldersCard } from "../../engine/api/holders/fetchAccounts"; import { PerfText } from "../basic/PerfText"; import { Typography } from "../styles"; @@ -18,7 +18,7 @@ import { HoldersAccountStatus } from "../../engine/hooks/holders/useHoldersAccou import { WImage } from "../WImage"; import { toBnWithDecimals } from "../../utils/withDecimals"; import { toNano } from "@ton/core"; -import { HoldersAppParams } from "../../fragments/holders/HoldersAppFragment"; +import { HoldersAppParams, HoldersAppParamsType } from "../../fragments/holders/HoldersAppFragment"; import { getAccountName } from "../../utils/holders/getAccountName"; import IcTonIcon from '@assets/ic-ton-acc.svg'; @@ -70,7 +70,7 @@ export const HoldersAccountItem = memo((props: { return true; } - if (holdersAccStatus.state === HoldersAccountState.NeedEnrollment) { + if (holdersAccStatus.state === HoldersUserState.NeedEnrollment) { return true; } @@ -86,12 +86,12 @@ export const HoldersAccountItem = memo((props: { props.onBeforeOpen?.(); if (needsEnrollment) { - const onEnrollType: HoldersAppParams = { type: 'account', id: props.account.id }; + const onEnrollType: HoldersAppParams = { type: HoldersAppParamsType.Account, id: props.account.id }; navigation.navigateHoldersLanding({ endpoint: url, onEnrollType }, props.isTestnet); return; } - navigation.navigateHolders({ type: 'account', id: props.account.id }, props.isTestnet); + navigation.navigateHolders({ type: HoldersAppParamsType.Account, id: props.account.id }, props.isTestnet); }, [props.account, needsEnrollment, props.isTestnet]); const { onPressIn, onPressOut, animatedStyle } = useAnimatedPressedInOut(); diff --git a/app/components/products/HoldersPrepaidCard.tsx b/app/components/products/HoldersPrepaidCard.tsx index 90461f302..ac919670e 100644 --- a/app/components/products/HoldersPrepaidCard.tsx +++ b/app/components/products/HoldersPrepaidCard.tsx @@ -6,7 +6,7 @@ import { useTypedNavigation } from "../../utils/useTypedNavigation"; import Animated from "react-native-reanimated"; import { useAnimatedPressedInOut } from "../../utils/useAnimatedPressedInOut"; import { useIsConnectAppReady, useTheme } from "../../engine/hooks"; -import { HoldersAccountState, holdersUrl } from "../../engine/api/holders/fetchAccountState"; +import { HoldersUserState, holdersUrl } from "../../engine/api/holders/fetchUserState"; import { GeneralHoldersCard, PrePaidHoldersCard } from "../../engine/api/holders/fetchAccounts"; import { PerfText } from "../basic/PerfText"; import { Typography } from "../styles"; @@ -15,7 +15,7 @@ import { toNano } from "@ton/core"; import { CurrencySymbols } from "../../utils/formatCurrency"; import { HoldersAccountCard } from "./HoldersAccountCard"; import { HoldersAccountStatus } from "../../engine/hooks/holders/useHoldersAccountStatus"; -import { HoldersAppParams } from "../../fragments/holders/HoldersAppFragment"; +import { HoldersAppParams, HoldersAppParamsType } from "../../fragments/holders/HoldersAppFragment"; import { useLockAppWithAuthState } from "../../engine/hooks/settings"; export const HoldersPrepaidCard = memo((props: { @@ -50,7 +50,7 @@ export const HoldersPrepaidCard = memo((props: { return true; } - if (holdersAccStatus.state === HoldersAccountState.NeedEnrollment) { + if (holdersAccStatus.state === HoldersUserState.NeedEnrollment) { return true; } @@ -62,12 +62,12 @@ export const HoldersPrepaidCard = memo((props: { props.onBeforeOpen?.(); if (needsEnrollment) { - const onEnrollType: HoldersAppParams = { type: 'prepaid', id: card.id }; + const onEnrollType: HoldersAppParams = { type: HoldersAppParamsType.Prepaid, id: card.id }; navigation.navigateHoldersLanding({ endpoint: url, onEnrollType }, props.isTestnet); return; } - navigation.navigateHolders({ type: 'prepaid', id: card.id }, props.isTestnet); + navigation.navigateHolders({ type: HoldersAppParamsType.Prepaid, id: card.id }, props.isTestnet); }, [card, needsEnrollment, props.onBeforeOpen, props.isTestnet]); const { onPressIn, onPressOut, animatedStyle } = useAnimatedPressedInOut(); diff --git a/app/components/products/ProductsComponent.tsx b/app/components/products/ProductsComponent.tsx index af64ef000..6032e4239 100644 --- a/app/components/products/ProductsComponent.tsx +++ b/app/components/products/ProductsComponent.tsx @@ -13,7 +13,7 @@ import { JettonsHiddenComponent } from "./JettonsHiddenComponent" import { SelectedAccount } from "../../engine/types" import { DappsRequests } from "../../fragments/wallet/products/DappsRequests" import { ProductBanner } from "./ProductBanner" -import { HoldersAccountState, holdersUrl } from "../../engine/api/holders/fetchAccountState" +import { HoldersUserState, holdersUrl } from "../../engine/api/holders/fetchUserState" import { PendingTransactions } from "../../fragments/wallet/views/PendingTransactions" import { Typography } from "../styles" import { useBanners } from "../../engine/hooks/banners" @@ -22,7 +22,8 @@ import { MixpanelEvent, trackEvent } from "../../analytics/mixpanel" import { AddressFormatUpdate } from "./AddressFormatUpdate" import { TonProductComponent } from "./TonProductComponent" import { SpecialJettonProduct } from "./SpecialJettonProduct" -import { useIsHoldersWhitelisted } from "../../engine/hooks/holders/useIsHoldersWhitelisted" +import { useIsHoldersInvited } from "../../engine/hooks/holders/useIsHoldersInvited" +import { HoldersAppParamsType } from "../../fragments/holders/HoldersAppFragment" import OldWalletIcon from '@assets/ic_old_wallet.svg'; @@ -37,11 +38,11 @@ export const ProductsComponent = memo(({ selected }: { selected: SelectedAccount const banners = useBanners(); const url = holdersUrl(isTestnet); const isHoldersReady = useIsConnectAppReady(url); - const isHoldersWhitelisted = useIsHoldersWhitelisted(selected!.address, isTestnet); - const showHoldersBuiltInBanner = (holdersAccounts?.accounts?.length ?? 0) === 0 && isHoldersWhitelisted; + const isHoldersInvited = useIsHoldersInvited(selected!.address, isTestnet); + const showHoldersBuiltInBanner = (holdersAccounts?.accounts?.length ?? 0) === 0 && isHoldersInvited; const needsEnrolment = useMemo(() => { - if (holdersAccStatus?.state === HoldersAccountState.NeedEnrollment) { + if (holdersAccStatus?.state === HoldersUserState.NeedEnrollment) { return true; } return false; @@ -67,10 +68,10 @@ export const ProductsComponent = memo(({ selected }: { selected: SelectedAccount const onHoldersPress = useCallback(() => { if (needsEnrolment || !isHoldersReady) { - navigation.navigateHoldersLanding({ endpoint: url, onEnrollType: { type: 'create' } }, isTestnet); + navigation.navigateHoldersLanding({ endpoint: url, onEnrollType: { type: HoldersAppParamsType.Create } }, isTestnet); return; } - navigation.navigateHolders({ type: 'create' }, isTestnet); + navigation.navigateHolders({ type: HoldersAppParamsType.Create }, isTestnet); }, [needsEnrolment, isHoldersReady, isTestnet]); const onProductBannerPress = useCallback((product: ProductAd) => { @@ -102,7 +103,7 @@ export const ProductsComponent = memo(({ selected }: { selected: SelectedAccount - {(!isHoldersWhitelisted && !!banners?.product) && ( + {(!isHoldersInvited && !!banners?.product) && ( {t('common.balances')} diff --git a/app/components/toast/ToastProvider.tsx b/app/components/toast/ToastProvider.tsx index 9262d06ce..6dd0a3e0d 100644 --- a/app/components/toast/ToastProvider.tsx +++ b/app/components/toast/ToastProvider.tsx @@ -115,7 +115,7 @@ export const Toast = memo(({ {Icon && ( )} - + {message} diff --git a/app/components/webview/DAppWebView.tsx b/app/components/webview/DAppWebView.tsx index 5853c8e16..dd0dd2fc0 100644 --- a/app/components/webview/DAppWebView.tsx +++ b/app/components/webview/DAppWebView.tsx @@ -1,5 +1,5 @@ import { ForwardedRef, RefObject, forwardRef, memo, useCallback, useEffect, useMemo, useReducer, useState } from "react"; -import { KeyboardAvoidingView, Platform, View, StyleSheet, ActivityIndicator, BackHandler } from "react-native"; +import { KeyboardAvoidingView, Platform, View, StyleSheet, ActivityIndicator, BackHandler, Linking } from "react-native"; import WebView, { WebViewMessageEvent, WebViewNavigation, WebViewProps } from "react-native-webview"; import { useNetwork, useTheme } from "../../engine/hooks"; import { WebViewErrorComponent } from "./WebViewErrorComponent"; @@ -22,10 +22,11 @@ import DeviceInfo from 'react-native-device-info'; import { processEmitterMessage } from "./utils/processEmitterMessage"; import { getLastAuthTimestamp, useKeysAuth } from "../secure/AuthWalletKeys"; import { getLockAppWithAuthState } from "../../engine/state/lockAppWithAuthState"; -import { useLockAppWithAuthState } from "../../engine/hooks/settings"; import WalletService, { addCardRequestSchema } from "../../modules/WalletService"; import { getHoldersToken } from "../../engine/hooks/holders/useHoldersAccountStatus"; import { getCurrentAddress } from "../../storage/appState"; +import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; +import { holdersUrl } from "../../engine/api/holders/fetchUserState"; export type DAppWebViewProps = WebViewProps & { useMainButton?: boolean; @@ -72,7 +73,6 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar const navigation = useTypedNavigation(); const toaster = useToaster(); const markRefIdShown = useMarkBannerHidden(); - const [, setLockAppWithAuth] = useLockAppWithAuthState(); const [loaded, setLoaded] = useState(false); @@ -98,15 +98,28 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar } ); + + const safelyOpenUrl = useCallback((url: string) => { try { + const scheme = new URL(url).protocol.replace(':', ''); + const sourceUrl = (props.source as WebViewSourceUri)?.uri; + + if ( + scheme === 'tg' + && !!sourceUrl + && sourceUrl.startsWith(holdersUrl(isTestnet)) + ) { + Linking.openURL(url); + return; + } let pageDomain = extractDomain(url); if (isSafeDomain(pageDomain)) { openWithInApp(url); return; } } catch { } - }, []); + }, [props.source]); const onNavigation = useCallback((url: string) => { if (!props.useQueryAPI) { @@ -331,11 +344,7 @@ export const DAppWebView = memo(forwardRef((props: DAppWebViewProps, ref: Forwar // Basic open url if (data.name === 'openUrl' && data.args.url) { try { - let pageDomain = extractDomain(data.args.url); - if (isSafeDomain(pageDomain)) { - openWithInApp(data.args.url); - return; - } + safelyOpenUrl(data.args.url); } catch { warn('Failed to open url'); return; diff --git a/app/components/webview/utils/processEmitterMessage.ts b/app/components/webview/utils/processEmitterMessage.ts index 08fcc7c11..1b7c79291 100644 --- a/app/components/webview/utils/processEmitterMessage.ts +++ b/app/components/webview/utils/processEmitterMessage.ts @@ -20,7 +20,7 @@ export function processEmitterMessage( case DAppEmitterEvents.APP_READY: setTimeout(() => { setLoaded(true); - }, 200); + }, 100); break; default: break; diff --git a/app/engine/api/holders/fetchAccounts.ts b/app/engine/api/holders/fetchAccounts.ts index bce349e07..fb0919e3b 100644 --- a/app/engine/api/holders/fetchAccounts.ts +++ b/app/engine/api/holders/fetchAccounts.ts @@ -1,7 +1,7 @@ import axios from "axios"; import { Address } from "@ton/core"; import { z } from "zod"; -import { holdersEndpoint } from "./fetchAccountState"; +import { holdersEndpoint } from "./fetchUserState"; const networksSchema = z.union([ z.literal('ton-mainnet'), diff --git a/app/engine/api/holders/fetchAddressInviteCheck.ts b/app/engine/api/holders/fetchAddressInviteCheck.ts new file mode 100644 index 000000000..a7d075a0a --- /dev/null +++ b/app/engine/api/holders/fetchAddressInviteCheck.ts @@ -0,0 +1,60 @@ +import axios from "axios"; +import { holdersEndpoint } from "./fetchUserState"; +import { z } from "zod"; +import { Address } from "@ton/core"; + +const inviteCheckCodec = z.object({ + allowed: z.boolean(), +}); + +export async function fetchAddressInviteCheck( + address: string, + isTestnet: boolean +) { + const endpoint = holdersEndpoint(isTestnet); + const formattedAddress = Address.parse(address).toString({ + testOnly: isTestnet, + }); + + try { + let res = await axios.post(`https://${endpoint}/v2/invite/wallet/check`, { + wallet: formattedAddress, + network: isTestnet ? "ton-testnet" : "ton-mainnet", + }); + + if (res.status >= 400) { + res = await axios.post(`https://${endpoint}/v2/whitelist/wallet/check`, { + wallet: formattedAddress, + }); + } + + const parsed = inviteCheckCodec.safeParse(res.data); + + if (!parsed.success) { + console.warn("Failed to parse invite check response", parsed.error); + return false; + } + + return parsed.data.allowed; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response!.status >= 400) { + const res = await axios.post( + `https://${endpoint}/v2/whitelist/wallet/check`, + { + wallet: formattedAddress, + } + ); + + const parsed = inviteCheckCodec.safeParse(res.data); + + if (!parsed.success) { + console.warn("Failed to parse invite check response", parsed.error); + return false; + } + + return parsed.data.allowed; + } + } + } +} diff --git a/app/engine/api/holders/fetchAddressWhitelistCheck.ts b/app/engine/api/holders/fetchAddressWhitelistCheck.ts deleted file mode 100644 index 3a3d94908..000000000 --- a/app/engine/api/holders/fetchAddressWhitelistCheck.ts +++ /dev/null @@ -1,21 +0,0 @@ -import axios from "axios"; -import { holdersEndpoint } from "./fetchAccountState"; -import { z } from "zod"; - -const whitelistCheckCodec = z.object({ - allowed: z.boolean(), -}); - -export async function fetchAddressWhitelistCheck(address: string, isTestnet: boolean) { - const endpoint = holdersEndpoint(isTestnet); - const res = await axios.post(`https://${endpoint}/v2/whitelist/wallet/check`, { wallet: address }); - - const parsed = whitelistCheckCodec.safeParse(res.data); - - if (!parsed.success) { - console.warn('Failed to parse whitelist check response', parsed.error); - return false; - } - - return parsed.data.allowed; -} \ No newline at end of file diff --git a/app/engine/api/holders/fetchApplePayCredentials.ts b/app/engine/api/holders/fetchApplePayCredentials.ts index d869f9059..67febfa51 100644 --- a/app/engine/api/holders/fetchApplePayCredentials.ts +++ b/app/engine/api/holders/fetchApplePayCredentials.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { holdersEndpoint } from "./fetchAccountState"; +import { holdersEndpoint } from "./fetchUserState"; import { z } from "zod"; const cardCredentialsCodec = z.object({ diff --git a/app/engine/api/holders/fetchCardItem.ts b/app/engine/api/holders/fetchCardItem.ts index 9321669ea..14598b9d4 100644 --- a/app/engine/api/holders/fetchCardItem.ts +++ b/app/engine/api/holders/fetchCardItem.ts @@ -1,6 +1,6 @@ import axios from "axios"; import * as t from "io-ts"; -import { holdersEndpoint } from "./fetchAccountState"; +import { holdersEndpoint } from "./fetchUserState"; export const cardItemCodec = t.type({ ok: t.boolean, diff --git a/app/engine/api/holders/fetchCardsTransactions.ts b/app/engine/api/holders/fetchCardsTransactions.ts index 468e28103..05b981ba3 100644 --- a/app/engine/api/holders/fetchCardsTransactions.ts +++ b/app/engine/api/holders/fetchCardsTransactions.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { holdersEndpoint } from "./fetchAccountState"; +import { holdersEndpoint } from "./fetchUserState"; export type CountryCode = 'AC' | 'AD' | 'AE' | 'AF' | 'AG' | 'AI' | 'AL' | 'AM' | 'AO' | 'AR' | 'AS' | 'AT' | 'AU' | 'AW' | 'AX' | 'AZ' | 'BA' | 'BB' | 'BD' | 'BE' | 'BF' | 'BG' | 'BH' | 'BI' | 'BJ' | 'BL' | 'BM' | 'BN' | 'BO' | 'BQ' | 'BR' | 'BS' | 'BT' | 'BW' | 'BY' | 'BZ' | 'CA' | 'CC' | 'CD' | 'CF' | 'CG' | 'CH' | 'CI' | 'CK' | 'CL' | 'CM' | 'CN' | 'CO' | 'CR' | 'CU' | 'CV' | 'CW' | 'CX' | 'CY' | 'CZ' | 'DE' | 'DJ' | 'DK' | 'DM' | 'DO' | 'DZ' | 'EC' | 'EE' | 'EG' | 'EH' | 'ER' | 'ES' | 'ET' | 'FI' | 'FJ' | 'FK' | 'FM' | 'FO' | 'FR' | 'GA' | 'GB' | 'GD' | 'GE' | 'GF' | 'GG' | 'GH' | 'GI' | 'GL' | 'GM' | 'GN' | 'GP' | 'GQ' | 'GR' | 'GT' | 'GU' | 'GW' | 'GY' | 'HK' | 'HN' | 'HR' | 'HT' | 'HU' | 'ID' | 'IE' | 'IL' | 'IM' | 'IN' | 'IO' | 'IQ' | 'IR' | 'IS' | 'IT' | 'JE' | 'JM' | 'JO' | 'JP' | 'KE' | 'KG' | 'KH' | 'KI' | 'KM' | 'KN' | 'KP' | 'KR' | 'KW' | 'KY' | 'KZ' | 'LA' | 'LB' | 'LC' | 'LI' | 'LK' | 'LR' | 'LS' | 'LT' | 'LU' | 'LV' | 'LY' | 'MA' | 'MC' | 'MD' | 'ME' | 'MF' | 'MG' | 'MH' | 'MK' | 'ML' | 'MM' | 'MN' | 'MO' | 'MP' | 'MQ' | 'MR' | 'MS' | 'MT' | 'MU' | 'MV' | 'MW' | 'MX' | 'MY' | 'MZ' | 'NA' | 'NC' | 'NE' | 'NF' | 'NG' | 'NI' | 'NL' | 'NO' | 'NP' | 'NR' | 'NU' | 'NZ' | 'OM' | 'PA' | 'PE' | 'PF' | 'PG' | 'PH' | 'PK' | 'PL' | 'PM' | 'PR' | 'PS' | 'PT' | 'PW' | 'PY' | 'QA' | 'RE' | 'RO' | 'RS' | 'RU' | 'RW' | 'SA' | 'SB' | 'SC' | 'SD' | 'SE' | 'SG' | 'SH' | 'SI' | 'SJ' | 'SK' | 'SL' | 'SM' | 'SN' | 'SO' | 'SR' | 'SS' | 'ST' | 'SV' | 'SX' | 'SY' | 'SZ' | 'TA' | 'TC' | 'TD' | 'TG' | 'TH' | 'TJ' | 'TK' | 'TL' | 'TM' | 'TN' | 'TO' | 'TR' | 'TT' | 'TV' | 'TW' | 'TZ' | 'UA' | 'UG' | 'US' | 'UY' | 'UZ' | 'VA' | 'VC' | 'VE' | 'VG' | 'VI' | 'VN' | 'VU' | 'WF' | 'WS' | 'XK' | 'YE' | 'YT' | 'ZA' | 'ZM' | 'ZW'; diff --git a/app/engine/api/holders/fetchAccountState.ts b/app/engine/api/holders/fetchUserState.ts similarity index 71% rename from app/engine/api/holders/fetchAccountState.ts rename to app/engine/api/holders/fetchUserState.ts index 27bdb6e80..6c914e49b 100644 --- a/app/engine/api/holders/fetchAccountState.ts +++ b/app/engine/api/holders/fetchUserState.ts @@ -10,11 +10,11 @@ export const holdersUrlProd = 'https://tonhub.holders.io'; export const holdersEndpoint = (isTestnet: boolean) => isTestnet ? holdersEndpointStage : holdersEndpointProd; export const holdersUrl = (isTestnet: boolean) => isTestnet ? holdersUrlStage : holdersUrlProd; -export type AccountState = z.infer; +export type UserState = z.infer; -export type AccountStateRes = { ok: boolean, state: AccountState }; +export type UserStateRes = { ok: boolean, state: UserState }; -export enum HoldersAccountState { +export enum HoldersUserState { NeedEnrollment = 'need-enrollment', NeedPhone = 'need-phone', NoRef = 'no-ref', @@ -23,15 +23,15 @@ export enum HoldersAccountState { Ok = 'ok', } -export const accountStateCodec = z.union([ +export const userStateCodec = z.union([ z.object({ state: z.union([ - z.literal(HoldersAccountState.NeedPhone), - z.literal(HoldersAccountState.NoRef), + z.literal(HoldersUserState.NeedPhone), + z.literal(HoldersUserState.NoRef), ]), }), z.object({ - state: z.literal(HoldersAccountState.NeedKyc), + state: z.literal(HoldersUserState.NeedKyc), kycStatus: z.union([ z.null(), z.object({ @@ -53,9 +53,9 @@ export const accountStateCodec = z.union([ }), z.object({ state: z.union([ - z.literal(HoldersAccountState.Ok), - z.literal(HoldersAccountState.NeedEmail), - z.literal(HoldersAccountState.NeedPhone), + z.literal(HoldersUserState.Ok), + z.literal(HoldersUserState.NeedEmail), + z.literal(HoldersUserState.NeedPhone), ]), notificationSettings: z.object({ enabled: z.boolean(), @@ -64,12 +64,12 @@ export const accountStateCodec = z.union([ }), ]); -export const accountStateResCodec = z.object({ +export const userStateResCodec = z.object({ ok: z.boolean(), - state: accountStateCodec, + state: userStateCodec, }); -export async function fetchAccountState(token: string, isTestnet: boolean): Promise { +export async function fetchUserState(token: string, isTestnet: boolean): Promise { const endpoint = isTestnet ? holdersEndpointStage : holdersEndpointProd; const res = await axios.post(`https://${endpoint}/account/state`, { token }); @@ -81,5 +81,5 @@ export async function fetchAccountState(token: string, isTestnet: boolean): Prom throw Error('Failed to fetch account state'); } - return res.data.state as AccountState; + return res.data.state as UserState; } \ No newline at end of file diff --git a/app/engine/api/holders/fetchAccountToken.ts b/app/engine/api/holders/fetchUserToken.ts similarity index 87% rename from app/engine/api/holders/fetchAccountToken.ts rename to app/engine/api/holders/fetchUserToken.ts index a1cf12afc..270a45bbb 100644 --- a/app/engine/api/holders/fetchAccountToken.ts +++ b/app/engine/api/holders/fetchUserToken.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { holdersEndpoint } from './fetchAccountState'; +import { holdersEndpoint } from './fetchUserState'; import { z } from 'zod'; const tonconnectV2Config = z.object({ @@ -57,12 +57,13 @@ const keys = z.union([tonXKey, tonXLiteKey, tonconnectV2Key]); export type AccountKeyParam = z.infer; -export async function fetchAccountToken(key: AccountKeyParam, isTestnet: boolean): Promise { +export async function fetchUserToken(key: AccountKeyParam, isTestnet: boolean, inviteId?: string): Promise { const endpoint = holdersEndpoint(isTestnet); const requestParams = { stack: 'ton', network: isTestnet ? 'ton-testnet' : 'ton-mainnet', - key: key + key: key, + inviteId }; const res = await axios.post( @@ -71,7 +72,7 @@ export async function fetchAccountToken(key: AccountKeyParam, isTestnet: boolean ); if (!res.data.ok) { - throw Error('Failed to fetch card token'); + throw Error('Failed to fetch user token'); } return res.data.token as string; } \ No newline at end of file diff --git a/app/engine/clients.ts b/app/engine/clients.ts index edf61f8b5..ffc950e82 100644 --- a/app/engine/clients.ts +++ b/app/engine/clients.ts @@ -1,10 +1,23 @@ import { TonClient4 } from '@ton/ton'; import { QueryClient } from '@tanstack/react-query'; +import { Platform } from 'react-native'; +import * as Application from 'expo-application'; + + +const requestInterceptorMainnet = (config: any) => { + config.headers['User-Agent'] = `Tonhub/${Application.nativeApplicationVersion} ${Platform.OS}`; + return config; +}; + +const requestInterceptorTestnet = (config: any) => { + config.headers['User-Agent'] = `Tonhub/${Application.nativeApplicationVersion} ${Platform.OS} (testnet)`; + return config; +}; export const clients = { ton: { - testnet: new TonClient4({ endpoint: 'https://testnet-v4.tonhubapi.com', timeout: 5000 }), - mainnet: new TonClient4({ endpoint: 'https://mainnet-v4.tonhubapi.com', timeout: 5000 }), + testnet: new TonClient4({ endpoint: 'https://testnet-v4.tonhubapi.com', timeout: 5000, requestInterceptor: requestInterceptorTestnet }), + mainnet: new TonClient4({ endpoint: 'https://mainnet-v4.tonhubapi.com', timeout: 5000, requestInterceptor: requestInterceptorMainnet }), } } diff --git a/app/engine/effects/onHoldersEnroll.ts b/app/engine/effects/onHoldersEnroll.ts index 4ae8045ac..3113ae4c9 100644 --- a/app/engine/effects/onHoldersEnroll.ts +++ b/app/engine/effects/onHoldersEnroll.ts @@ -2,7 +2,7 @@ import { Address } from "@ton/ton"; import { queryClient } from "../clients"; import { Queries } from "../queries"; import { getHoldersToken } from "../hooks/holders/useHoldersAccountStatus"; -import { HoldersAccountState, fetchAccountState } from "../api/holders/fetchAccountState"; +import { HoldersUserState, fetchUserState } from "../api/holders/fetchUserState"; import { fetchAccountsPublic, fetchAccountsList } from "../api/holders/fetchAccounts"; import { updateProvisioningCredentials } from "../holders/updateProvisioningCredentials"; @@ -12,17 +12,17 @@ export async function onHoldersEnroll(account: string, isTestnet: boolean) { const token = getHoldersToken(address); if (!token) { - return { state: HoldersAccountState.NeedEnrollment } as { state: HoldersAccountState.NeedEnrollment }; // This looks amazingly stupid + return { state: HoldersUserState.NeedEnrollment } as { state: HoldersUserState.NeedEnrollment }; // This looks amazingly stupid } - const fetched = await fetchAccountState(token, isTestnet); + const fetched = await fetchUserState(token, isTestnet); if (!fetched) { - return { state: HoldersAccountState.NeedEnrollment } as { state: HoldersAccountState.NeedEnrollment }; + return { state: HoldersUserState.NeedEnrollment } as { state: HoldersUserState.NeedEnrollment }; } const status = { ...fetched, token }; queryClient.setQueryData(Queries.Holders(address).Status(), () => status); - if (status.state === HoldersAccountState.Ok) { + if (status.state === HoldersUserState.Ok) { let accounts; let prepaidCards; let type = 'public'; diff --git a/app/engine/holders/watchHoldersAccountUpdates.ts b/app/engine/holders/watchHoldersAccountUpdates.ts index 1383bf4e8..fba7be9e0 100644 --- a/app/engine/holders/watchHoldersAccountUpdates.ts +++ b/app/engine/holders/watchHoldersAccountUpdates.ts @@ -1,5 +1,5 @@ import { createLogger } from '../../utils/log'; -import { holdersEndpoint } from '../api/holders/fetchAccountState'; +import { holdersEndpoint } from '../api/holders/fetchUserState'; let index = 0; diff --git a/app/engine/holdersWatcher.ts b/app/engine/holdersWatcher.ts index 8ebe6d45b..b815646d4 100644 --- a/app/engine/holdersWatcher.ts +++ b/app/engine/holdersWatcher.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useHoldersAccountStatus } from "./hooks/holders/useHoldersAccountStatus"; import { useSelectedAccount } from "./hooks/appstate/useSelectedAccount"; -import { HoldersAccountState } from "./api/holders/fetchAccountState"; +import { HoldersUserState } from "./api/holders/fetchUserState"; import { useNetwork } from "./hooks/network/useNetwork"; import { watchHoldersAccountUpdates } from './holders/watchHoldersAccountUpdates'; import { queryClient } from "./clients"; @@ -14,8 +14,8 @@ export function useHoldersWatcher() { const status = useHoldersAccountStatus(account?.address.toString({ testOnly: isTestnet }) ?? ''); const cards = useHoldersAccounts(account?.address.toString({ testOnly: isTestnet }) ?? ''); - useEffect(() => { - if (status?.data?.state !== HoldersAccountState.Ok) { + useEffect(() => { + if (status?.data?.state !== HoldersUserState.Ok) { return; } diff --git a/app/engine/hooks/holders/useCardTransactions.ts b/app/engine/hooks/holders/useCardTransactions.ts index e6cd9c6a1..e7e6eee2c 100644 --- a/app/engine/hooks/holders/useCardTransactions.ts +++ b/app/engine/hooks/holders/useCardTransactions.ts @@ -2,7 +2,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { CardNotification, fetchCardsTransactions } from "../../api/holders/fetchCardsTransactions"; import { Queries } from "../../queries"; import { useHoldersAccountStatus } from "./useHoldersAccountStatus"; -import { HoldersAccountState } from "../../api/holders/fetchAccountState"; +import { HoldersUserState } from "../../api/holders/fetchUserState"; export function useCardTransactions(address: string, id: string) { let status = useHoldersAccountStatus(address).data; @@ -23,7 +23,7 @@ export function useCardTransactions(address: string, id: string) { }; }, queryFn: async (ctx) => { - if (!!status && status.state !== HoldersAccountState.NeedEnrollment) { + if (!!status && status.state !== HoldersUserState.NeedEnrollment) { const cardRes = await fetchCardsTransactions(status.token, id, 40, ctx.pageParam?.lastCursor, 'desc'); if (!!cardRes) { return cardRes; diff --git a/app/engine/hooks/holders/useClearHolders.ts b/app/engine/hooks/holders/useClearHolders.ts index a5a674c7f..15fe88925 100644 --- a/app/engine/hooks/holders/useClearHolders.ts +++ b/app/engine/hooks/holders/useClearHolders.ts @@ -1,4 +1,4 @@ -import { holdersUrl } from "../../api/holders/fetchAccountState"; +import { holdersUrl } from "../../api/holders/fetchUserState"; import { useDomainKeys } from "../dapps/useDomainKeys"; import { deleteHoldersToken } from "./useHoldersAccountStatus"; import { extractDomain } from "../../utils/extractDomain"; diff --git a/app/engine/hooks/holders/useHasHoldersProducts.ts b/app/engine/hooks/holders/useHasHoldersProducts.ts index e4e312900..d4c48777f 100644 --- a/app/engine/hooks/holders/useHasHoldersProducts.ts +++ b/app/engine/hooks/holders/useHasHoldersProducts.ts @@ -4,7 +4,7 @@ import { Queries } from "../../queries"; import { queryClient } from "../../clients"; import { getQueryData } from "../../utils/getQueryData"; import { HoldersAccountStatus } from "./useHoldersAccountStatus"; -import { HoldersAccountState } from "../../api/holders/fetchAccountState"; +import { HoldersUserState } from "../../api/holders/fetchUserState"; import { HoldersAccounts } from "./useHoldersAccounts"; function hasAccounts(accs: HoldersAccounts | undefined) { @@ -24,8 +24,8 @@ export function getHasHoldersProducts(address: string) { const token = ( !!status && - status.state !== HoldersAccountState.NoRef && - status.state !== HoldersAccountState.NeedEnrollment + status.state !== HoldersUserState.NoRef && + status.state !== HoldersUserState.NeedEnrollment ) ? status.token : null; const accounts = getQueryData(queryCache, Queries.Holders(address).Cards(!!token ? 'private' : 'public')); diff --git a/app/engine/hooks/holders/useHoldersAccountStatus.ts b/app/engine/hooks/holders/useHoldersAccountStatus.ts index cfe841fbb..aeeb04ff6 100644 --- a/app/engine/hooks/holders/useHoldersAccountStatus.ts +++ b/app/engine/hooks/holders/useHoldersAccountStatus.ts @@ -4,13 +4,13 @@ import { Address } from "@ton/core"; import { useMemo } from "react"; import { useNetwork } from "../network/useNetwork"; import { storage } from "../../../storage/storage"; -import { HoldersAccountState, accountStateCodec, fetchAccountState } from "../../api/holders/fetchAccountState"; +import { HoldersUserState, userStateCodec, fetchUserState } from "../../api/holders/fetchUserState"; import { z } from 'zod'; import { removeProvisioningCredentials } from "../../holders/updateProvisioningCredentials"; const holdersAccountStatus = z.union([ - z.object({ state: z.literal(HoldersAccountState.NeedEnrollment) }), - z.intersection(z.object({ token: z.string() }), accountStateCodec), + z.object({ state: z.literal(HoldersUserState.NeedEnrollment) }), + z.intersection(z.object({ token: z.string() }), userStateCodec), ]); export type HoldersAccountStatus = z.infer; @@ -63,13 +63,13 @@ export function useHoldersAccountStatus(address: string | Address) { const token = getHoldersToken(addr); if (!token) { - return { state: HoldersAccountState.NeedEnrollment } as HoldersAccountStatus; // This looks amazingly stupid + return { state: HoldersUserState.NeedEnrollment } as HoldersAccountStatus; // This looks amazingly stupid } - const fetched = await fetchAccountState(token, isTestnet); + const fetched = await fetchUserState(token, isTestnet); if (!fetched) { - return { state: HoldersAccountState.NeedEnrollment } as HoldersAccountStatus; + return { state: HoldersUserState.NeedEnrollment } as HoldersAccountStatus; } return { ...fetched, token } as HoldersAccountStatus; diff --git a/app/engine/hooks/holders/useHoldersAccounts.ts b/app/engine/hooks/holders/useHoldersAccounts.ts index f8507d2ef..6a15f01bb 100644 --- a/app/engine/hooks/holders/useHoldersAccounts.ts +++ b/app/engine/hooks/holders/useHoldersAccounts.ts @@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { Queries } from "../../queries"; import { GeneralHoldersAccount, PrePaidHoldersCard, fetchAccountsList, fetchAccountsPublic } from "../../api/holders/fetchAccounts"; import { useHoldersAccountStatus } from "./useHoldersAccountStatus"; -import { HoldersAccountState } from "../../api/holders/fetchAccountState"; +import { HoldersUserState } from "../../api/holders/fetchUserState"; import { updateProvisioningCredentials } from "../../holders/updateProvisioningCredentials"; export type HoldersAccounts = { @@ -27,8 +27,8 @@ export function useHoldersAccounts(address: string | Address) { const token = ( !!status && - status.state !== HoldersAccountState.NoRef && - status.state !== HoldersAccountState.NeedEnrollment + status.state !== HoldersUserState.NoRef && + status.state !== HoldersUserState.NeedEnrollment ) ? status.token : null; let query = useQuery({ diff --git a/app/engine/hooks/holders/useHoldersEnroll.ts b/app/engine/hooks/holders/useHoldersEnroll.ts index 955eeec62..59e5db53d 100644 --- a/app/engine/hooks/holders/useHoldersEnroll.ts +++ b/app/engine/hooks/holders/useHoldersEnroll.ts @@ -1,11 +1,11 @@ import { Address, beginCell, storeStateInit } from "@ton/core"; import { AuthParams, AuthWalletKeysType } from "../../../components/secure/AuthWalletKeys"; -import { fetchAccountToken } from "../../api/holders/fetchAccountToken"; +import { fetchUserToken } from "../../api/holders/fetchUserToken"; import { contractFromPublicKey } from "../../contractFromPublicKey"; import { onHoldersEnroll } from "../../effects/onHoldersEnroll"; import { WalletKeys } from "../../../storage/walletKeys"; import { ConnectReplyBuilder } from "../../tonconnect/ConnectReplyBuilder"; -import { holdersUrl } from "../../api/holders/fetchAccountState"; +import { holdersUrl } from "../../api/holders/fetchUserState"; import { getAppManifest } from "../../getters/getAppManifest"; import { AppManifest } from "../../api/fetchManifest"; import { ConnectItemReply, TonProofItemReplySuccess } from "@tonconnect/protocol"; @@ -24,7 +24,8 @@ export type HoldersEnrollParams = { }, domain: string, authContext: AuthWalletKeysType, - authStyle?: AuthParams | undefined + authStyle?: AuthParams | undefined, + inviteId?: string } export enum HoldersEnrollErrorType { @@ -41,7 +42,7 @@ export enum HoldersEnrollErrorType { export type HoldersEnrollResult = { type: 'error', error: HoldersEnrollErrorType } | { type: 'success' }; -export function useHoldersEnroll({ acc, authContext, authStyle }: HoldersEnrollParams) { +export function useHoldersEnroll({ acc, authContext, authStyle, inviteId }: HoldersEnrollParams) { const { isTestnet } = useNetwork(); const saveAppConnection = useSaveAppConnection(); const connectApp = useConnectApp(); @@ -59,6 +60,15 @@ export function useHoldersEnroll({ acc, authContext, authStyle }: HoldersEnrollP const connections = app ? connectAppConnections(extensionKey(app.url)) : []; const isInjected = connections.find((item) => item.type === TonConnectBridgeType.Injected); + if (inviteId) { + + // + // Reset holders token with every invite attempt + // + + deleteHoldersToken(acc.address.toString({ testOnly: isTestnet })) + } + // // Check holders token value // @@ -149,7 +159,7 @@ export function useHoldersEnroll({ acc, authContext, authStyle }: HoldersEnrollP return { type: 'error', error: HoldersEnrollErrorType.NoProof }; } - let token = await fetchAccountToken({ + let token = await fetchUserToken({ kind: 'tonconnect-v2', wallet: 'tonhub', config: { @@ -163,7 +173,7 @@ export function useHoldersEnroll({ acc, authContext, authStyle }: HoldersEnrollP walletStateInit: stateInitStr } } - }, isTestnet); + }, isTestnet, inviteId); setHoldersToken(acc.address.toString({ testOnly: isTestnet }), token); } catch { diff --git a/app/engine/hooks/holders/useIsHoldersWhitelisted.ts b/app/engine/hooks/holders/useIsHoldersInvited.ts similarity index 63% rename from app/engine/hooks/holders/useIsHoldersWhitelisted.ts rename to app/engine/hooks/holders/useIsHoldersInvited.ts index 313c76e86..c4a10805b 100644 --- a/app/engine/hooks/holders/useIsHoldersWhitelisted.ts +++ b/app/engine/hooks/holders/useIsHoldersInvited.ts @@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { Address } from "@ton/core"; import { Queries } from "../../queries"; import { useMemo } from "react"; -import { fetchAddressWhitelistCheck } from "../../api/holders/fetchAddressWhitelistCheck"; +import { fetchAddressInviteCheck } from "../../api/holders/fetchAddressInviteCheck"; -export function useIsHoldersWhitelisted(address: string | Address, isTestnet: boolean) { +export function useIsHoldersInvited(address: string | Address, isTestnet: boolean) { const addressString = useMemo(() => { if (address instanceof Address) { return address.toString({ testOnly: isTestnet }); @@ -13,11 +13,11 @@ export function useIsHoldersWhitelisted(address: string | Address, isTestnet: bo }, [address, isTestnet]); const query = useQuery({ - queryKey: Queries.Holders(addressString).WhiteList(), + queryKey: Queries.Holders(addressString).Invite(), refetchOnMount: true, staleTime: 1000 * 60 * 5, // 5 minutes queryFn: async (key) => { - return await fetchAddressWhitelistCheck(addressString, isTestnet); + return await fetchAddressInviteCheck(addressString, isTestnet); } }); diff --git a/app/engine/queries.ts b/app/engine/queries.ts index 2deeab8b0..72ce084f1 100644 --- a/app/engine/queries.ts +++ b/app/engine/queries.ts @@ -24,7 +24,7 @@ export const Queries = { Status: () => ['holders', address, 'status'], Cards: (mode: 'private' | 'public') => ['holders', address, 'cards', mode], Notifications: (id: string) => ['holders', address, 'events', id], - WhiteList: () => ['holders', address, 'whitelist'], + Invite: () => ['holders', address, 'invite'], }), ContractMetadata: (address: string) => (['contractMetadata', address]), diff --git a/app/fragments/SettingsFragment.tsx b/app/fragments/SettingsFragment.tsx index 00823afd4..08266de57 100644 --- a/app/fragments/SettingsFragment.tsx +++ b/app/fragments/SettingsFragment.tsx @@ -20,7 +20,7 @@ import { useFocusEffect, useRoute } from '@react-navigation/native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useLedgerTransport } from './ledger/components/TransportContext'; import { Typography } from '../components/styles'; -import { HoldersAccountState, holdersUrl as resolveHoldersUrl } from '../engine/api/holders/fetchAccountState'; +import { HoldersUserState, holdersUrl as resolveHoldersUrl } from '../engine/api/holders/fetchUserState'; import IcSecurity from '@assets/settings/ic-security.svg'; import IcSpam from '@assets/settings/ic-spam.svg'; @@ -122,7 +122,7 @@ export const SettingsFragment = fragment(() => { const queryCache = queryClient.getQueryCache(); const address = seleted!.address.toString({ testOnly: network.isTestnet }); const status = getQueryData(queryCache, Queries.Holders(address).Status()); - const token = status?.state === HoldersAccountState.Ok ? status.token : getHoldersToken(address); + const token = status?.state === HoldersUserState.Ok ? status.token : getHoldersToken(address); const accountsStatus = getQueryData(queryCache, Queries.Holders(address).Cards(!!token ? 'private' : 'public')); const initialState = { diff --git a/app/fragments/apps/components/inject/createInjectSource.ts b/app/fragments/apps/components/inject/createInjectSource.ts index 420c0ce49..d62a6314c 100644 --- a/app/fragments/apps/components/inject/createInjectSource.ts +++ b/app/fragments/apps/components/inject/createInjectSource.ts @@ -221,7 +221,7 @@ export const authAPI = (params: { lastAuthTime?: number, isSecured: boolean }) = export const dappWalletAPI = ` window['dapp-wallet'] = (() => { - let __DAPP_WALLET_AVAILIBLE = true; + let __DAPP_WALLET_AVAILABLE = true; let inProgress = false; let currentCallback = null; @@ -290,7 +290,7 @@ window['dapp-wallet'] = (() => { } } - const obj = { __DAPP_WALLET_AVAILIBLE, isEnabled, checkIfCardIsAlreadyAdded, canAddCard, addCardToWallet, __response }; + const obj = { __DAPP_WALLET_AVAILABLE, isEnabled, checkIfCardIsAlreadyAdded, canAddCard, addCardToWallet, __response }; Object.freeze(obj); return obj; })(); diff --git a/app/fragments/holders/HoldersAppFragment.tsx b/app/fragments/holders/HoldersAppFragment.tsx index e94f2849d..f2bfd597e 100644 --- a/app/fragments/holders/HoldersAppFragment.tsx +++ b/app/fragments/holders/HoldersAppFragment.tsx @@ -6,16 +6,27 @@ import { useParams } from '../../utils/useParams'; import { t } from '../../i18n/t'; import { useEffect, useMemo } from 'react'; import { useHoldersAccountStatus, useHoldersAccounts, useNetwork, useSelectedAccount, useTheme } from '../../engine/hooks'; -import { holdersUrl } from '../../engine/api/holders/fetchAccountState'; +import { holdersUrl } from '../../engine/api/holders/fetchUserState'; import { StatusBar, setStatusBarStyle } from 'expo-status-bar'; import { onHoldersInvalidate } from '../../engine/effects/onHoldersInvalidate'; import { useFocusEffect } from '@react-navigation/native'; +export enum HoldersAppParamsType { + Account = 'account', + Prepaid = 'prepaid', + Create = 'create', + Invite = 'invite', + Transactions = 'transactions', + Path = 'path' +} + export type HoldersAppParams = - | { type: 'account', id: string } - | { type: 'prepaid', id: string } - | { type: 'create' } - | { type: 'transactions', query: { [key: string]: string | undefined } }; + | { type: HoldersAppParamsType.Account, id: string } + | { type: HoldersAppParamsType.Prepaid, id: string } + | { type: HoldersAppParamsType.Create } + | { type: HoldersAppParamsType.Invite } + | { type: HoldersAppParamsType.Path, path: string, query: { [key: string]: string | undefined } } + | { type: HoldersAppParamsType.Transactions, query: { [key: string]: string | undefined } }; export const HoldersAppFragment = fragment(() => { const theme = useTheme(); diff --git a/app/fragments/holders/HoldersLandingFragment.tsx b/app/fragments/holders/HoldersLandingFragment.tsx index 9598deb1b..9d65a9024 100644 --- a/app/fragments/holders/HoldersLandingFragment.tsx +++ b/app/fragments/holders/HoldersLandingFragment.tsx @@ -6,11 +6,11 @@ import { useTypedNavigation } from '../../utils/useTypedNavigation'; import { t } from '../../i18n/t'; import { extractDomain } from '../../engine/utils/extractDomain'; import { useParams } from '../../utils/useParams'; -import { HoldersAppParams } from './HoldersAppFragment'; +import { HoldersAppParams, HoldersAppParamsType } from './HoldersAppFragment'; import { getLocales } from 'react-native-localize'; import { fragment } from '../../fragment'; import { useKeysAuth } from '../../components/secure/AuthWalletKeys'; -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useNetwork, usePrimaryCurrency, useSelectedAccount } from '../../engine/hooks'; import { useTheme } from '../../engine/hooks'; import { useHoldersEnroll } from '../../engine/hooks'; @@ -22,6 +22,7 @@ import { HoldersEnrollErrorType } from '../../engine/hooks/holders/useHoldersEnr import { DAppWebView, DAppWebViewProps } from '../../components/webview/DAppWebView'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { getAppManifest } from '../../engine/getters/getAppManifest'; +import { useActionSheet } from '@expo/react-native-action-sheet'; export const HoldersLandingFragment = fragment(() => { const acc = useSelectedAccount()!; @@ -29,14 +30,15 @@ export const HoldersLandingFragment = fragment(() => { const { isTestnet } = useNetwork(); const webRef = useRef(null); const authContext = useKeysAuth(); + const { showActionSheetWithOptions } = useActionSheet(); const navigation = useTypedNavigation(); const safeArea = useSafeAreaInsets(); const [currency,] = usePrimaryCurrency(); - const { endpoint, onEnrollType } = useParams<{ endpoint: string, onEnrollType: HoldersAppParams }>(); + const { endpoint, onEnrollType, inviteId } = useParams<{ endpoint: string, onEnrollType: HoldersAppParams, inviteId?: string }>(); const domain = extractDomain(endpoint); - const enroll = useHoldersEnroll({ acc, domain, authContext, authStyle: { paddingTop: 32 } }); + const enroll = useHoldersEnroll({ acc, domain, authContext, inviteId, authStyle: { paddingTop: 32 } }); const lang = getLocales()[0].languageCode; // Anim @@ -184,10 +186,45 @@ export const HoldersLandingFragment = fragment(() => { } }, [onContentProcessDidTerminate, onEnroll]); + const [renderKey, setRenderKey] = useState(0); + + const onReload = useCallback(() => { + setRenderKey(renderKey + 1); + }, []); + + const onSupport = useCallback(() => { + const tonhubOptions = [t('common.cancel'), t('settings.support.telegram'), t('settings.support.form')]; + const cancelButtonIndex = 0; + + const tonhubSupportSheet = () => { + showActionSheetWithOptions({ + options: tonhubOptions, + title: t('settings.support.title'), + cancelButtonIndex, + }, (selectedIndex?: number) => { + switch (selectedIndex) { + case 1: + openWithInApp('https://t.me/WhalesSupportBot'); + break; + case 2: + openWithInApp('https://airtable.com/appWErwfR8x0o7vmz/shr81d2H644BNUtPN'); + break; + default: + break; + } + }); + } + + tonhubSupportSheet(); + }, []); + return ( - + { lockScroll: true }} webviewDebuggingEnabled={isTestnet} - loader={(p) => } + loader={(p) => } /> void, + onSupport?: () => void, + showClose?: boolean +}) => { + const safeArea = useSafeAreaInsets(); + const animation = useSharedValue(0); + + useEffect(() => { + animation.value = withRepeat( + withTiming(1, { + duration: 500, + easing: Easing.bezier(0.25, 0.1, 0.25, 1) + }), + 10, + true, + ); + }, []); + + const animatedStyles = useAnimatedStyle(() => { + const opacity = interpolate( + animation.value, + [0, 1], + [1, theme.style === 'dark' ? 0.75 : 1], + Extrapolation.CLAMP + ); + const scale = interpolate( + animation.value, + [0, 1], + [1, 1.01], + Extrapolation.CLAMP + ) + return { + opacity: opacity, + transform: [{ scale: scale }], + }; + }, [theme.style]); + + return ( + + + + + + + + + + + + + + + {(onReload || onSupport) && ( + + + + {t('products.holders.loadingLongerTitle')} + + + {t('products.holders.loadingLonger')} + + + {onReload && ( + + + + )} + {onSupport && ( + + + + )} + + + )} + + ); +}); diff --git a/app/fragments/holders/components/HoldersAppComponent.tsx b/app/fragments/holders/components/HoldersAppComponent.tsx index 645033810..7ef061e4c 100644 --- a/app/fragments/holders/components/HoldersAppComponent.tsx +++ b/app/fragments/holders/components/HoldersAppComponent.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Linking, Platform, View } from 'react-native'; +import { Linking, Platform, Pressable, View } from 'react-native'; import { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; import { extractDomain } from '../../../engine/utils/extractDomain'; import { useTypedNavigation } from '../../../utils/useTypedNavigation'; @@ -9,15 +9,15 @@ import { protectNavigation } from '../../apps/components/protect/protectNavigati import { getLocales } from 'react-native-localize'; import { useLinkNavigator } from '../../../useLinkNavigator'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { HoldersAppParams } from '../HoldersAppFragment'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { HoldersAppParams, HoldersAppParamsType } from '../HoldersAppFragment'; import Animated, { Easing, Extrapolation, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'; import { useDAppBridge, usePrimaryCurrency } from '../../../engine/hooks'; import { useTheme } from '../../../engine/hooks'; import { useNetwork } from '../../../engine/hooks'; import { useSelectedAccount } from '../../../engine/hooks'; import { getCurrentAddress } from '../../../storage/appState'; -import { HoldersAccountState, holdersUrl } from '../../../engine/api/holders/fetchAccountState'; +import { HoldersUserState, holdersUrl } from '../../../engine/api/holders/fetchUserState'; import { HoldersAccountStatus, getHoldersToken } from '../../../engine/hooks/holders/useHoldersAccountStatus'; import { ScreenHeader } from '../../../components/ScreenHeader'; import { onHoldersInvalidate } from '../../../engine/effects/onHoldersInvalidate'; @@ -25,6 +25,11 @@ import { DAppWebView, DAppWebViewProps } from '../../../components/webview/DAppW import { ThemeType } from '../../../engine/state/theme'; import { useDimensions } from '@react-native-community/hooks'; import { HoldersAccounts } from '../../../engine/hooks/holders/useHoldersAccounts'; +import { openWithInApp } from '../../../utils/openWithInApp'; +import { t } from '../../../i18n/t'; +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { AccountPlaceholder } from './AccountPlaceholder'; +import { Image } from "expo-image"; export function normalizePath(path: string) { return path.replaceAll('.', '_'); @@ -32,87 +37,6 @@ export function normalizePath(path: string) { import IcHolders from '@assets/ic_holders.svg'; -const AccountPlaceholder = memo(({ theme }: { theme: ThemeType }) => { - const safeArea = useSafeAreaInsets(); - - return ( - - - - - - - - - - - - - - - ); -}); - const CardPlaceholder = memo(({ theme }: { theme: ThemeType }) => { const dimensions = useDimensions(); const safeArea = useSafeAreaInsets(); @@ -226,7 +150,17 @@ export const HoldersPlaceholder = memo(() => { ); }); -export const HoldersLoader = memo(({ loaded, type }: { loaded: boolean, type: 'account' | 'create' | 'prepaid' }) => { +export const HoldersLoader = memo(({ + loaded, + type, + onReload, + onSupport +}: { + loaded: boolean, + type: HoldersAppParamsType, + onReload?: () => void, + onSupport?: () => void +}) => { const theme = useTheme(); const navigation = useTypedNavigation(); const safeArea = useSafeAreaInsets(); @@ -237,9 +171,17 @@ export const HoldersLoader = memo(({ loaded, type }: { loaded: boolean, type: 'a return { opacity: opacity.value }; }); + const longLoadingTimerRef = useRef(null); + const start = useMemo(() => Date.now(), []); + const trackLoadingTime = useCallback(() => { + trackEvent(MixpanelEvent.HoldersLoadingTime, { type, duration: Date.now() - start }); + }, []); + useEffect(() => { if (loaded) { + longLoadingTimerRef.current && clearTimeout(longLoadingTimerRef.current); opacity.value = withTiming(0, { duration: 350, easing: Easing.inOut(Easing.ease) }); + trackLoadingTime(); } else { setShowClose(false); opacity.value = 1; @@ -247,22 +189,42 @@ export const HoldersLoader = memo(({ loaded, type }: { loaded: boolean, type: 'a }, [loaded]); useEffect(() => { - setTimeout(() => { + const showCloseTimer = setTimeout(() => { setShowClose(true); - }, 3000); + }, 7000); + + if (longLoadingTimerRef.current) { + clearTimeout(longLoadingTimerRef.current); + } + + longLoadingTimerRef.current = setTimeout(() => { + trackEvent(MixpanelEvent.holdersLongLoadingTime, { type, duration: 12000 }); + }, 10000); + + return () => { + longLoadingTimerRef.current && clearTimeout(longLoadingTimerRef.current); + clearTimeout(showCloseTimer); + } }, []); const placeholder = useMemo(() => { - if (type === 'account') { - return ; + if (type === HoldersAppParamsType.Account) { + return ( + + ); } - if (type === 'prepaid') { + if (type === HoldersAppParamsType.Prepaid) { return ; } return ; - }, [type, theme]); + }, [type, theme, showClose]); return ( [ + { + opacity: pressed ? 0.5 : 1, + backgroundColor: type === HoldersAppParamsType.Account ? '#1c1c1e' : theme.surfaceOnBg, + borderRadius: 32, + height: 32, width: 32, + justifyContent: 'center', alignItems: 'center', + }, + ]} + onPress={navigation.goBack} + > + + : undefined} style={[ { position: 'absolute', top: 32, left: 16, right: 0 }, Platform.select({ @@ -317,6 +301,7 @@ export const HoldersAppComponent = memo(( const [currency,] = usePrimaryCurrency(); const selectedAccount = useSelectedAccount(); const url = holdersUrl(isTestnet); + const { showActionSheetWithOptions } = useActionSheet(); const source = useMemo(() => { const queryParams = new URLSearchParams({ @@ -328,16 +313,19 @@ export const HoldersAppComponent = memo(( let route = ''; switch (props.variant.type) { - case 'create': + case HoldersAppParamsType.Invite: + route = '/'; + break; + case HoldersAppParamsType.Create: route = '/create'; break; - case 'account': + case HoldersAppParamsType.Account: route = `/account/${props.variant.id}`; break; - case 'prepaid': + case HoldersAppParamsType.Prepaid: route = `/card-prepaid/${props.variant.id}`; break; - case 'transactions': + case HoldersAppParamsType.Transactions: route = `/transactions`; for (const [key, value] of Object.entries(props.variant.query)) { if (!!value) { @@ -345,6 +333,14 @@ export const HoldersAppComponent = memo(( } } break; + case HoldersAppParamsType.Path: + route = `/${props.variant.path ?? ''}`; + for (const [key, value] of Object.entries(props.variant.query)) { + if (!!value) { + queryParams.append(key, value); + } + } + break; } const url = `${props.endpoint}${route}?${queryParams.toString()}`; @@ -414,7 +410,7 @@ export const HoldersAppComponent = memo(( kycStatus: status.state === 'need-kyc' ? status.kycStatus : null, suspended: (status as { suspended: boolean | undefined }).suspended === true, }, - token: status.state === HoldersAccountState.Ok ? status.token : getHoldersToken(acc.address.toString({ testOnly: isTestnet })), + token: status.state === HoldersUserState.Ok ? status.token : getHoldersToken(acc.address.toString({ testOnly: isTestnet })), } } : {}, @@ -477,8 +473,51 @@ export const HoldersAppComponent = memo(( injectSource ]); + const [renderKey, setRenderKey] = useState(0); + + const onReaload = useCallback(() => { + setRenderKey(renderKey + 1); + }, []); + + const onSupport = useCallback(() => { + const tonhubOptions = [ + t('common.cancel'), + t('settings.support.telegram'), + t('settings.support.form'), + t('settings.support.holders') + ]; + const cancelButtonIndex = 0; + + const tonhubSupportSheet = () => { + showActionSheetWithOptions({ + options: tonhubOptions, + title: t('settings.support.title'), + cancelButtonIndex, + }, (selectedIndex?: number) => { + switch (selectedIndex) { + case 1: + openWithInApp('https://t.me/WhalesSupportBot'); + break; + case 2: + openWithInApp('https://airtable.com/appWErwfR8x0o7vmz/shr81d2H644BNUtPN'); + break; + case 3: + openWithInApp('https://help.holders.io/en'); + break; + default: + break; + } + }); + } + + tonhubSupportSheet(); + }, []); + return ( - + ( )} /> diff --git a/app/fragments/secure/components/TransferSingleView.tsx b/app/fragments/secure/components/TransferSingleView.tsx index b72dc4cc0..e52bbc94a 100644 --- a/app/fragments/secure/components/TransferSingleView.tsx +++ b/app/fragments/secure/components/TransferSingleView.tsx @@ -14,7 +14,7 @@ import { Address, fromNano, toNano } from "@ton/core"; import { WalletSettings } from "../../../engine/state/walletSettings"; import { useAppState, useNetwork, useBounceableWalletFormat, usePrice, useSelectedAccount, useTheme, useWalletsSettings, useVerifyJetton } from "../../../engine/hooks"; import { AddressComponent } from "../../../components/address/AddressComponent"; -import { holdersUrl as resolveHoldersUrl } from "../../../engine/api/holders/fetchAccountState"; +import { holdersUrl as resolveHoldersUrl } from "../../../engine/api/holders/fetchUserState"; import { useLedgerTransport } from "../../ledger/components/TransportContext"; import { Jetton, StoredOperation } from "../../../engine/types"; import { AboutIconButton } from "../../../components/AboutIconButton"; diff --git a/app/fragments/staking/LiquidStakingTransferFragment.tsx b/app/fragments/staking/LiquidStakingTransferFragment.tsx index 3f099ba7a..41c6b4e13 100644 --- a/app/fragments/staking/LiquidStakingTransferFragment.tsx +++ b/app/fragments/staking/LiquidStakingTransferFragment.tsx @@ -31,43 +31,10 @@ import { Typography } from '../../components/styles'; import { useValidAmount } from '../../utils/useValidAmount'; import IcTonIcon from '@assets/ic-ton-acc.svg'; +import { LiquidStakingAmountAction, liquidStakingAmountReducer } from '../../utils/staking/liquidStakingAmountReducer'; export type LiquidStakingTransferParams = Omit; -type AmountAction = { type: 'ton', amount: string } | { type: 'wsTon', amount: string }; -type AmountState = { ton: string, wsTon: string }; - -function reduceAmountState(withdrawRate: bigint, depositRate: bigint, type: 'withdraw' | 'top_up') { - return (state: AmountState, action: AmountAction): AmountState => { - try { - const amount = action.amount.replace(',', '.').replaceAll(' ', ''); - if (action.type === 'ton') { - const ton = formatInputAmount(action.amount, 9, { skipFormattingDecimals: true }, state.ton); - const computed = parseFloat(amount) * parseFloat(fromNano(type === 'withdraw' ? withdrawRate : depositRate)) || 0 - const wsTon = fromNano(toNano(computed.toFixed(9))); - - if (ton === state.ton) { - return state; - } - - return { ton, wsTon }; - } - - const wsTon = formatInputAmount(action.amount, 9, { skipFormattingDecimals: true }, state.wsTon); - const computed = parseFloat(amount) * parseFloat(fromNano(type === 'withdraw' ? withdrawRate : depositRate)) || 0; - const ton = fromNano(toNano(computed.toFixed(9))); - - if (wsTon === state.wsTon) { - return state; - } - - return { ton, wsTon }; - } catch { - return state; - } - } -} - export const LiquidStakingTransferFragment = fragment(() => { const theme = useTheme(); const network = useNetwork(); @@ -101,7 +68,7 @@ export const LiquidStakingTransferFragment = fragment(() => { } if (params?.action === 'top_up' && params.amount) { - initAmount = reduceAmountState( + initAmount = liquidStakingAmountReducer( liquidStaking?.rateWithdraw ?? 0n, liquidStaking?.rateDeposit ?? 0n, 'top_up' @@ -109,7 +76,7 @@ export const LiquidStakingTransferFragment = fragment(() => { } const [amount, dispatchAmount] = useReducer( - reduceAmountState( + liquidStakingAmountReducer( liquidStaking?.rateWithdraw ?? 0n, liquidStaking?.rateDeposit ?? 0n, params?.action === 'withdraw' ? 'withdraw' : 'top_up' @@ -131,7 +98,7 @@ export const LiquidStakingTransferFragment = fragment(() => { return 0n; }, [params.action, member, account]); - const onSetAmount = useCallback((action: AmountAction) => { + const onSetAmount = useCallback((action: LiquidStakingAmountAction) => { setMinAmountWarn(undefined); dispatchAmount(action); }, []); diff --git a/app/fragments/wallet/ProductsFragment.tsx b/app/fragments/wallet/ProductsFragment.tsx index 63728e1be..1a484c809 100644 --- a/app/fragments/wallet/ProductsFragment.tsx +++ b/app/fragments/wallet/ProductsFragment.tsx @@ -3,14 +3,15 @@ import { useCallback, useMemo } from "react"; import { fragment } from "../../fragment"; import { useTypedNavigation } from "../../utils/useTypedNavigation"; import { useHoldersAccountStatus, useIsConnectAppReady, useNetwork, useSelectedAccount, useStakingApy, useTheme } from "../../engine/hooks"; -import { HoldersAccountState, holdersUrl as resolveHoldersUrl } from "../../engine/api/holders/fetchAccountState"; +import { HoldersUserState, holdersUrl as resolveHoldersUrl } from "../../engine/api/holders/fetchUserState"; import { ScreenHeader } from "../../components/ScreenHeader"; import { t } from "../../i18n/t"; import { ProductBanner } from "../../components/products/ProductBanner"; import { StatusBar } from "expo-status-bar"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useIsHoldersWhitelisted } from "../../engine/hooks/holders/useIsHoldersWhitelisted"; +import { useIsHoldersInvited } from "../../engine/hooks/holders/useIsHoldersInvited"; import { Typography } from "../../components/styles"; +import { HoldersAppParamsType } from "../holders/HoldersAppFragment"; export const ProductsFragment = fragment(() => { const navigation = useTypedNavigation(); @@ -22,7 +23,7 @@ export const ProductsFragment = fragment(() => { const status = useHoldersAccountStatus(selected!.address).data; const holdersUrl = resolveHoldersUrl(network.isTestnet); const isHoldersReady = useIsConnectAppReady(holdersUrl); - const isHoldersWhitelisted = useIsHoldersWhitelisted(selected!.address, network.isTestnet); + const isHoldersInvited = useIsHoldersInvited(selected!.address, network.isTestnet); const apyWithFee = useMemo(() => { if (!!apy) { @@ -31,7 +32,7 @@ export const ProductsFragment = fragment(() => { }, [apy]); const needsEnrolment = useMemo(() => { - if (status?.state === HoldersAccountState.NeedEnrollment) { + if (status?.state === HoldersUserState.NeedEnrollment) { return true; } return false; @@ -42,11 +43,11 @@ export const ProductsFragment = fragment(() => { navigation.goBack(); if (needsEnrolment || !isHoldersReady) { - navigation.navigateHoldersLanding({ endpoint: holdersUrl, onEnrollType: { type: 'create' } }, network.isTestnet); + navigation.navigateHoldersLanding({ endpoint: holdersUrl, onEnrollType: { type: HoldersAppParamsType.Create } }, network.isTestnet); return; } - navigation.navigateHolders({ type: 'create' }, network.isTestnet); + navigation.navigateHolders({ type: HoldersAppParamsType.Create }, network.isTestnet); }, [needsEnrolment, isHoldersReady, network.isTestnet]); return ( @@ -81,7 +82,7 @@ export const ProductsFragment = fragment(() => { {t('products.addNew')} - {isHoldersWhitelisted && ( + {isHoldersInvited && ( { illustrationStyle={{ backgroundColor: theme.elevation }} /> )} - + { navigation.goBack(); diff --git a/app/i18n/i18n_en.ts b/app/i18n/i18n_en.ts index 81e7ddab5..95a24446b 100644 --- a/app/i18n/i18n_en.ts +++ b/app/i18n/i18n_en.ts @@ -160,7 +160,8 @@ const schema: PrepareSchema = { title: 'Receive', subtitle: 'Only send TON Blockchain assets to this address. Other assets will be lost forever', share: { - title: 'My Tonhub Address' + title: 'My Tonhub Address', + error: 'Failed to share address, please try again or contact support' } }, transfer: { @@ -501,6 +502,8 @@ const schema: PrepareSchema = { }, holders: { title: 'Bank account', + loadingLongerTitle: 'Connection problems', + loadingLonger: 'Check your internet connection and reload page. If the issue persists please contact support', accounts: { title: 'Payment accounts', prepaidTitle: 'Prepaid cards', diff --git a/app/i18n/i18n_ru.ts b/app/i18n/i18n_ru.ts index c499f974d..4d271d3ce 100644 --- a/app/i18n/i18n_ru.ts +++ b/app/i18n/i18n_ru.ts @@ -144,7 +144,8 @@ const schema: PrepareSchema = { "title": "Получить", "subtitle": "Отправляйте на этот адрес только токены блокчейна TON. Другие активы будут потеряны навсегда", "share": { - "title": "My Tonhub Address" + "title": "Мой Tonhub адрес", + "error": "Не удалось поделиться адресом, попробуйте еще раз или обратитесь в службу поддержки" } }, "transfer": { @@ -501,6 +502,8 @@ const schema: PrepareSchema = { }, "holders": { "title": "Банковский счет", + "loadingLongerTitle": "Проблемы c подключением", + "loadingLonger": "Проверьте подключение к интернету и перезагрузите страницу. Если проблема сохраняется, обратитесь в службу поддержки", "accounts": { "title": "Счета", "prepaidTitle": 'Prepaid карты', @@ -883,7 +886,7 @@ const schema: PrepareSchema = { }, "delete": "Удалить контакт", "empty": "У вас ещё нет контактов", - "description": "Нажмите и подержите адрес, чтобы добавить его в контакты, или выберите его из списка недавних контактов ниже", + "description": "Нажмите и удерживайте адрес или выберите его из списка ниже, чтобы добавить в контакты", "contactAddress": "Адрес контакта", "search": "Имя или адрес кошелька", "new": "Новый контакт" diff --git a/app/i18n/schema.ts b/app/i18n/schema.ts index 55b0d8986..5b6910b48 100644 --- a/app/i18n/schema.ts +++ b/app/i18n/schema.ts @@ -162,7 +162,8 @@ export type LocalizationSchema = { title: string, subtitle: string, share: { - title: string + title: string, + error: string } }, transfer: { @@ -503,6 +504,8 @@ export type LocalizationSchema = { }, holders: { title: string, + loadingLongerTitle: string, + loadingLonger: string, accounts: { title: string, prepaidTitle: string, diff --git a/app/secure/KnownWallets.ts b/app/secure/KnownWallets.ts index 0f58019e6..24f0eb9f7 100644 --- a/app/secure/KnownWallets.ts +++ b/app/secure/KnownWallets.ts @@ -32,6 +32,8 @@ const Img_venera = require('@assets/known/Img_venera.jpeg'); const Img_Team_1 = require('@assets/known/ic_team_1.png'); const Img_Team_2 = require('@assets/known/ic_team_2.png'); +const Img_Club_1 = require('@assets/ic_club_cosmos.png'); +const Img_Club_2 = require('@assets/ic_club_robot.png'); const Img_ePN_1 = require('@assets/known/ic_epn_1.png'); const Img_ePN_2 = require('@assets/known/ic_epn_2.png'); const Img_Lockups_1 = require('@assets/known/ic_lockups_1.png'); @@ -171,6 +173,24 @@ const knownWalletsMainnet = { ic: Img_Team_2, requireMemo: true }, + [Address.parse('EQDFvnxuyA2ogNPOoEj1lu968U4PP8_FzJfrOWUsi_o1CLUB').toString()]: { + name: 'Club 1', + colors: { + primary: '#65C6FF', + secondary: '#DEEFFC' + }, + ic: Img_Club_1, + requireMemo: true + }, + [Address.parse('EQA_cc5tIQ4haNbMVFUD1d0bNRt17S7wgWEqfP_xEaTACLUB').toString()]: { + name: 'Club 2', + colors: { + primary: '#65C6FF', + secondary: '#DEEFFC' + }, + ic: Img_Club_2, + requireMemo: true + }, [Address.parse('EQBeNwQShukLyOWjKWZ0Oxoe5U3ET-ApQIWYeC4VLZ4tmeTm').toString()]: { name: 'Whales Pool Withdraw 1', colors: { @@ -489,11 +509,52 @@ const knownWalletsMainnet = { ic: Img_OKX, requireMemo: true }, + [Address.parse('EQCbm3Od4lJ65y0hCD9RmNcQxyiEU7RzPlfsrbLhiayCnNuU').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, [Address.parse('EQCFTsRSHv1SrUO88ZiOTETr35omrRj6Uav9toX8OzSKXGkS').toString()]: { name: 'OKX', ic: Img_OKX, requireMemo: true }, + [Address.parse('EQDxXbLGLNhq_whg05xJH8c6MTlfr-tZReZESYViJgx4Lg4_').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('UQDn6G2gh0LtkzQ0_-uPCKY8fhAO6ELiX1manL8IkVdKbDEu').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('UQADON7zS4TG7pE0oEqxYBRQvkRjQKN64lneV8s3vbWQzTjL').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('UQCjCknscl6fVyRLZq9MLaerdgBLT86A06NLHNDDd2Krztab').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('UQAk2h57jakB5bPdD5jDl_625aXqSDGVqLa9BoLPLFMnrnbQ').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('EQB_LoTHI9i2trGqz4EMjCarI8IgIrRkuHEEstMGWw6nC3Nw').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('EQD5vcDeRhwaLgAvralVC7sJXI-fc2aNcMUXqcx-BQ-OWnOZ').toString()]: { + name: 'OKX', + ic: Img_OKX, + requireMemo: true + }, + [Address.parse('EQABMMdzRuntgt9nfRB61qd1wR-cGPagXA3ReQazVYUNrT7p').toString()]: { name: 'EXMO Deposit', ic: Img_EXMO_Deposit, diff --git a/app/useLinkNavigator.ts b/app/useLinkNavigator.ts index 5935c3849..11b13d25e 100644 --- a/app/useLinkNavigator.ts +++ b/app/useLinkNavigator.ts @@ -32,9 +32,9 @@ import { extractDomain } from './engine/utils/extractDomain'; import { Linking } from 'react-native'; import { openWithInApp } from './utils/openWithInApp'; import { getHoldersToken, HoldersAccountStatus } from './engine/hooks/holders/useHoldersAccountStatus'; -import { HoldersAccountState, holdersUrl } from './engine/api/holders/fetchAccountState'; +import { HoldersUserState, holdersUrl } from './engine/api/holders/fetchUserState'; import { getIsConnectAppReady } from './engine/hooks/dapps/useIsConnectAppReady'; -import { HoldersAppParams } from './fragments/holders/HoldersAppFragment'; +import { HoldersAppParams, HoldersAppParamsType } from './fragments/holders/HoldersAppFragment'; const infoBackoff = createBackoff({ maxFailureCount: 10 }); @@ -473,22 +473,27 @@ function getNeedsEnrollment(url: string, address: string, isTestnet: boolean, qu return true; } - if (status.state === HoldersAccountState.NeedEnrollment) { + if (status.state === HoldersUserState.NeedEnrollment) { return true; } return false; } -function resolveAndNavigateToHolders(params: { +type HolderResloveParams = { query: { [key: string]: string | undefined }, navigation: TypedNavigation, selected: SelectedAccount, updateAppState: (value: AppState, isTestnet: boolean) => void, isTestnet: boolean, queryClient: QueryClient -}) { - const { query, navigation, selected, updateAppState, queryClient, isTestnet } = params +} + +type HoldersTransactionResolveParams = HolderResloveParams & { type: 'holders-transactions' } +type HoldersPathResolveParams = HolderResloveParams & { type: 'holders-path', path: string } + +function resolveAndNavigateToHolders(params: HoldersTransactionResolveParams | HoldersPathResolveParams) { + const { type, query, navigation, selected, updateAppState, queryClient, isTestnet } = params const addresses = query['addresses']?.split(','); if (!addresses || addresses.length === 0) { @@ -498,10 +503,15 @@ function resolveAndNavigateToHolders(params: { const isSelectedAddress = addresses.find((a) => Address.parse(a).equals(selected.address)); const transactionId = query['transactionId']; - const holdersNavParams: HoldersAppParams = { - type: 'transactions', - query: { transactionId } - } + const holdersNavParams: HoldersAppParams = type === 'holders-transactions' + ? { + type: HoldersAppParamsType.Transactions, + query: { transactionId } + } : { + type: HoldersAppParamsType.Path, + path: params.path, + query + } const url = holdersUrl(isTestnet); @@ -552,6 +562,18 @@ function resolveAndNavigateToHolders(params: { } } +function resolveHoldersInviteLink(params: { + navigation: TypedNavigation, + isTestnet: boolean, + inviteId: string +}) { + const { navigation, isTestnet, inviteId } = params + + const endpoint = holdersUrl(isTestnet); + + navigation.navigateHoldersLanding({ endpoint, onEnrollType: { type: HoldersAppParamsType.Invite }, inviteId }, isTestnet); +} + export function useLinkNavigator( isTestnet: boolean, toastProps?: { duration?: ToastDuration, marginBottom?: number }, @@ -686,6 +708,24 @@ export function useLinkNavigator( } resolveAndNavigateToHolders({ + type: 'holders-transactions', + navigation, + query: resolved.query, + selected, + updateAppState, + isTestnet, + queryClient + }); + break; + } + case 'holders-path': { + if (!selected) { + return; + } + + resolveAndNavigateToHolders({ + type: 'holders-path', + path: resolved.path, navigation, query: resolved.query, selected, @@ -693,6 +733,19 @@ export function useLinkNavigator( isTestnet, queryClient }); + break; + } + case 'holders-invite': { + if (!selected) { + return; + } + + resolveHoldersInviteLink({ + navigation, + isTestnet, + inviteId: resolved.inviteId + }) + break; } } diff --git a/app/utils/resolveUrl.ts b/app/utils/resolveUrl.ts index 141634735..a60fa2fb1 100644 --- a/app/utils/resolveUrl.ts +++ b/app/utils/resolveUrl.ts @@ -68,6 +68,13 @@ export type ResolvedUrl = { } | { type: 'holders-transactions', query: { [key: string]: string | undefined } +} | { + type: 'holders-path', + path: string, + query: { [key: string]: string | undefined } +} | { + type: 'holders-invite', + inviteId: string } export function isUrl(str: string): boolean { @@ -87,6 +94,25 @@ function resolveHoldersUrl(url: Url>): Resolv type: 'holders-transactions', query: url.query } + } else if (!!url.query && url.query.path) { + const path = decodeURIComponent(url.query.path); + delete url.query.path; + + return { + type: 'holders-path', + path, + query: url.query + } + } + + const isInvite = url.pathname.startsWith('/holders/invite'); + const inviteId = url.pathname.split('holders/invite/')[1] + + if (isInvite && inviteId) { + return { + type: 'holders-invite', + inviteId: inviteId + } } return { type: 'error', error: ResolveUrlError.InvalidHoldersPath }; diff --git a/app/utils/staking/liquidStakingAmountReducer.ts b/app/utils/staking/liquidStakingAmountReducer.ts new file mode 100644 index 000000000..36d09430c --- /dev/null +++ b/app/utils/staking/liquidStakingAmountReducer.ts @@ -0,0 +1,41 @@ +import { fromNano, toNano } from "@ton/core"; +import { formatInputAmount } from "../formatCurrency"; + +export type LiquidStakingAmountAction = { type: 'ton', amount: string } | { type: 'wsTon', amount: string }; +type AmountState = { ton: string, wsTon: string }; + +export function liquidStakingAmountReducer(withdrawRate: bigint, depositRate: bigint, type: 'withdraw' | 'top_up') { + return (state: AmountState, action: LiquidStakingAmountAction): AmountState => { + if (action.amount === '' || action.amount === '') { + return { ton: '', wsTon: '' }; + } + try { + const rate = fromNano(type === 'withdraw' ? withdrawRate : depositRate); + + const amount = action.amount.replace(',', '.').replaceAll(' ', ''); + if (action.type === 'ton') { + const ton = formatInputAmount(action.amount, 9, { skipFormattingDecimals: true }, state.ton); + const computed = parseFloat(amount) * (1 / parseFloat(rate)); + const wsTon = fromNano(toNano(computed.toFixed(9))); + + if (ton === state.ton) { + return state; + } + + return { ton, wsTon }; + } + + const wsTon = formatInputAmount(action.amount, 9, { skipFormattingDecimals: true }, state.wsTon); + const computed = parseFloat(amount) * parseFloat(rate); + const ton = fromNano(toNano(computed.toFixed(9))); + + if (wsTon === state.wsTon) { + return state; + } + + return { ton, wsTon }; + } catch { + return state; + } + } +} \ No newline at end of file diff --git a/app/utils/useTypedNavigation.ts b/app/utils/useTypedNavigation.ts index 7d8dc612e..9d69728d9 100644 --- a/app/utils/useTypedNavigation.ts +++ b/app/utils/useTypedNavigation.ts @@ -167,16 +167,16 @@ export class TypedNavigation { this.navigateAndReplaceAll('LedgerApp'); } - navigateHoldersLanding({ endpoint, onEnrollType }: { endpoint: string, onEnrollType: HoldersAppParams }, isTestnet: boolean) { + navigateHoldersLanding({ endpoint, onEnrollType, inviteId }: { endpoint: string, onEnrollType: HoldersAppParams, inviteId?: string }, isTestnet: boolean) { if (shouldTurnAuthOn(isTestnet)) { const callback = (success: boolean) => { if (success) { // navigate only if auth is set up - this.navigate('HoldersLanding', { endpoint, onEnrollType }) + this.navigate('HoldersLanding', { endpoint, onEnrollType, inviteId }) } } this.navigateMandatoryAuthSetup({ callback }); } else { - this.navigate('HoldersLanding', { endpoint, onEnrollType }); + this.navigate('HoldersLanding', { endpoint, onEnrollType, inviteId }); } } diff --git a/assets/ic-bad-connection.png b/assets/ic-bad-connection.png new file mode 100644 index 000000000..f8fb93747 Binary files /dev/null and b/assets/ic-bad-connection.png differ diff --git a/assets/ic-bad-connection@2x.png b/assets/ic-bad-connection@2x.png new file mode 100644 index 000000000..3d3cdca1a Binary files /dev/null and b/assets/ic-bad-connection@2x.png differ diff --git a/assets/ic-bad-connection@3x.png b/assets/ic-bad-connection@3x.png new file mode 100644 index 000000000..9ed9f9a6a Binary files /dev/null and b/assets/ic-bad-connection@3x.png differ diff --git a/assets/ic-comment.png b/assets/ic-comment.png new file mode 100644 index 000000000..29c475f06 Binary files /dev/null and b/assets/ic-comment.png differ diff --git a/assets/ic-comment@2x.png b/assets/ic-comment@2x.png new file mode 100644 index 000000000..16843a5b6 Binary files /dev/null and b/assets/ic-comment@2x.png differ diff --git a/assets/ic-comment@3x.png b/assets/ic-comment@3x.png new file mode 100644 index 000000000..979f52e87 Binary files /dev/null and b/assets/ic-comment@3x.png differ diff --git a/assets/ic-reload.png b/assets/ic-reload.png new file mode 100644 index 000000000..a7118a594 Binary files /dev/null and b/assets/ic-reload.png differ diff --git a/assets/ic-reload@2x.png b/assets/ic-reload@2x.png new file mode 100644 index 000000000..d11603415 Binary files /dev/null and b/assets/ic-reload@2x.png differ diff --git a/assets/ic-reload@3x.png b/assets/ic-reload@3x.png new file mode 100644 index 000000000..9475da8e1 Binary files /dev/null and b/assets/ic-reload@3x.png differ diff --git a/ios/ci_scripts/ci_post_clone.sh b/ios/ci_scripts/ci_post_clone.sh index 6e738cca6..bfee41934 100644 --- a/ios/ci_scripts/ci_post_clone.sh +++ b/ios/ci_scripts/ci_post_clone.sh @@ -9,10 +9,12 @@ export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=TRUE # brew doesn't have version pinning for cocoapods, so we have to install it manually from the commit echo "===== Uninstalling prev cocoapods =====" brew uninstall --ignore-dependencies cocoapods || true -echo "===== Downloading formula =====" -curl https://raw.githubusercontent.com/Homebrew/homebrew-core/1364b74ebeedb2eab300d62c99e12f2a6f344277/Formula/c/cocoapods.rb > cocoapods.rb -echo "===== Installing formula =====" -brew install cocoapods.rb +# echo "===== Downloading formula =====" +# curl https://raw.githubusercontent.com/Homebrew/homebrew-core/1364b74ebeedb2eab300d62c99e12f2a6f344277/Formula/c/cocoapods.rb > cocoapods.rb +# echo "===== Installing formula =====" +# brew install cocoapods.rb +echo "===== Installing cocoapods=====" +brew install cocoapods echo "===== Installing node =====" # have to add node yourself diff --git a/ios/wallet/Info.plist b/ios/wallet/Info.plist index 9f1def4a8..565dbc4f1 100644 --- a/ios/wallet/Info.plist +++ b/ios/wallet/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.3.12 + 2.3.13 CFBundleSignature ???? CFBundleURLTypes @@ -41,7 +41,7 @@ CFBundleVersion - 206 + 207 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/package.json b/package.json index efda4eca8..35e822038 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wallet", - "version": "2.3.12", + "version": "2.3.13", "scripts": { "start": "expo start --dev-client", "android": "expo run:android", @@ -124,7 +124,7 @@ "react-native-reanimated": "^3.6.1", "react-native-safe-area-context": "4.6.3", "react-native-screens": "~3.22.0", - "react-native-share": "^9.4.1", + "react-native-share": "^10.2.1", "react-native-sse": "^1.1.0", "react-native-svg": "14.0.0", "react-native-tab-view": "^3.5.2", diff --git a/yarn.lock b/yarn.lock index 9640e378e..237721844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9195,10 +9195,10 @@ react-native-screens@~3.22.0: react-freeze "^1.0.0" warn-once "^0.1.0" -react-native-share@^9.4.1: - version "9.4.1" - resolved "https://registry.yarnpkg.com/react-native-share/-/react-native-share-9.4.1.tgz#1b6d96015009e3878bfc4346940602c1cffff525" - integrity sha512-jm4qA5J5+ytWA8UFg6s8iEfdZYGPW+t5oreSuzrPt0assjvBUlFaoqYGGwGR5RJ8BIpjzOJYvx/c9MjXB4ApUg== +react-native-share@^10.2.1: + version "10.2.1" + resolved "https://registry.yarnpkg.com/react-native-share/-/react-native-share-10.2.1.tgz#baf94848c2acee6e52f6b28e05c47fa5fa9402be" + integrity sha512-Z2LWGYWH7raM4H6Oauttv1tEhaB43XSWJAN8iS6oaSG9CnyrUBeYFF4QpU1AH5RgNeylXQdN8CtbizCHHt6coQ== react-native-sse@^1.1.0: version "1.2.0"