diff --git a/android/app/build.gradle b/android/app/build.gradle index 548312c8d..ba7fd3443 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -6,8 +6,8 @@ apply plugin: "com.google.firebase.crashlytics" import com.android.build.OutputFile -def canonicalVersionName = "3.1.0" -def canonicalVersionCode = 29 +def canonicalVersionName = "3.2.0" +def canonicalVersionCode = 34 // NOTE: DO NOT change postFixSize value, this is for handling legacy method for handling the versioning in android def postFixSize = 30_000 diff --git a/ios/Xaman.xcodeproj/project.pbxproj b/ios/Xaman.xcodeproj/project.pbxproj index 9d8459c90..66764b455 100644 --- a/ios/Xaman.xcodeproj/project.pbxproj +++ b/ios/Xaman.xcodeproj/project.pbxproj @@ -1182,7 +1182,7 @@ CODE_SIGN_ENTITLEMENTS = Xaman/Xaman.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = LK5BBJNJZ6; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; @@ -1193,7 +1193,7 @@ INFOPLIST_FILE = Xaman/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.2.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1218,13 +1218,13 @@ CODE_SIGN_ENTITLEMENTS = Xaman/Xaman.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = LK5BBJNJZ6; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; INFOPLIST_FILE = Xaman/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 3.1.0; + MARKETING_VERSION = 3.2.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/Xaman/Info.plist b/ios/Xaman/Info.plist index 4b30a4d32..7999ad71b 100644 --- a/ios/Xaman/Info.plist +++ b/ios/Xaman/Info.plist @@ -54,7 +54,7 @@ CFBundleVersion - 9 + 5 LSApplicationQueriesSchemes https diff --git a/ios/XamanTests/Info.plist b/ios/XamanTests/Info.plist index 0c590ff2f..11a3c5782 100644 --- a/ios/XamanTests/Info.plist +++ b/ios/XamanTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9 + 5 diff --git a/package-lock.json b/package-lock.json index 539421ba9..12654d1e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xaman", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xaman", - "version": "3.1.0", + "version": "3.2.0", "hasInstallScript": true, "license": "SEE LICENSE IN ", "dependencies": { diff --git a/package.json b/package.json index 110c7c374..07b3f63a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xaman", - "version": "3.1.0", + "version": "3.2.0", "license": "SEE LICENSE IN ", "scripts": { "start": "node node_modules/react-native/cli.js start", diff --git a/src/common/constants/screens.ts b/src/common/constants/screens.ts index f75f4ea23..27cc4a535 100644 --- a/src/common/constants/screens.ts +++ b/src/common/constants/screens.ts @@ -92,6 +92,7 @@ const screens = { Edit: 'app.Settings.ThirdPartyApps.Edit', }, SessionLog: 'app.Settings.SessionLog', + DeveloperSettings: 'app.Settings.DeveloperSettings', General: 'app.Settings.General', Advanced: 'app.Settings.Advanced', Security: 'app.Settings.Security', diff --git a/src/common/helpers/advisory.ts b/src/common/helpers/advisory.ts new file mode 100644 index 000000000..9bf94d3da --- /dev/null +++ b/src/common/helpers/advisory.ts @@ -0,0 +1,109 @@ +/** +Advisory helper + */ + +import BigNumber from 'bignumber.js'; + +import LedgerService from '@services/LedgerService'; + +import { AccountRoot } from '@common/libs/ledger/types/ledger'; +import { AccountInfoAccountFlags } from '@common/libs/ledger/types/methods/accountInfo'; + +/* Constants ==================================================================== */ +const BLACK_HOLE_KEYS = ['rrrrrrrrrrrrrrrrrrrrrhoLvTp', 'rrrrrrrrrrrrrrrrrrrrBZbvji']; +const EXCHANGE_BALANCE_THRESHOLD = 1000000000000; +const MIN_TRANSACTION_TAG = 9999; +const HIGH_SENDER_COUNT = 10; +const HIGH_PERCENTAGE_TAGGED_TX = 50; + +/* Helper Functions ==================================================================== */ +/** + * The Advisory object provides methods to fetch account advisory information, account details, + * and perform various checks on accounts based on their data and flags. + */ +const Advisory = { + /** + * Determines whether a possible exchange can take place based on the account balance. + * + * The function evaluates if the account balance is defined and greater than a specified threshold. + * + * @param {AccountRoot} accountData - The account data containing balance information. + * @returns {boolean} - Returns true if the account balance exceeds the exchange threshold; otherwise, false. + */ + checkPossibleExchange: (accountData?: AccountRoot): boolean => { + return !!accountData?.Balance && new BigNumber(accountData.Balance).isGreaterThan(EXCHANGE_BALANCE_THRESHOLD); + }, + + /** + * Checks if a given account is a "black hole" account. + * + * A black hole account is determined by checking if: + * 1. The account has a RegularKey set. + * 2. The master key is disabled. + * 3. The RegularKey is one of the predefined black hole keys. + * + * @param {AccountRoot} accountData - The account data object containing details of the account. + * @param {AccountInfoAccountFlags} accountFlags - The flags indicating account settings. + * @returns {boolean} - True if the account is a black hole account, otherwise false. + */ + checkBlackHoleAccount: (accountData?: AccountRoot, accountFlags?: AccountInfoAccountFlags): boolean => { + return ( + !!accountData?.RegularKey && + !!accountFlags?.disableMasterKey && + BLACK_HOLE_KEYS.includes(accountData.RegularKey) + ); + }, + + /** + * Determines if incoming XRP is disallowed for an account based on its flags. + * + * @param {AccountInfoAccountFlags} accountFlags - The flags associated with the account. + * @returns {boolean} - Returns `true` if the account disallows incoming XRP, otherwise `false`. + */ + checkDisallowIncomingXRP: (accountFlags?: AccountInfoAccountFlags): boolean => { + return accountFlags?.disallowIncomingXRP ?? false; + }, + + /** + * Checks if a destination tag is required for transactions to a specific account. + * + * This function evaluates multiple conditions to determine if a destination tag + * should be enforced for incoming transactions to the specified account address. + * It first checks if the destination tag requirement is already specified in the + * provided advisory or account flags. If not, it retrieves recent transactions + * for the account and analyzes the percentage of incoming transactions that + * already use a destination tag, as well as the number of unique senders. + * + * @param {string} address - The account address to check for destination tag requirement. + * @param {XamanBackend.AccountAdvisoryResponse} advisory - Advisory response with force destination tag info. + * @param {AccountInfoAccountFlags} accountFlags - Account flags indicating if destination tag is required. + * @returns {Promise} - Returns true if a destination tag is required, false otherwise. + */ + checkRequireDestinationTag: async ( + address: string, + advisory: XamanBackend.AccountAdvisoryResponse, + accountFlags?: AccountInfoAccountFlags, + ): Promise => { + // already indicates on advisory or account info ? + if (advisory.force_dtag || accountFlags?.requireDestinationTag) { + return true; + } + + const transactionsResp = await LedgerService.getTransactions(address, undefined, 200); + + if (!('error' in transactionsResp) && transactionsResp.transactions?.length > 0) { + const incomingTXS = transactionsResp.transactions.filter((tx) => tx.tx.Destination === address); + const incomingTxCountWithTag = incomingTXS.filter( + (tx) => Number(tx.tx.DestinationTag) > MIN_TRANSACTION_TAG, + ).length; + const uniqueSenders = new Set(transactionsResp.transactions.map((tx) => tx.tx.Account || '')).size; + const percentageTag = (incomingTxCountWithTag / incomingTXS.length) * 100; + + return uniqueSenders >= HIGH_SENDER_COUNT && percentageTag > HIGH_PERCENTAGE_TAGGED_TX; + } + + return false; + }, +}; + +export default Advisory; diff --git a/src/common/helpers/resolver.ts b/src/common/helpers/resolver.ts deleted file mode 100644 index a8ef031e1..000000000 --- a/src/common/helpers/resolver.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * AccountResolver is responsible for resolving account names and retrieving account information. - * It provides utility methods to look up account names based on the address and tag, - * as well as methods to fetch detailed account information including risk level and settings. - */ - -import { has, get, assign } from 'lodash'; - -import AccountRepository from '@store/repositories/account'; -import ContactRepository from '@store/repositories/contact'; - -import LedgerService from '@services/LedgerService'; -import BackendService from '@services/BackendService'; - -import Amount from '@common/libs/ledger/parser/common/amount'; - -import LRUCache from '@common/utils/cache'; -import LoggerService, { LoggerInstance } from '@services/LoggerService'; - -/* Types ==================================================================== */ -export interface PayIDInfo { - account: string; - tag: string | null; -} - -export interface AccountNameType { - address: string; - tag?: number; - name?: string; - source?: string; - kycApproved?: boolean; -} - -export interface AccountInfoType { - exist: boolean; - risk: 'ERROR' | 'UNKNOWN' | 'PROBABLE' | 'HIGH_PROBABILITY' | 'CONFIRMED'; - requireDestinationTag: boolean; - possibleExchange: boolean; - disallowIncomingXRP: boolean; - blackHole: boolean; -} - -/* Resolver ==================================================================== */ -class AccountResolver { - private static CacheSize = 300; - private cache: LRUCache | AccountNameType>; - private logger: LoggerInstance; - - constructor() { - this.cache = new LRUCache | AccountNameType>(AccountResolver.CacheSize); - this.logger = LoggerService.createLogger('AccountResolver'); - } - - private lookupresolveAccountName = async ( - address: string, - tag?: number, - internal = false, - ): Promise => { - const notFound: AccountNameType = { - address, - tag, - name: '', - source: '', - }; - - if (!address) { - return notFound; - } - - // Check in address book - try { - const contact = await ContactRepository.findOne({ - address, - destinationTag: `${tag ?? ''}`, - }); - - if (contact) { - return { - address, - tag, - name: contact.name, - source: 'contacts', - }; - } - } catch (error) { - this.logger.error('fetching contact:', error); - } - - // Check in accounts list - try { - const account = await AccountRepository.findOne({ address }); - if (account) { - return { - address, - tag, - name: account.label, - source: 'accounts', - }; - } - } catch (error) { - this.logger.error('fetching account:', error); - } - - // Only lookup for local results - if (internal) { - return notFound; - } - - // Check the backend - try { - const res = await BackendService.getAddressInfo(address); - if (res) { - return { - address, - tag, - name: res.name ?? undefined, - source: res.source?.replace('internal:', '').replace('.com', ''), - kycApproved: res.kycApproved, - }; - } - } catch (error) { - this.logger.error('fetching info from API', error); - } - - return notFound; - }; - - public setCache = (key: string, value: AccountNameType | Promise) => { - this.cache.set(key, value); - }; - - public getAccountName = async (address: string, tag?: number, internal = false): Promise => { - if (!address) { - throw new Error('Address is required.'); - } - - const key = `${address}${tag ?? ''}`; - - const cachedValue = this.cache.get(key); - if (cachedValue) { - return cachedValue; - } - - const resultPromise = (async () => { - const result = await this.lookupresolveAccountName(address, tag, internal); - this.cache.set(key, result); - return result; - })(); - - this.cache.set(key, resultPromise); // save the promise itself for subsequent calls - - return resultPromise; - }; - - getAccountInfo = async (address: string): Promise => { - if (!address) { - throw new Error('Address is required.'); - } - - const info: AccountInfoType = { - exist: true, - risk: 'UNKNOWN', - requireDestinationTag: false, - possibleExchange: false, - disallowIncomingXRP: false, - blackHole: false, - }; - - // get account risk level - const accountAdvisory = await BackendService.getAccountAdvisory(address); - - if (has(accountAdvisory, 'danger')) { - assign(info, { risk: accountAdvisory.danger }); - } else { - this.logger.error('account advisory risk level not found.'); - throw new Error('Account advisory risk level not found.'); - } - - const accountInfo = await LedgerService.getAccountInfo(address); - - // account doesn't exist, no need to check account risk - if ('error' in accountInfo) { - if (get(accountInfo, 'error') === 'actNotFound') { - assign(info, { exist: false }); - return info; - } - this.logger.error('fetching account info:', accountInfo); - throw new Error('Error fetching account info.'); - } - - const { account_data, account_flags } = accountInfo; - - // if balance is more than 1m possibly exchange account - if (has(account_data, ['Balance'])) { - if (new Amount(account_data.Balance, true).dropsToNative().toNumber() > 1000000) { - assign(info, { possibleExchange: true }); - } - } - - // check for black hole - if (has(account_data, ['RegularKey'])) { - if ( - account_flags?.disableMasterKey && - ['rrrrrrrrrrrrrrrrrrrrrhoLvTp', 'rrrrrrrrrrrrrrrrrrrrBZbvji'].indexOf(account_data.RegularKey ?? '') > - -1 - ) { - assign(info, { blackHole: true }); - } - } - - // check for disallow incoming XRP - if (account_flags?.disallowIncomingXRP) { - assign(info, { disallowIncomingXRP: true }); - } - - if (get(accountAdvisory, 'force_dtag')) { - // first check on account advisory - assign(info, { requireDestinationTag: true, possibleExchange: true }); - } else if (account_flags?.requireDestinationTag) { - // check if account have the required destination tag flag set - assign(info, { requireDestinationTag: true, possibleExchange: true }); - } else { - // scan the most recent transactions of the account for the destination tags - const transactionsResp = await LedgerService.getTransactions(address, undefined, 200); - if ( - !('error' in transactionsResp) && - transactionsResp.transactions && - transactionsResp.transactions.length > 0 - ) { - const incomingTXS = transactionsResp.transactions.filter((tx) => tx.tx.Destination === address); - - const incomingTxCountWithTag = incomingTXS.filter( - (tx) => - typeof tx.tx.TransactionType === 'string' && - typeof tx.tx.DestinationTag !== 'undefined' && - Number(tx.tx.DestinationTag) > 9999, - ).length; - - const senders = transactionsResp.transactions.map((tx) => tx.tx.Account || ''); - - const uniqueSenders = new Set(senders).size; - - const percentageTag = (incomingTxCountWithTag / incomingTXS.length) * 100; - - if (uniqueSenders >= 10 && percentageTag > 50) { - assign(info, { requireDestinationTag: true, possibleExchange: true }); - } - } - } - - return info; - }; - - getPayIdInfo = (payId: string): Promise => { - return BackendService.lookup(payId) - .then((res) => { - if (res) { - if (Array.isArray(res.matches) && res.matches.length > 0) { - const match = res.matches[0]; - return { - account: match.account, - tag: match.tag, - }; - } - } - return undefined; - }) - .catch(() => { - return undefined; - }); - }; -} - -export default new AccountResolver(); diff --git a/src/common/libs/ledger/transactions/genuine/Payment/Payment.info.ts b/src/common/libs/ledger/transactions/genuine/Payment/Payment.info.ts index d599c5643..9bd2d21b5 100644 --- a/src/common/libs/ledger/transactions/genuine/Payment/Payment.info.ts +++ b/src/common/libs/ledger/transactions/genuine/Payment/Payment.info.ts @@ -71,6 +71,7 @@ class PaymentInfo extends ExplainerAbstract { getParticipants() { // 3rd party consuming own offer + // or regular key if ([this.item.Account, this.item.Destination].indexOf(this.account.address) === -1) { return { start: { address: this.item.Account, tag: this.item.SourceTag }, diff --git a/src/common/libs/preferences.ts b/src/common/libs/preferences.ts index 393d807fb..402d8cf36 100644 --- a/src/common/libs/preferences.ts +++ b/src/common/libs/preferences.ts @@ -14,7 +14,7 @@ enum Keys { LATEST_VERSION_CODE = 'LATEST_VERSION_CODE', UPDATE_IGNORE_VERSION_CODE = 'UPDATE_IGNORE_VERSION_CODE', XAPP_STORE_IGNORE_MESSAGE_ID = 'XAPP_STORE_IGNORE_MESSAGE_ID', - CURATED_LIST_VERSION = 'CURATED_LIST_VERSION', + EXPERIMENTAL_SIMPLICITY_UI = 'EXPERIMENTAL_SIMPLICITY_UI', } /* Lib ==================================================================== */ diff --git a/src/common/utils/cache.ts b/src/common/utils/cache.ts index 37c89209e..8df1fd5f0 100644 --- a/src/common/utils/cache.ts +++ b/src/common/utils/cache.ts @@ -33,6 +33,14 @@ class LRUCache { } this.cache.set(key, value); } + + delete(key: K): void { + this.cache.delete(key); + } + + clear(): void { + this.cache.clear(); + } } export default LRUCache; diff --git a/src/common/utils/queue.ts b/src/common/utils/queue.ts new file mode 100644 index 000000000..f44506118 --- /dev/null +++ b/src/common/utils/queue.ts @@ -0,0 +1,36 @@ +export class PromiseQueue { + private queue: Map Promise> = new Map(); + private activeCount: number = 0; + private concurrency: number; + + constructor(concurrency: number) { + this.concurrency = concurrency; + } + + private async runNext() { + if (this.activeCount >= this.concurrency || this.queue.size === 0) { + return; + } + + const [key, task] = this.queue.entries().next().value; + if (!task) { + return; + } + + this.queue.delete(key); + this.activeCount++; + try { + await task(); + } finally { + this.activeCount--; + this.runNext(); + } + } + + enqueue(key: string, task: () => Promise) { + if (!this.queue.has(key)) { + this.queue.set(key, task); + this.runNext(); + } + } +} diff --git a/src/components/General/Button/Button.tsx b/src/components/General/Button/Button.tsx index 05242332c..c059b7c09 100644 --- a/src/components/General/Button/Button.tsx +++ b/src/components/General/Button/Button.tsx @@ -34,7 +34,7 @@ interface Props extends PropsWithChildren { numberOfLines?: number; isLoading?: boolean; isDisabled?: boolean; - loadingIndicatorStyle?: 'light' | 'dark'; + loadingIndicatorStyle?: 'light' | 'dark' | 'default'; onPress?: () => void; onLongPress?: () => void; label?: string; @@ -91,7 +91,7 @@ export default class Button extends Component { ); } diff --git a/src/components/General/Header/Header.tsx b/src/components/General/Header/Header.tsx index ee3836da0..f215f3540 100644 --- a/src/components/General/Header/Header.tsx +++ b/src/components/General/Header/Header.tsx @@ -20,17 +20,17 @@ interface ChildrenProps { icon?: Extract; iconSize?: number; iconStyle?: ImageStyle; - render?: any; + render?: () => JSX.Element | null; onPress?: () => void; extraComponent?: React.ReactNode; } interface Props { placement: placementType; - leftComponent?: ChildrenProps; - centerComponent?: ChildrenProps; - subComponent?: ChildrenProps; - rightComponent?: ChildrenProps; + leftComponent?: ChildrenProps | (() => JSX.Element); + centerComponent?: ChildrenProps | (() => JSX.Element); + subComponent?: ChildrenProps | (() => JSX.Element); + rightComponent?: ChildrenProps | (() => JSX.Element); backgroundColor?: string; containerStyle?: ViewStyle; } @@ -50,8 +50,12 @@ const Children = ({ }: { style: ViewStyle | ViewStyle[]; placement: placementType; - children: ChildrenProps; + children: ChildrenProps | (() => JSX.Element); }) => { + if (typeof children === 'function') { + return children(); + } + if (!children) { return ( { diff --git a/src/components/General/MultiPressDetector/MultiPressDetector.tsx b/src/components/General/MultiPressDetector/MultiPressDetector.tsx new file mode 100644 index 000000000..9db4d4a5f --- /dev/null +++ b/src/components/General/MultiPressDetector/MultiPressDetector.tsx @@ -0,0 +1,97 @@ +/** + * MultiPressDetector + * + + * + */ +import React, { PureComponent, PropsWithChildren } from 'react'; + +import { TouchableOpacity } from 'react-native'; + +/* Types ==================================================================== */ +export interface Props extends PropsWithChildren { + pressThreshold?: number; + onMultiPress?: () => void; +} + +export interface State { + pressCount: number; + lastPress: number; +} + +/* Component ==================================================================== */ +class MultiPressDetector extends PureComponent { + declare readonly props: Props & Required>; + + static defaultProps: Partial = { + pressThreshold: 3, + }; + + constructor(props: Props) { + super(props); + + this.state = { + pressCount: 0, + lastPress: 0, + }; + } + + handleButtonPress = () => { + const { pressCount, lastPress } = this.state; + const { pressThreshold } = this.props; + + const currentTime = Date.now(); + const timeDifference = currentTime - lastPress; + + if (timeDifference < 500) { + this.setState( + { + pressCount: pressCount + 1, + lastPress: currentTime, + }, + () => { + const { pressCount: newPressCount } = this.state; + if (newPressCount === pressThreshold) { + this.executeCallback(); + this.resetPressCount(); + } + }, + ); + } else { + this.resetPressCount(currentTime); + } + }; + + resetPressCount = (currentTime = 0) => { + this.setState({ + pressCount: 0, + lastPress: currentTime, + }); + }; + + executeCallback = () => { + const { onMultiPress } = this.props; + + // Call your callback function here + if (typeof onMultiPress === 'function') { + onMultiPress(); + } + }; + + render() { + const { children } = this.props; + + return ( + + {children} + + ); + } +} + +/* Export Component ==================================================================== */ +export default MultiPressDetector; diff --git a/src/components/General/MultiPressDetector/index.ts b/src/components/General/MultiPressDetector/index.ts new file mode 100644 index 000000000..d2cfb6776 --- /dev/null +++ b/src/components/General/MultiPressDetector/index.ts @@ -0,0 +1 @@ +export { default as MultiPressDetector, type Props as MultiPressDetectorProps } from './MultiPressDetector'; diff --git a/src/components/General/MultiPressDetector/styles.ts b/src/components/General/MultiPressDetector/styles.ts new file mode 100644 index 000000000..a7ab268ff --- /dev/null +++ b/src/components/General/MultiPressDetector/styles.ts @@ -0,0 +1,33 @@ +import StyleService from '@services/StyleService'; + +/* Styles ==================================================================== */ +export default StyleService.create({ + container: { + borderRadius: 11, + backgroundColor: '$tint', + alignItems: 'center', + justifyContent: 'center', + }, + placeholder: { + backgroundColor: '$grey', + }, + image: { + borderRadius: 11, + }, + border: { + borderColor: '$lightGrey', + borderWidth: 1, + }, + badgeContainer: { + position: 'absolute', + }, + badgeContainerText: { + position: 'absolute', + backgroundColor: '$blue', + borderWidth: 2.5, + borderColor: '$background', + }, + badge: { + tintColor: '$white', + }, +}); diff --git a/src/components/General/index.ts b/src/components/General/index.ts index a1f53f25e..ead7d96f6 100644 --- a/src/components/General/index.ts +++ b/src/components/General/index.ts @@ -33,14 +33,13 @@ export * from './NumberSteps'; export * from './HorizontalLine'; export * from './ProgressBar'; export * from './CheckBox'; +export * from './MultiPressDetector'; export * from './SortableFlatList'; export * from './AmountText'; export * from './LoadingIndicator'; export * from './KeyboardAwareScrollView'; export * from './ActionPanel'; -export * from './TokenAvatar'; -export * from './TokenIcon'; export * from './ExpandableView'; export * from './BlurView'; export * from './WebView'; diff --git a/src/components/Modules/AccountElement/AccountElement.tsx b/src/components/Modules/AccountElement/AccountElement.tsx index 2bab1bd65..8890840a3 100644 --- a/src/components/Modules/AccountElement/AccountElement.tsx +++ b/src/components/Modules/AccountElement/AccountElement.tsx @@ -3,7 +3,7 @@ import { isEqual, isEmpty } from 'lodash'; import React, { Component } from 'react'; import { View, Text, ViewStyle, InteractionManager, TextStyle } from 'react-native'; -import AccountResolver, { AccountNameType } from '@common/helpers/resolver'; +import ResolverService, { AccountNameResolveType } from '@services/ResolverService'; import { Navigator } from '@common/helpers/navigator'; import { AppScreens } from '@common/constants'; @@ -37,16 +37,16 @@ interface Props { id?: string; address: string; tag?: number; - info?: AccountNameType; + info?: AccountNameResolveType; visibleElements?: VisibleElementsType; containerStyle?: ViewStyle | ViewStyle[]; textStyle?: TextStyle | TextStyle[]; onPress?: (account: AccountElementType) => void; - onInfoUpdate?: (info: AccountNameType) => void; + onInfoUpdate?: (info: AccountNameResolveType) => void; } interface State { - info?: AccountNameType; + info?: AccountNameResolveType; isLoading: boolean; } @@ -125,7 +125,7 @@ class AccountElement extends Component { }); } - AccountResolver.getAccountName(address, tag) + ResolverService.getAccountName(address, tag) .then((res) => { if (!isEmpty(res) && this.mounted) { this.setState( diff --git a/src/components/Modules/AssetsList/AssetsList.tsx b/src/components/Modules/AssetsList/AssetsList.tsx index 6b1a61503..2034cb2b1 100644 --- a/src/components/Modules/AssetsList/AssetsList.tsx +++ b/src/components/Modules/AssetsList/AssetsList.tsx @@ -19,6 +19,7 @@ interface Props { account: AccountModel; discreetMode: boolean; spendable: boolean; + experimentalUI?: boolean; } interface State { @@ -67,7 +68,7 @@ class AssetsList extends Component { }; render() { - const { style, timestamp, discreetMode, spendable, account } = this.props; + const { style, timestamp, discreetMode, spendable, experimentalUI, account } = this.props; const { category } = this.state; let AssetListComponent; @@ -91,6 +92,7 @@ class AssetsList extends Component { spendable={spendable} onChangeCategoryPress={this.onChangeCategoryPress} style={style} + experimentalUI={experimentalUI} /> ); } diff --git a/src/components/Modules/AssetsList/Tokens/ListFilter/ListFilter.tsx b/src/components/Modules/AssetsList/Tokens/ListFilter/ListFilter.tsx index e0b415100..7247babe6 100644 --- a/src/components/Modules/AssetsList/Tokens/ListFilter/ListFilter.tsx +++ b/src/components/Modules/AssetsList/Tokens/ListFilter/ListFilter.tsx @@ -18,7 +18,7 @@ export interface FiltersType { interface Props { filters?: FiltersType; - reorderEnabled: boolean; + visible: boolean; onFilterChange: (filters: FiltersType | undefined) => void; onReorderPress: () => void; } @@ -50,11 +50,11 @@ class ListFilter extends Component { } shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean { - const { reorderEnabled } = this.props; + const { visible } = this.props; const { filterText, favoritesEnabled, hideZeroEnabled } = this.state; return ( - !isEqual(nextProps.reorderEnabled, reorderEnabled) || + !isEqual(nextProps.visible, visible) || !isEqual(nextState.filterText, filterText) || !isEqual(nextState.favoritesEnabled, favoritesEnabled) || !isEqual(nextState.hideZeroEnabled, hideZeroEnabled) @@ -178,11 +178,11 @@ class ListFilter extends Component { }; render() { - const { reorderEnabled } = this.props; + const { visible } = this.props; const { favoritesEnabled, hideZeroEnabled } = this.state; // hide filters when reordering is enabled - if (reorderEnabled) { + if (!visible) { return null; } diff --git a/src/components/Modules/AssetsList/Tokens/ListFilter/styles.ts b/src/components/Modules/AssetsList/Tokens/ListFilter/styles.ts index 0f78d4634..e5f6ebd18 100644 --- a/src/components/Modules/AssetsList/Tokens/ListFilter/styles.ts +++ b/src/components/Modules/AssetsList/Tokens/ListFilter/styles.ts @@ -57,7 +57,7 @@ export default StyleService.create({ alignItems: 'center', }, searchBarInput: { - fontFamily: AppFonts.base.familyMono, + fontFamily: AppFonts.base.family, fontSize: AppFonts.subtext.size, color: '$grey', paddingLeft: 30, diff --git a/src/components/Modules/AssetsList/Tokens/NativeItem/NativeItem.tsx b/src/components/Modules/AssetsList/Tokens/NativeItem/NativeItem.tsx index 7c65bf410..0f5ddffb5 100644 --- a/src/components/Modules/AssetsList/Tokens/NativeItem/NativeItem.tsx +++ b/src/components/Modules/AssetsList/Tokens/NativeItem/NativeItem.tsx @@ -11,7 +11,9 @@ import { Toast } from '@common/helpers/interface'; import { CoreRepository } from '@store/repositories'; import { AccountModel, CoreModel } from '@store/models'; -import { AmountText, Icon, TokenAvatar, TokenIcon, TouchableDebounce } from '@components/General'; +import { AmountText, Icon, TouchableDebounce } from '@components/General'; + +import { TokenAvatar, TokenIcon } from '@components/Modules/TokenElement'; import Localize from '@locale'; diff --git a/src/components/Modules/AssetsList/Tokens/TokenItem/TokenItem.tsx b/src/components/Modules/AssetsList/Tokens/TokenItem/TokenItem.tsx index 9ff164329..c41501676 100644 --- a/src/components/Modules/AssetsList/Tokens/TokenItem/TokenItem.tsx +++ b/src/components/Modules/AssetsList/Tokens/TokenItem/TokenItem.tsx @@ -3,14 +3,12 @@ import isEqual from 'lodash/isEqual'; import React, { PureComponent } from 'react'; import { View, Text } from 'react-native'; -import { Button, AmountText, Icon, TokenAvatar, TokenIcon } from '@components/General'; +import { Button, AmountText, Icon } from '@components/General'; -import { NormalizeCurrencyCode } from '@common/utils/monetary'; +import { TokenAvatar, TokenIcon } from '@components/Modules/TokenElement'; import { TrustLineModel } from '@store/models'; -import Localize from '@locale'; - import { AppStyles, AppSizes } from '@theme'; import styles from './styles'; @@ -21,6 +19,7 @@ interface Props { selfIssued: boolean; reorderEnabled: boolean; discreetMode: boolean; + saturate?: boolean; onPress: (token: TrustLineModel, index: number) => void; onMoveTopPress: (token: TrustLineModel, index: number) => void; } @@ -80,44 +79,25 @@ class TokenItem extends PureComponent { } }; - getIssuerLabel = () => { - const { selfIssued, token } = this.props; - - if (selfIssued) return Localize.t('home.selfIssued'); - - if (token.currency.name) { - return `${token.counterParty.name} ${NormalizeCurrencyCode(token.currency.currencyCode)}`; - } - - return `${token.counterParty.name}`; - }; - - getCurrencyName = () => { - const { token } = this.props; - - return token.getReadableCurrency(); - }; - - getTokenAvatar = () => { + getTokenAvatarBadge = () => { const { token } = this.props; const { favorite, no_ripple, limit } = this.state; - let badge = null as any; + // show alert on top of avatar if rippling set + if ((!no_ripple || Number(limit) === 0) && !token.obligation && !token.isLiquidityPoolToken()) { + return ; + } + // favorite token if (favorite) { - badge = ( + return ( ); } - // show alert on top of avatar if rippling set - if ((!no_ripple || Number(limit) === 0) && !token.obligation && !token.isLiquidityPoolToken()) { - badge = ; - } - - return badge} />; + return null; }; renderReorderButtons = () => { @@ -150,7 +130,7 @@ class TokenItem extends PureComponent { }; renderBalance = () => { - const { token, discreetMode } = this.props; + const { token, discreetMode, saturate } = this.props; const { balance } = this.state; return ( @@ -160,6 +140,7 @@ class TokenItem extends PureComponent { token={token} containerStyle={styles.tokenIconContainer} style={discreetMode ? AppStyles.imgColorGrey : {}} + saturate={saturate} /> } value={balance} @@ -171,18 +152,26 @@ class TokenItem extends PureComponent { }; render() { - const { token, reorderEnabled } = this.props; + const { token, saturate, reorderEnabled } = this.props; return ( - {this.getTokenAvatar()} + + + - - {this.getCurrencyName()} + + {token.getFormattedCurrency()} - {this.getIssuerLabel()} + {token.getFormattedIssuer()} diff --git a/src/components/Modules/AssetsList/Tokens/TokensList.tsx b/src/components/Modules/AssetsList/Tokens/TokensList.tsx index 3ff1bc49a..f5e9ea6f8 100644 --- a/src/components/Modules/AssetsList/Tokens/TokensList.tsx +++ b/src/components/Modules/AssetsList/Tokens/TokensList.tsx @@ -8,7 +8,7 @@ import { NormalizeCurrencyCode } from '@common/utils/monetary'; import { Navigator } from '@common/helpers/navigator'; -import { TrustLineRepository } from '@store/repositories'; +import { CurrencyRepository, TrustLineRepository } from '@store/repositories'; import { AccountModel, TrustLineModel } from '@store/models'; import { SortableFlatList } from '@components/General'; @@ -29,7 +29,7 @@ interface Props { account: AccountModel; discreetMode: boolean; spendable: boolean; - + experimentalUI?: boolean; onChangeCategoryPress: () => void; } @@ -68,20 +68,25 @@ class TokensList extends Component { // listen for token updates // this is needed when a single token favorite status changed TrustLineRepository.on('trustLineUpdate', this.onTrustLineUpdate); + // this is needed when using ResolveService to sync the currency details + CurrencyRepository.on('currencyDetailsUpdate', this.onCurrencyDetailsUpdate); } componentWillUnmount(): void { // remove trustLine update listener TrustLineRepository.off('trustLineUpdate', this.onTrustLineUpdate); + // remove listener + CurrencyRepository.off('currencyDetailsUpdate', this.onCurrencyDetailsUpdate); } shouldComponentUpdate(nextProps: Props, nextState: State): boolean { - const { discreetMode, spendable } = this.props; + const { discreetMode, spendable, experimentalUI } = this.props; const { dataSource, accountStateVersion, reorderEnabled, filters } = this.state; return ( !isEqual(nextProps.spendable, spendable) || !isEqual(nextProps.discreetMode, discreetMode) || + !isEqual(nextProps.experimentalUI, experimentalUI) || !isEqual(nextState.accountStateVersion, accountStateVersion) || !isEqual(nextState.reorderEnabled, reorderEnabled) || !isEqual(nextState.filters, filters) || @@ -153,7 +158,7 @@ class TokensList extends Component { filtered = filter(filtered, (item: TrustLineModel) => { return ( toLower(item.currency.name).indexOf(normalizedSearch) > -1 || - toLower(item.counterParty?.name).indexOf(normalizedSearch) > -1 || + toLower(item.currency?.issuerName).indexOf(normalizedSearch) > -1 || toLower(NormalizeCurrencyCode(item.currency.currencyCode)).indexOf(normalizedSearch) > -1 ); }); @@ -174,6 +179,11 @@ class TokensList extends Component { return filtered ?? []; }; + onCurrencyDetailsUpdate = () => { + // update the token list if any of token details changed + this.forceUpdate(); + }; + onTrustLineUpdate = (updatedToken: TrustLineModel, changes: Partial) => { // update the token in the list if token favorite changed if (has(changes, 'favorite')) { @@ -198,7 +208,7 @@ class TokensList extends Component { if (spendable) { Navigator.showOverlay( AppScreens.Overlay.TokenSettings, - { trustLine: token, account }, + { token, account }, { overlay: { interceptTouchOutside: false, @@ -298,7 +308,7 @@ class TokensList extends Component { }; renderItem = ({ item, index }: { item: TrustLineModel; index: number }) => { - const { discreetMode } = this.props; + const { discreetMode, experimentalUI } = this.props; const { account, reorderEnabled } = this.state; return ( @@ -307,6 +317,7 @@ class TokensList extends Component { token={item} reorderEnabled={reorderEnabled} discreetMode={discreetMode} + saturate={experimentalUI} selfIssued={item.currency.issuer === account.address} onPress={this.onTokenItemPress} onMoveTopPress={this.onItemMoveTopPress} @@ -330,7 +341,7 @@ class TokensList extends Component { }; render() { - const { account, style, spendable, discreetMode } = this.props; + const { account, style, spendable, discreetMode, experimentalUI } = this.props; const { dataSource, reorderEnabled, filters } = this.state; return ( @@ -344,7 +355,7 @@ class TokensList extends Component { /> diff --git a/src/components/Modules/CurrencyElement/CurrencyElement.tsx b/src/components/Modules/CurrencyElement/CurrencyElement.tsx index b366c1c39..71203be34 100644 --- a/src/components/Modules/CurrencyElement/CurrencyElement.tsx +++ b/src/components/Modules/CurrencyElement/CurrencyElement.tsx @@ -3,11 +3,11 @@ import { isEmpty } from 'lodash'; import React, { Component } from 'react'; import { View, Text, ViewStyle, InteractionManager, TextStyle } from 'react-native'; -import NetworkService from '@services/NetworkService'; - import { WebLinks } from '@common/constants/endpoints'; -import AccountResolver, { AccountNameType } from '@common/helpers/resolver'; +import ResolverService, { AccountNameResolveType } from '@services/ResolverService'; +import NetworkService from '@services/NetworkService'; + import { NormalizeCurrencyCode } from '@common/utils/monetary'; import { Images } from '@common/helpers/images'; @@ -28,7 +28,7 @@ interface Props { } interface State { - issuerInfo?: AccountNameType; + issuerInfo?: AccountNameResolveType; isLoading: boolean; } @@ -72,7 +72,7 @@ class CurrencyElement extends Component { }); } - AccountResolver.getAccountName(issuer) + ResolverService.getAccountName(issuer) .then((res) => { if (!isEmpty(res)) { this.setState({ diff --git a/src/components/Modules/CurrencyPicker/CurrencyItem/CurrencyItem.tsx b/src/components/Modules/CurrencyPicker/CurrencyItem/CurrencyItem.tsx index 6d3d33a56..069b2c68e 100644 --- a/src/components/Modules/CurrencyPicker/CurrencyItem/CurrencyItem.tsx +++ b/src/components/Modules/CurrencyPicker/CurrencyItem/CurrencyItem.tsx @@ -7,7 +7,8 @@ import { AccountModel, TrustLineModel } from '@store/models'; import { CalculateAvailableBalance } from '@common/utils/balance'; -import { AmountText, TokenAvatar } from '@components/General'; +import { AmountText } from '@components/General'; +import { TokenAvatar } from '@components/Modules/TokenElement'; import Localize from '@locale'; @@ -52,19 +53,17 @@ class CurrencyItem extends Component { - - {item.getReadableCurrency()} - - -  - {item.counterParty.name} + + + {item.getFormattedCurrency()} - - + +  - {item.getFormattedIssuer()} + + diff --git a/src/components/Modules/CurrencyPicker/CurrencyItem/styles.ts b/src/components/Modules/CurrencyPicker/CurrencyItem/styles.ts index 4605b18e6..bf3fbdfee 100644 --- a/src/components/Modules/CurrencyPicker/CurrencyItem/styles.ts +++ b/src/components/Modules/CurrencyPicker/CurrencyItem/styles.ts @@ -13,16 +13,17 @@ export default StyleService.create({ justifyContent: 'center', color: '$textPrimary', }, - currencyItemCounterPartyLabel: { + currencyIssuerLabel: { fontSize: AppFonts.small.size, fontFamily: AppFonts.base.family, color: '$textSecondary', + paddingTop: 5, }, currencyItemLabelSelected: { color: '$blue', }, currencyBalance: { - fontSize: AppFonts.subtext.size * 0.9, + fontSize: AppFonts.subtext.size, fontFamily: AppFonts.base.familyMono, color: '$grey', }, diff --git a/src/components/Modules/EventsList/EventListItems/Blocks/Monetary.tsx b/src/components/Modules/EventsList/EventListItems/Blocks/Monetary.tsx index c185b7578..2a04ba6c0 100644 --- a/src/components/Modules/EventsList/EventListItems/Blocks/Monetary.tsx +++ b/src/components/Modules/EventsList/EventListItems/Blocks/Monetary.tsx @@ -74,7 +74,11 @@ class Monetary extends PureComponent { prefix: undefined, value: factor.at(0)?.value, currency: factor.at(0)?.currency, - style: factor.at(0)?.action === OperationActions.INC ? styles.pendingIncColor : styles.pendingDecColor, + style: factor.at(0)?.action + ? factor.at(0)?.action + ? styles.pendingDecColor + : styles.pendingIncColor + : styles.notEffectedColor, }; } diff --git a/src/components/Modules/EventsList/EventListItems/Blocks/styles.tsx b/src/components/Modules/EventsList/EventListItems/Blocks/styles.tsx index 8df7354af..7b13fd4cd 100644 --- a/src/components/Modules/EventsList/EventListItems/Blocks/styles.tsx +++ b/src/components/Modules/EventsList/EventListItems/Blocks/styles.tsx @@ -63,6 +63,9 @@ const styles = StyleService.create({ pendingIncColor: { color: '$grey', }, + notEffectedColor: { + color: '$grey', + }, }); export default styles; diff --git a/src/components/Modules/EventsList/EventListItems/Blocks/types.ts b/src/components/Modules/EventsList/EventListItems/Blocks/types.ts index 248dececb..42a9e3047 100644 --- a/src/components/Modules/EventsList/EventListItems/Blocks/types.ts +++ b/src/components/Modules/EventsList/EventListItems/Blocks/types.ts @@ -4,11 +4,12 @@ import { CombinedTransactions, FallbackTransaction, Transactions } from '@common import { LedgerObjects } from '@common/libs/ledger/objects/types'; import { MutationsMixinType } from '@common/libs/ledger/mixin/types'; import { ExplainerAbstract } from '@common/libs/ledger/factory/types'; -import { AccountNameType } from '@common/helpers/resolver'; + +import { AccountNameResolveType } from '@services/ResolverService'; export interface Props { item: ((Transactions | FallbackTransaction) & MutationsMixinType) | LedgerObjects; account: AccountModel; - participant?: AccountNameType; + participant?: AccountNameResolveType; explainer?: ExplainerAbstract; } diff --git a/src/components/Modules/EventsList/EventListItems/LedgerObject.tsx b/src/components/Modules/EventsList/EventListItems/LedgerObject.tsx index eb92d460a..c72da354b 100644 --- a/src/components/Modules/EventsList/EventListItems/LedgerObject.tsx +++ b/src/components/Modules/EventsList/EventListItems/LedgerObject.tsx @@ -13,7 +13,7 @@ import { MutationsMixinType } from '@common/libs/ledger/mixin/types'; import { Navigator } from '@common/helpers/navigator'; -import AccountResolver, { AccountNameType } from '@common/helpers/resolver'; +import ResolverService, { AccountNameResolveType } from '@services/ResolverService'; import { TouchableDebounce } from '@components/General'; @@ -36,7 +36,7 @@ export interface Props { export interface State { isLoading: boolean; - participant?: AccountNameType; + participant?: AccountNameResolveType; explainer?: ExplainerAbstract; } @@ -122,7 +122,7 @@ class LedgerObjectItem extends Component { try { // get participant details - const resp = await AccountResolver.getAccountName(otherParty.address, otherParty.tag); + const resp = await ResolverService.getAccountName(otherParty.address, otherParty.tag); if (!isEmpty(resp) && this.mounted) { this.setState({ explainer, diff --git a/src/components/Modules/EventsList/EventListItems/Transaction.tsx b/src/components/Modules/EventsList/EventListItems/Transaction.tsx index 7bc0bb3bf..eb5aaeb17 100644 --- a/src/components/Modules/EventsList/EventListItems/Transaction.tsx +++ b/src/components/Modules/EventsList/EventListItems/Transaction.tsx @@ -3,6 +3,8 @@ import { isEmpty, isEqual } from 'lodash'; import React, { Component } from 'react'; import { View, InteractionManager } from 'react-native'; +import { AppScreens } from '@common/constants'; + import { ExplainerFactory } from '@common/libs/ledger/factory'; import { CombinedTransactions, Transactions } from '@common/libs/ledger/transactions/types'; import { MutationsMixinType } from '@common/libs/ledger/mixin/types'; @@ -12,17 +14,16 @@ import { LedgerObjects } from '@common/libs/ledger/objects/types'; import { AccountModel } from '@store/models'; -import { Navigator } from '@common/helpers/navigator'; -import AccountResolver, { AccountNameType } from '@common/helpers/resolver'; +import ResolverService, { AccountNameResolveType } from '@services/ResolverService'; -import { AppScreens } from '@common/constants'; +import { Navigator } from '@common/helpers/navigator'; import { TouchableDebounce } from '@components/General'; -import * as Blocks from './Blocks'; - import { TransactionDetailsViewProps } from '@screens/Events/Details'; +import * as Blocks from './Blocks'; + import { AppSizes, AppStyles } from '@theme'; import styles from './styles'; @@ -35,7 +36,7 @@ export interface Props { export interface State { isLoading: boolean; - participant?: AccountNameType; + participant?: AccountNameResolveType; explainer?: ExplainerAbstract; } @@ -121,7 +122,7 @@ class TransactionItem extends Component { try { // get participant details - const resp = await AccountResolver.getAccountName(otherParty.address, otherParty.tag); + const resp = await ResolverService.getAccountName(otherParty.address, otherParty.tag); if (!isEmpty(resp) && this.mounted) { this.setState({ explainer, @@ -159,6 +160,9 @@ class TransactionItem extends Component { activeOpacity={0.6} style={[styles.container, { height: TransactionItem.Height }]} > + {/* if participant is block the show an overlay to reduce the visibility */} + {participant?.blocked && } + diff --git a/src/components/Modules/EventsList/EventListItems/styles.tsx b/src/components/Modules/EventsList/EventListItems/styles.tsx index c28664664..94f62f25e 100644 --- a/src/components/Modules/EventsList/EventListItems/styles.tsx +++ b/src/components/Modules/EventsList/EventListItems/styles.tsx @@ -1,5 +1,7 @@ import StyleService from '@services/StyleService'; +import { HexToRgbA } from '@common/utils/color'; + import { AppFonts, AppSizes } from '@theme'; /* Styles ==================================================================== */ @@ -7,7 +9,15 @@ const styles = StyleService.create({ container: { flexDirection: 'row', alignItems: 'center', - borderRadius: 10, + borderRadius: AppSizes.borderRadius, + }, + containerBlocked: { + width: '100%', + height: '100%', + backgroundColor: HexToRgbA(StyleService.value('$background'), 0.8), + position: 'absolute', + borderRadius: AppSizes.borderRadius, + zIndex: 9999, // top of all }, iconContainer: { borderColor: '$lightGrey', diff --git a/src/components/Modules/MonetizationElement/MonetizationElement.tsx b/src/components/Modules/MonetizationElement/MonetizationElement.tsx index 057babcdb..652202a0a 100644 --- a/src/components/Modules/MonetizationElement/MonetizationElement.tsx +++ b/src/components/Modules/MonetizationElement/MonetizationElement.tsx @@ -10,7 +10,7 @@ import { InteractionTypes } from '@store/models/objects/userInteraction'; import { PurchaseProductModalProps } from '@screens/Modal/PurchaseProduct'; -import { Button, RaisedButton } from '@components/General'; +import { Button, Icon } from '@components/General'; import Localize from '@locale'; @@ -74,6 +74,13 @@ class MonetizationElement extends PureComponent { const { monetization } = profile; + // clean up old suppress warning flag + if (suppressComingUpWarning && monetization.monetizationStatus === MonetizationStatus.NONE) { + UserInteractionRepository.updateInteraction(InteractionTypes.MONETIZATION, { + suppress_warning_on_home_screen: false, + }); + } + this.setState({ suppressComingUpWarning, monetizationStatus: monetization.monetizationStatus, @@ -138,19 +145,29 @@ class MonetizationElement extends PureComponent { renderPaymentRequired = () => { const { style } = this.props; + const { productForPurchase } = this.state; + + // just making sure we are not ending up with unresponsive UI + if (!productForPurchase) { + return null; + } return ( - - {Localize.t('monetization.paymentRequiredMessage')} - - + + + + + {Localize.t('monetization.unlockFullFunctionality')} + +