feat: Local First Storage (Evolu) for mobile

fix: temp comment out some code that causes some ESM import hell

fix: tests, improve to not use 'as' in fixtures

chore: replace 'as' with array spread to convert to ordinary (from readonly) array

fix: bump expo to ^53.0.22 so it can be same as expo required by evolu (and can be deduped)

fix: re-enable the FetureFlag callback

chore: better naming for local-first-package in native

fix: remove on bad circular dependency from local-first-storage reducer<->selectors

chore: update madge (circular import, one more is removed)

fix: disable Evolu labeling in Portfolio Tracker mode

chore: use F.toMutable instead of a ... spread

fix: depcheck ignore (peerdependecny: expo, expo-sqlite) only for relevant package

fix: move feature-flag code out of the feature-falgs module into dev-tools

fix: better selectors, no need to provide whole object, the string|undefined is all what is needed

fix: bad key props in the list

fix: use standard TextButton component

fix: unify string|null for (all) label selectors

fix: missing translation string

chore: extract the labeling logic into separate thunk

fix: depcheck

fix: transaction name component has now correct variant

fix: render debug inly when labeling is on
This commit is contained in:
Peter Sanderson
2025-09-02 13:57:27 +02:00
committed by Peter Sanderson
parent 6ffac4a029
commit ccc434d6a4
94 changed files with 1430 additions and 556 deletions

View File

@@ -24,7 +24,7 @@ module.exports = {
'\\.(js|jsx|ts|tsx)$': ['babel-jest', babelConfig],
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@shopify/react-native-skia|@noble)',
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@shopify/react-native-skia|@noble|@evolu|nanoid|msgpackr)',
],
setupFiles: [
'<rootDir>/../../node_modules/@shopify/react-native-skia/jestSetup.js',

View File

@@ -10,7 +10,7 @@
},
"dependencies": {
"@trezor/connect-mobile": "workspace:*",
"expo": "53.0.20",
"expo": "^53.0.22",
"expo-linking": "7.1.7",
"expo-status-bar": "^2.0.0",
"react": "19.0.0",

View File

@@ -156,8 +156,7 @@ export const init = async (container: HTMLElement) => {
// init bluetooth module
await store.dispatch(initBluetoothThunk());
// This needs to be initialized to subscribe to all Remembered Wallets
await store.dispatch(initSuiteLocalFirstStorageThunk());
store.dispatch(initSuiteLocalFirstStorageThunk());
// finally render whole app
root.render(

View File

@@ -82,8 +82,7 @@ export const init = async (container: HTMLElement) => {
const preloadAction = await preloadStore();
const store = initStore(preloadAction);
// This needs to be initialized to subscribe to all Remembered Wallets
await store.dispatch(initSuiteLocalFirstStorageThunk());
store.dispatch(initSuiteLocalFirstStorageThunk());
root.render(
<ReduxProvider store={store}>

View File

@@ -107,7 +107,7 @@ export const AccountDetails = ({ selectedAccount, isBalanceShown }: AccountDetai
}),
);
const label = localFirstAccountLabel?.label ?? selectedAccountLabels.accountLabel;
const label = localFirstAccountLabel ?? selectedAccountLabels.accountLabel;
const { getDefaultAccountLabel } = useDefaultAccountLabel();
const { symbol, key, path, index, accountType, formattedBalance, deviceState } =

View File

@@ -1,4 +1,5 @@
import { AcquiredDevice } from '@suite-common/suite-types';
import { parseDeviceStaticSessionId } from '@suite-common/wallet-utils';
import { Code, Row, Text, Tooltip } from '@trezor/components';
import { spacings } from '@trezor/theme';
@@ -20,6 +21,8 @@ export const LocalFirstStorageDebug = ({ device }: { device: AcquiredDevice }) =
return;
}
const { walletDescriptor, deviceId } = parseDeviceStaticSessionId(device.state.staticSessionId);
return isLocalFirstStorageEnabled ? (
<Row gap={spacings.xxs}>
🐞
@@ -27,11 +30,11 @@ export const LocalFirstStorageDebug = ({ device }: { device: AcquiredDevice }) =
{isLocalFirstStorageEnabled && (
<>
<Text typographyStyle="hint" variant="warning">
<Code>{device.state.staticSessionId.split('@')[0].slice(-8)}</Code>
<Code>{walletDescriptor.slice(-8)}</Code>
</Text>
@
<Text typographyStyle="hint" variant="purple">
<Code>{device.state.staticSessionId.slice(-8)}</Code>
<Code>{deviceId.slice(-8)}</Code>
</Text>
<Tooltip
content={
@@ -39,7 +42,7 @@ export const LocalFirstStorageDebug = ({ device }: { device: AcquiredDevice }) =
}
>
<Text typographyStyle="hint" variant="purple">
E:{' '}
E:
<Code>
{device.localFirstStorageSecret?.evoluKeys?.ownerId.slice(-8)}
</Code>

View File

@@ -232,7 +232,6 @@ suite-common/connect-popup/src/methodHooks/index.ts > suite-common/connect-popup
suite-common/firmware/src/index.ts > suite-common/firmware/src/hooks/useFirmwareInstallation.ts
suite-common/graph/src/graphDataFetching.ts > suite-common/graph/src/graphUtils.ts
suite-common/intl-types/src/index.ts > suite-common/intl-types/src/types.ts > packages/suite/src/components/suite/Translation.tsx
suite-common/local-first-storage/src/labeling/labelingReducer.ts > suite-common/local-first-storage/src/labeling/labelingSelectors.ts
suite-common/staking-solana/src/index.ts > suite-common/staking-solana/src/utils/index.ts > suite-common/staking-solana/src/utils/stakingUtils.ts
suite-common/trading/src/index.ts > suite-common/trading/src/hooks/useTradingInfo.ts > suite-common/trading/src/hooks/useSelector.ts > suite-common/trading/src/types.ts > suite-common/trading/src/reducers/tradingReducer.ts > suite-common/trading/src/thunks/index.ts > suite-common/trading/src/thunks/buy/confirmBuyTradeThunk.ts > suite-common/trading/src/invityAPI.ts
suite-common/trading/src/reducers/tradingReducer.ts > suite-common/trading/src/thunks/index.ts > suite-common/trading/src/thunks/buy/confirmBuyTradeThunk.ts

View File

@@ -1,5 +1,6 @@
@babel/plugin-transform-export-namespace-from
@config-plugins/detox
@evolu/react-native
@expo/config-plugins
@gorhom/bottom-sheet
@mobily/ts-belt
@@ -46,6 +47,7 @@ expo-local-authentication
expo-localization
expo-secure-store
expo-splash-screen
expo-sqlite
expo-status-bar
expo-system-ui
expo-updates

View File

@@ -13,15 +13,13 @@ export { updateAddressLabelThunk } from './labeling/updateAddressLabelThunk';
export {
selectWalletLabel,
selectAccountLabels,
findAccountLabel,
findOutputLabel,
selectAddressLabels,
selectAddressLabel,
findAddressLabel,
selectAccountLabel,
selectOutputLabels,
selectOutputLabel,
} from './labeling/labelingSelectors';
export { findAccountLabel, findOutputLabel, findAddressLabel } from './labeling/selectorUtils';
export type { WithLabelingState } from './labeling/labelingSelectors';
export { labelingActions } from './labeling/labelingActions';
export { prepareLabelingReducer, initialLabelingState } from './labeling/labelingReducer';

View File

@@ -5,7 +5,7 @@ import { AccountLabel } from './evolu/accountLabels';
import { AddressLabel } from './evolu/addressLabels';
import { OutputLabel } from './evolu/outputLabels';
import { labelingActions } from './labelingActions';
import { findAccountLabel, findAddressLabel, findOutputLabel } from './labelingSelectors';
import { findAccountLabel, findAddressLabel, findOutputLabel } from './selectorUtils';
export type WalletLabelState = {
walletLabel: string | null;

View File

@@ -1,12 +1,9 @@
import { NetworkSymbol } from '@suite-common/wallet-config';
import type { AccountDescriptor, AccountKey, WalletDescriptor } from '@suite-common/wallet-types';
import type { AccountKey, WalletDescriptor } from '@suite-common/wallet-types';
import { parseAccountKey, parseDeviceStaticSessionId } from '@suite-common/wallet-utils';
import type { StaticSessionId } from '@trezor/connect';
import { AccountLabel } from './evolu/accountLabels';
import { AddressLabel } from './evolu/addressLabels';
import { OutputLabel } from './evolu/outputLabels';
import { LabelingState } from './labelingReducer';
import type { LabelingState } from './labelingReducer';
import { findAccountLabel, findAddressLabel, findOutputLabel } from './selectorUtils';
export type WithLabelingState = {
labeling: LabelingState;
@@ -17,7 +14,10 @@ type SelectWalletLabelParams = {
deviceStaticSessionId: StaticSessionId | undefined;
};
export const selectWalletLabel = ({ state, deviceStaticSessionId }: SelectWalletLabelParams) => {
export const selectWalletLabel = ({
state,
deviceStaticSessionId,
}: SelectWalletLabelParams): string | null => {
if (deviceStaticSessionId === undefined) {
return null;
}
@@ -41,21 +41,6 @@ export const selectAccountLabels = ({
return state.labeling.walletsLabels[walletDescriptor]?.accountLabels ?? [];
};
type FindAccountLabelParams = {
accountLabels: AccountLabel[];
accountDescriptor: AccountDescriptor;
networkSymbol: NetworkSymbol;
};
export const findAccountLabel = ({
accountLabels,
accountDescriptor,
networkSymbol,
}: FindAccountLabelParams) =>
accountLabels.find(
it => it.accountDescriptor === accountDescriptor && it.networkSymbol === networkSymbol,
);
type SelectAccountLabelParams = {
state: WithLabelingState;
walletDescriptor: WalletDescriptor;
@@ -66,30 +51,24 @@ export const selectAccountLabel = ({
state,
walletDescriptor,
accountKey,
}: SelectAccountLabelParams) => {
}: SelectAccountLabelParams): string | null => {
const { accountDescriptor, networkSymbol } = parseAccountKey(accountKey);
const walletLabelState = state.labeling.walletsLabels[walletDescriptor];
if (walletLabelState === undefined) {
return undefined;
return null;
}
return findAccountLabel({
accountLabels: walletLabelState.accountLabels,
networkSymbol,
accountDescriptor,
});
return (
findAccountLabel({
accountLabels: walletLabelState.accountLabels,
networkSymbol,
accountDescriptor,
})?.label ?? null
);
};
type FindAddressLabelParams = {
addressLabels: AddressLabel[];
address: string;
};
export const findAddressLabel = ({ addressLabels, address }: FindAddressLabelParams) =>
addressLabels.find(it => it.address === address);
type SelectAddressLabelsParams = {
state: WithLabelingState;
deviceStaticSessionId: StaticSessionId;
@@ -107,17 +86,21 @@ export const selectAddressLabels = ({
type SelectAddressLabelParam = {
state: WithLabelingState;
deviceStaticSessionId: StaticSessionId;
address: string;
address: string | undefined;
};
export const selectAddressLabel = ({
state,
deviceStaticSessionId,
address,
}: SelectAddressLabelParam) => {
}: SelectAddressLabelParam): string | null => {
if (address === undefined) {
return null;
}
const addressLabels = selectAddressLabels({ state, deviceStaticSessionId });
return findAddressLabel({ address, addressLabels });
return findAddressLabel({ address, addressLabels })?.label ?? null;
};
export const selectOutputLabels = (
@@ -129,15 +112,6 @@ export const selectOutputLabels = (
return state.labeling.walletsLabels[walletDescriptor]?.outputLabels ?? [];
};
type FindOutputLabelParams = {
outputLabels: OutputLabel[];
txId: string;
outputIndex: number;
};
export const findOutputLabel = ({ outputLabels, txId, outputIndex }: FindOutputLabelParams) =>
outputLabels.find(it => it.txId === txId && it.outputIndex === outputIndex);
type SelectOutputLabelParams = {
state: WithLabelingState;
deviceStaticSessionId: StaticSessionId;
@@ -150,8 +124,8 @@ export const selectOutputLabel = ({
deviceStaticSessionId,
txId,
outputIndex,
}: SelectOutputLabelParams) => {
}: SelectOutputLabelParams): string | null => {
const outputLabels = selectOutputLabels(state, deviceStaticSessionId);
return findOutputLabel({ txId, outputIndex, outputLabels });
return findOutputLabel({ txId, outputIndex, outputLabels })?.label ?? null;
};

View File

@@ -0,0 +1,38 @@
import { NetworkSymbol } from '@suite-common/wallet-config';
import type { AccountDescriptor } from '@suite-common/wallet-types';
import { AccountLabel } from './evolu/accountLabels';
import { AddressLabel } from './evolu/addressLabels';
import { OutputLabel } from './evolu/outputLabels';
type FindAccountLabelParams = {
accountLabels: AccountLabel[];
accountDescriptor: AccountDescriptor;
networkSymbol: NetworkSymbol;
};
export const findAccountLabel = ({
accountLabels,
accountDescriptor,
networkSymbol,
}: FindAccountLabelParams) =>
accountLabels.find(
it => it.accountDescriptor === accountDescriptor && it.networkSymbol === networkSymbol,
);
type FindAddressLabelParams = {
addressLabels: AddressLabel[];
address: string;
};
export const findAddressLabel = ({ addressLabels, address }: FindAddressLabelParams) =>
addressLabels.find(it => it.address === address);
type FindOutputLabelParams = {
outputLabels: OutputLabel[];
txId: string;
outputIndex: number;
};
export const findOutputLabel = ({ outputLabels, txId, outputIndex }: FindOutputLabelParams) =>
outputLabels.find(it => it.txId === txId && it.outputIndex === outputIndex);

View File

@@ -78,11 +78,13 @@ export class LocalFirstStorageProvider {
let storage = this.storages.get(evoluKeys.ownerId);
if (storage === undefined) {
const relayUrl =
this.relayUrl === null || this.relayUrl.trim() === ''
? DEFAULT_LOCAL_FIRST_STORAGE_RELAY_URL
: this.relayUrl;
const evolu = createEvoluInstance({
relayUrl:
this.relayUrl === null || this.relayUrl.trim() === ''
? DEFAULT_LOCAL_FIRST_STORAGE_RELAY_URL
: this.relayUrl,
relayUrl,
evoluKeys,
evoluDeps: this.evoluDeps,
});

View File

@@ -1,13 +1,14 @@
import { TrezorDevice } from '@suite-common/suite-types';
import { WalletDescriptor } from '@suite-common/wallet-types';
import { asWalletDescriptor } from '@suite-common/wallet-types';
import { Device, StaticSessionId } from '@trezor/connect';
import { isNative } from '@trezor/env-utils';
export const parseDeviceStaticSessionId = (deviceStaticSessionId: StaticSessionId) => {
const [walletDescriptor] = deviceStaticSessionId.split('@');
const [walletDescriptor, deviceId] = deviceStaticSessionId.split('@');
return {
walletDescriptor: walletDescriptor as WalletDescriptor,
walletDescriptor: asWalletDescriptor(walletDescriptor),
deviceId,
};
};

View File

@@ -29,6 +29,7 @@
"@suite-native/forms": "workspace:*",
"@suite-native/icons": "workspace:*",
"@suite-native/intl": "workspace:*",
"@suite-native/labeling": "workspace:*",
"@suite-native/navigation": "workspace:*",
"@suite-native/staking": "workspace:*",
"@suite-native/toasts": "workspace:*",

View File

@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { AccountsRootState, selectFormattedAccountType } from '@suite-common/wallet-core';
import { Account, AccountKey } from '@suite-common/wallet-types';
import { parseDeviceStaticSessionId } from '@suite-common/wallet-utils';
import { Badge } from '@suite-native/atoms';
import {
BaseCurrencyAmountFormatter,
@@ -12,6 +13,7 @@ import {
} from '@suite-native/formatters';
import { CryptoIcon, CryptoIconWithNetwork } from '@suite-native/icons';
import { Translation } from '@suite-native/intl';
import { AccountLabel } from '@suite-native/labeling';
import { NativeStakingRootState, selectAccountHasStaking } from '@suite-native/staking';
import {
TokensRootState,
@@ -60,8 +62,6 @@ export const AccountsListItem = ({
isLast = false,
showDivider = false,
}: AccountListItemProps) => {
const { accountLabel } = account;
const formattedAccountType = useSelector((state: AccountsRootState) =>
selectFormattedAccountType(state, account.key),
);
@@ -77,6 +77,8 @@ export const AccountsListItem = ({
selectAccountFiatBalance(state, account.key, accountHasStaking),
);
const { walletDescriptor } = parseDeviceStaticSessionId(account.deviceState);
const handleOnPress = useCallback(() => {
onPress?.({
account,
@@ -111,7 +113,11 @@ export const AccountsListItem = ({
icon={icon}
title={
shouldShowAccountLabel ? (
accountLabel
<AccountLabel
walletDescriptor={walletDescriptor}
accountKey={account.key}
fallbackLabel={account.accountLabel}
/>
) : (
<NetworkDisplaySymbolNameFormatter value={account.symbol} />
)

View File

@@ -33,6 +33,7 @@
{ "path": "../forms" },
{ "path": "../icons" },
{ "path": "../intl" },
{ "path": "../labeling" },
{ "path": "../navigation" },
{ "path": "../staking" },
{ "path": "../toasts" },

View File

@@ -60,6 +60,7 @@
"@suite-native/discovery": "workspace:*",
"@suite-native/icons": "workspace:*",
"@suite-native/intl": "workspace:*",
"@suite-native/local-first-storage": "workspace:*",
"@suite-native/message-system": "workspace:*",
"@suite-native/module-accounts-import": "workspace:*",
"@suite-native/module-accounts-management": "workspace:*",
@@ -98,7 +99,7 @@
"abortcontroller-polyfill": "1.7.8",
"buffer": "^6.0.3",
"event-target-shim": "6.0.2",
"expo": "53.0.20",
"expo": "^53.0.22",
"expo-build-properties": "0.14.8",
"expo-camera": "16.1.11",
"expo-clipboard": "7.1.5",

View File

@@ -13,6 +13,7 @@ import {
import { walletConnectInitThunk } from '@suite-common/walletconnect';
import { initAnalyticsThunk } from '@suite-native/analytics';
import { FeatureFlag, selectIsFeatureFlagEnabled } from '@suite-native/feature-flags';
import { initNativeLocalFirstStorageThunk } from '@suite-native/local-first-storage';
import { setIsAppReady } from '@suite-native/state/src/appSlice';
const ACTION_PREFIX = '@suite-native/app';
@@ -58,5 +59,7 @@ export const postOnboardingInit = createThunk(
if (selectIsFeatureFlagEnabled(getState(), FeatureFlag.IsWalletConnectEnabled)) {
dispatch(walletConnectInitThunk());
}
dispatch(initNativeLocalFirstStorageThunk());
},
);

View File

@@ -45,6 +45,7 @@
{ "path": "../discovery" },
{ "path": "../icons" },
{ "path": "../intl" },
{ "path": "../local-first-storage" },
{ "path": "../message-system" },
{ "path": "../module-accounts-import" },
{

View File

@@ -1,4 +1,4 @@
import { Pressable } from 'react-native';
import { FlexStyle, Pressable } from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
@@ -19,6 +19,7 @@ type TextButtonProps = Omit<ButtonProps, 'colorScheme'> & {
isUnderlined?: boolean;
variant?: TextButtonVariant;
isBold?: boolean;
justifyContent?: FlexStyle['justifyContent'];
};
const variantToColorsMap = {
@@ -77,6 +78,7 @@ export const TextButton = ({
isDisabled = false,
isUnderlined = false,
isBold = false,
justifyContent,
...pressableProps
}: TextButtonProps) => {
const { applyStyle, utils } = useNativeStyles();
@@ -120,7 +122,7 @@ export const TextButton = ({
style={applyStyle(buttonContainerStyle)}
{...pressableProps}
>
<HStack alignItems="center">
<HStack alignItems="center" justifyContent={justifyContent}>
{viewLeft && (
<ButtonAccessoryView element={viewLeft} iconColor={iconColor} iconSize={size} />
)}

View File

@@ -16,6 +16,7 @@ export type LaunchArguments = {
preloadedState?: Record<string, unknown>;
isFirmwareUpdateEnabled?: boolean;
isLocalizationEnabled?: boolean;
isLocalFirstStorageEnabled?: boolean;
};
export const launchArguments = LaunchArguments.value<LaunchArguments>();

View File

@@ -23,6 +23,7 @@
"@suite-native/formatters": "workspace:*",
"@suite-native/icons": "workspace:*",
"@suite-native/intl": "workspace:*",
"@suite-native/labeling": "workspace:*",
"@suite-native/link": "workspace:*",
"@suite-native/navigation": "workspace:*",
"@trezor/styles": "workspace:*",

View File

@@ -14,6 +14,7 @@ import {
import { ACCESSIBILITY_FONTSIZE_MULTIPLIER, Box, HStack } from '@suite-native/atoms';
import { selectShouldFactoryResetBeVisible } from '@suite-native/device';
import { Translation, useTranslate } from '@suite-native/intl';
import { WalletLabel } from '@suite-native/labeling';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { NativeTypographyStyle } from '@trezor/theme';
@@ -100,7 +101,7 @@ export const DeviceItemContent = React.memo(
translate('deviceManager.defaultHeader');
// todo: only makes sense device is already authorized (has state)
const walletNameLabel = device?.useEmptyPassphrase ? (
const fallbackLabel = device?.useEmptyPassphrase ? (
<Translation id="deviceManager.wallet.standard" />
) : (
<Translation
@@ -137,7 +138,12 @@ export const DeviceItemContent = React.memo(
headerTextVariant={headerTextVariant}
isConnected={device.isConnected}
header={deviceHeader}
subHeader={walletNameLabel}
subHeader={
<WalletLabel
deviceStaticSessionId={deviceState?.staticSessionId}
fallbackLabel={fallbackLabel}
/>
}
isDeviceInBootloader={device.isDeviceInBootloaderMode}
isPortfolioTrackerDevice={isPortfolioTrackerDevice}
/>

View File

@@ -1,19 +1,18 @@
import { useSelector } from 'react-redux';
import { TrezorDevice } from '@suite-common/suite-types';
import { selectDeviceByState, selectSelectedDevice } from '@suite-common/wallet-core';
import { selectSelectedDevice } from '@suite-common/wallet-core';
import { selectDeviceTotalFiatBalanceByDeviceState } from '@suite-native/device';
import { WalletItemBase } from './WalletItemBase';
type WalletItemProps = {
onPress: () => void;
deviceState: NonNullable<TrezorDevice['state']>;
device: TrezorDevice;
isSelectable?: boolean;
};
export const WalletItem = ({ onPress, deviceState, isSelectable = true }: WalletItemProps) => {
const device = useSelector((state: any) => selectDeviceByState(state, deviceState));
export const WalletItem = ({ onPress, device, isSelectable = true }: WalletItemProps) => {
const selectedDevice = useSelector(selectSelectedDevice);
const baseCurrencyAmount = useSelector((state: any) =>
device?.state?.staticSessionId
@@ -40,7 +39,7 @@ export const WalletItem = ({ onPress, deviceState, isSelectable = true }: Wallet
onPress={onPress}
isSelectable={isSelectable}
isSelected={showAsSelected}
walletNumber={device.walletNumber}
device={device}
baseCurrencyAmount={baseCurrencyAmount}
/>
);

View File

@@ -1,10 +1,13 @@
import { Pressable } from 'react-native';
import { BaseCurrencyAmount } from '@suite-common/wallet-utils';
import { TrezorDevice } from '@suite-common/suite-types';
import { BaseCurrencyAmount, parseDeviceStaticSessionId } from '@suite-common/wallet-utils';
import { HStack, Radio, Text } from '@suite-native/atoms';
import { isDebugEnv } from '@suite-native/config';
import { BaseCurrencyAmountFormatter } from '@suite-native/formatters';
import { Icon } from '@suite-native/icons';
import { Translation } from '@suite-native/intl';
import { WalletLabel, useIsLabelingEnabled } from '@suite-native/labeling';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
type WalletItemBaseVariant = 'standard' | 'passphrase';
@@ -14,7 +17,7 @@ type WalletItemBaseProps = {
onPress: () => void;
isSelectable: boolean;
isSelected: boolean;
walletNumber?: number;
device?: TrezorDevice;
baseCurrencyAmount?: BaseCurrencyAmount;
};
@@ -53,21 +56,48 @@ const labelStyle = prepareNativeStyle(() => ({
flex: 1,
}));
const LocalFirstStorageDebug = ({ device }: { device?: TrezorDevice }) => {
const isLabelingEnabled = useIsLabelingEnabled();
if (!isDebugEnv() || !isLabelingEnabled || !device) {
return null;
}
const deviceStaticSessionId = device.state?.staticSessionId;
if (deviceStaticSessionId === undefined) {
return null;
}
const { walletDescriptor, deviceId } = parseDeviceStaticSessionId(deviceStaticSessionId);
const evoluDebug =
walletDescriptor.split('@')[0].slice(-8) +
' @ ' +
deviceId.slice(-8) +
' E: ' +
device.localFirstStorageSecret?.evoluKeys?.ownerId.slice(-8);
return <Text>{evoluDebug}</Text>;
};
export const WalletItemBase = ({
variant,
onPress,
isSelected,
isSelectable,
walletNumber,
device,
baseCurrencyAmount,
}: WalletItemBaseProps) => {
const { applyStyle } = useNativeStyles();
const isStandard = variant === 'standard';
const walletNameLabel = isStandard ? (
const fallbackLabel = isStandard ? (
<Translation id="deviceManager.wallet.standard" />
) : (
<Translation id="deviceManager.wallet.defaultPassphrase" values={{ index: walletNumber }} />
<Translation
id="deviceManager.wallet.defaultPassphrase"
values={{ index: device?.walletNumber }}
/>
);
return (
@@ -76,9 +106,14 @@ export const WalletItemBase = ({
<HStack alignItems="center" flex={1}>
<Icon name={isStandard ? 'wallet' : 'password'} size="mediumLarge" />
<Text variant="callout" numberOfLines={1} style={applyStyle(labelStyle)}>
{walletNameLabel}
<WalletLabel
deviceStaticSessionId={device?.state?.staticSessionId}
fallbackLabel={fallbackLabel}
/>
</Text>
<LocalFirstStorageDebug device={device} />
</HStack>
<HStack alignItems="center" spacing="sp12">
{baseCurrencyAmount && (
<BaseCurrencyAmountFormatter

View File

@@ -67,7 +67,7 @@ export const WalletList = ({ onSelectDevice }: WalletListProps) => {
return (
<WalletItem
key={`${device.path}-${device.state.staticSessionId}`}
deviceState={device.state}
device={device}
isSelectable={isSelectable}
onPress={() => onSelectDevice(device)}
/>

View File

@@ -18,6 +18,7 @@
{ "path": "../formatters" },
{ "path": "../icons" },
{ "path": "../intl" },
{ "path": "../labeling" },
{ "path": "../link" },
{ "path": "../navigation" },
{ "path": "../../packages/styles" },

View File

@@ -65,8 +65,8 @@ export const prepareDeviceMiddleware = createMiddlewareWithExtraDeps(
const deviceState = action.payload.device.state;
if (deviceState) {
const accounts = selectAccountsByDeviceState(getState(), deviceState);
dispatch(forgetAccountsThunk({ accountsToRemove: accounts }));
const accountsToRemove = selectAccountsByDeviceState(getState(), deviceState);
dispatch(forgetAccountsThunk({ accountsToRemove }));
}
}

7
suite-native/feature-flags/redux.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import { AsyncThunkAction } from '@reduxjs/toolkit';
declare module 'redux' {
export interface Dispatch {
<TThunk extends AsyncThunkAction<any, any, any>>(thunk: TThunk): ReturnType<TThunk>;
}
}

View File

@@ -23,6 +23,7 @@ describe('featureFlagsSlice', () => {
isConnectPopupEnabled_v2: true,
isDebugKeysAllowed: false,
isWalletConnectEnabled_v2: true,
isLocalFirstStorageEnabled: false,
isTradingBuyEnabled: false,
isTradingExchangeEnabled: false,
isTradingSellEnabled: false,
@@ -48,6 +49,7 @@ describe('featureFlagsSlice', () => {
isConnectPopupEnabled_v2: false,
isDebugKeysAllowed: false,
isWalletConnectEnabled_v2: false,
isLocalFirstStorageEnabled: false,
isTradingBuyEnabled: false,
isTradingExchangeEnabled: false,
isTradingSellEnabled: false,

View File

@@ -14,6 +14,7 @@ export const FeatureFlag = {
IsTradingExchangeEnabled: 'isTradingExchangeEnabled',
IsTradingSellEnabled: 'isTradingSellEnabled',
IsLocalizationEnabled: 'isLocalizationEnabled',
IsLocalFirstStorageEnabled: 'isLocalFirstStorageEnabled',
} as const;
export type FeatureFlag = (typeof FeatureFlag)[keyof typeof FeatureFlag];
@@ -47,6 +48,8 @@ export const featureFlagsInitialState: FeatureFlagsState = {
process.env.EXPO_PUBLIC_FF_IS_TRADING_SELL_ENABLED === 'true',
[FeatureFlag.IsLocalizationEnabled]:
process.env.EXPO_PUBLIC_FF_IS_LOCALIZATION_ENABLED === 'true',
[FeatureFlag.IsLocalFirstStorageEnabled]:
process.env.EXPO_PUBLIC_FF_IS_LOCAL_FIRST_STORAGE_ENABLED === 'true',
};
export const featureFlagsPersistedKeys: Array<keyof FeatureFlagsState> = [
@@ -59,6 +62,7 @@ export const featureFlagsPersistedKeys: Array<keyof FeatureFlagsState> = [
FeatureFlag.IsTradingBuyEnabled,
FeatureFlag.IsTradingExchangeEnabled,
FeatureFlag.IsTradingSellEnabled,
FeatureFlag.IsLocalFirstStorageEnabled,
FeatureFlag.IsLocalizationEnabled,
];

View File

@@ -5,5 +5,7 @@ import { FeatureFlag, toggleFeatureFlag } from './featureFlagsSlice';
export const useToggleFeatureFlag = (featureFlag: FeatureFlag): (() => void) => {
const dispatch = useDispatch();
return () => dispatch(toggleFeatureFlag({ featureFlag }));
return () => {
dispatch(toggleFeatureFlag({ featureFlag }));
};
};

View File

@@ -76,6 +76,10 @@ export const messages = {
cta: 'Download latest version',
},
},
labeling: {
label: 'Label',
addLabel: 'Add label',
},
moduleHome: {
graphIgnoredNetworks: {
sol: 'Solana and all related tokens are reflected in the balance, but not in the graph.',

View File

@@ -0,0 +1,25 @@
{
"name": "@suite-native/labeling",
"version": "1.0.0",
"private": true,
"license": "See LICENSE.md in repo root",
"sideEffects": false,
"main": "src/index",
"scripts": {
"depcheck": "yarn g:depcheck",
"type-check": "yarn g:tsc --build"
},
"dependencies": {
"@reduxjs/toolkit": "2.8.2",
"@suite-common/local-first-storage": "workspace:*",
"@suite-common/validators": "workspace:*",
"@suite-common/wallet-types": "workspace:*",
"@suite-native/atoms": "workspace:*",
"@suite-native/feature-flags": "workspace:*",
"@suite-native/forms": "workspace:*",
"@suite-native/intl": "workspace:*",
"@trezor/connect": "workspace:*",
"react": "19.0.0",
"react-redux": "9.2.0"
}
}

7
suite-native/labeling/redux.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import { AsyncThunkAction } from '@reduxjs/toolkit';
declare module 'redux' {
export interface Dispatch {
<TThunk extends AsyncThunkAction<any, any, any>>(thunk: TThunk): ReturnType<TThunk>;
}
}

View File

@@ -0,0 +1,28 @@
import { useSelector } from 'react-redux';
import { WithLabelingState, selectAccountLabel } from '@suite-common/local-first-storage';
import { AccountKey, WalletDescriptor } from '@suite-common/wallet-types';
import { Text } from '@suite-native/atoms';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type AccountLabelProps = {
walletDescriptor: WalletDescriptor;
accountKey: AccountKey;
/** @deprecated this shall be removed once Evolu labeling is rolled out and account.accountLabel data migrated */
fallbackLabel: string | undefined;
};
export const AccountLabel = ({
accountKey,
walletDescriptor,
fallbackLabel,
}: AccountLabelProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
const label = useSelector((state: WithLabelingState) =>
selectAccountLabel({ state, walletDescriptor, accountKey }),
);
return <Text>{!isLabelingEnabled || label === null ? fallbackLabel : label}</Text>;
};

View File

@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { useSelector } from 'react-redux';
import { WithLabelingState, selectAddressLabel } from '@suite-common/local-first-storage';
import { Text } from '@suite-native/atoms';
import type { StaticSessionId } from '@trezor/connect';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type AddressLabelEProps = {
address: string;
deviceStaticSessionId: StaticSessionId;
fallback: ReactNode;
};
export const AddressLabel = ({ address, deviceStaticSessionId, fallback }: AddressLabelEProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
const label = useSelector((state: WithLabelingState) =>
selectAddressLabel({
state,
address,
deviceStaticSessionId,
}),
);
if (!isLabelingEnabled || label === null) {
return fallback;
}
return <Text>{label}</Text>;
};

View File

@@ -0,0 +1,61 @@
import { useDispatch, useSelector } from 'react-redux';
import {
WithLabelingState,
selectAddressLabel,
updateAddressLabelThunk,
} from '@suite-common/local-first-storage';
import type { StaticSessionId } from '@trezor/connect';
import { EditableLabelLayout } from './EditableLabelLayout';
import { LabelEditForm } from './LabelEditForm';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type AddressLabelEditableProps = {
address: string;
deviceStaticSessionId: StaticSessionId;
};
export const AddressLabelEditable = ({
address,
deviceStaticSessionId,
}: AddressLabelEditableProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
const dispatch = useDispatch();
const label = useSelector((state: WithLabelingState) =>
selectAddressLabel({
state,
address,
deviceStaticSessionId,
}),
);
const onSubmit = (newLabel: string) => {
dispatch(
updateAddressLabelThunk({
deviceStaticSessionId,
address,
label: newLabel,
}),
);
};
if (!isLabelingEnabled) {
return null;
}
return (
<EditableLabelLayout label={label}>
{({ onClose }) => (
<LabelEditForm
label={label ?? ''}
onSubmit={newLabel => {
onSubmit(newLabel);
onClose();
}}
/>
)}
</EditableLabelLayout>
);
};

View File

@@ -0,0 +1,39 @@
import { ReactNode, Ref } from 'react';
import type { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import { BottomSheetModal, TextButton, useBottomSheetModal } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type EditableLabelLayoutParams = {
children: (params: { onClose: () => void; ref: Ref<BottomSheetModalMethods> }) => ReactNode;
label: string | null;
};
export const EditableLabelLayout = ({ children, label }: EditableLabelLayoutParams) => {
const { bottomSheetRef, openModal, closeModal } = useBottomSheetModal();
const isLabelingEnabled = useIsLabelingEnabled();
if (!isLabelingEnabled) {
return null;
}
return (
<>
<TextButton onPress={openModal} viewRight="pencil" testID="@labeling/addLabel">
{label ?? <Translation id="labeling.addLabel" />}
</TextButton>
<BottomSheetModal
ref={bottomSheetRef}
title={<Translation id="labeling.label" />}
onDismiss={closeModal}
isCloseDisplayed={false}
>
{children({ ref: bottomSheetRef, onClose: closeModal })}
</BottomSheetModal>
</>
);
};

View File

@@ -0,0 +1,48 @@
import { useRef } from 'react';
import { yup } from '@suite-common/validators';
import { Button, InputType, VStack } from '@suite-native/atoms';
import { Form, TextInputField, useForm } from '@suite-native/forms';
import { Translation } from '@suite-native/intl';
const labelValidationSchema = yup.object({
label: yup.string().required(),
});
export type FormValues = yup.InferType<typeof labelValidationSchema>;
type LabelEditFormParam = {
label: string | null;
onSubmit: (label: string) => void;
};
export const LabelEditForm = ({ label, onSubmit }: LabelEditFormParam) => {
const inputRef = useRef<InputType>(null);
const form = useForm<FormValues>({
validation: labelValidationSchema,
defaultValues: { label: label ?? '' },
});
const {
handleSubmit,
formState: { isValid },
} = form;
const onConfirm = handleSubmit((formValues: FormValues) => {
onSubmit(formValues.label);
});
return (
<VStack spacing="sp16">
<Form form={form}>
<VStack spacing="sp8">
<TextInputField ref={inputRef} name="label" asBottomSheetInput />
<Button onPress={onConfirm} size="large" isDisabled={!isValid}>
<Translation id="generic.buttons.confirm" />
</Button>
</VStack>
</Form>
</VStack>
);
};

View File

@@ -0,0 +1,30 @@
import { EditableLabelLayout } from './EditableLabelLayout';
import { LabelEditForm } from './LabelEditForm';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type SendFormLabelEditableProps = {
label: string | null;
onLabelChange: (label: string) => void;
};
export const SendFormLabelEditable = ({ onLabelChange, label }: SendFormLabelEditableProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
if (!isLabelingEnabled) {
return null;
}
return (
<EditableLabelLayout label={label}>
{({ onClose }) => (
<LabelEditForm
label={label}
onSubmit={newLabel => {
onLabelChange(newLabel);
onClose();
}}
/>
)}
</EditableLabelLayout>
);
};

View File

@@ -0,0 +1,32 @@
import { useSelector } from 'react-redux';
import { WithLabelingState, selectOutputLabel } from '@suite-common/local-first-storage';
import { Text } from '@suite-native/atoms';
import type { StaticSessionId } from '@trezor/connect';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type TransactionOutputLabelProps = {
txId: string;
outputIndex: number;
deviceStaticSessionId: StaticSessionId;
};
export const TransactionOutputLabel = ({
txId,
outputIndex,
deviceStaticSessionId,
}: TransactionOutputLabelProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
const label = useSelector((state: WithLabelingState) =>
selectOutputLabel({
state,
txId,
outputIndex,
deviceStaticSessionId,
}),
);
return isLabelingEnabled ? <Text>{label}</Text> : null;
};

View File

@@ -0,0 +1,61 @@
import { useDispatch, useSelector } from 'react-redux';
import {
WithLabelingState,
selectOutputLabel,
updateOutputLabelThunk,
} from '@suite-common/local-first-storage';
import type { StaticSessionId } from '@trezor/connect';
import { EditableLabelLayout } from './EditableLabelLayout';
import { LabelEditForm } from './LabelEditForm';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type TransactionOutputLabelEditableProps = {
txId: string;
outputIndex: number;
deviceStaticSessionId: StaticSessionId;
};
export const TransactionOutputLabelEditable = ({
txId,
outputIndex,
deviceStaticSessionId,
}: TransactionOutputLabelEditableProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
const dispatch = useDispatch();
const label = useSelector((state: WithLabelingState) =>
selectOutputLabel({
state,
txId,
outputIndex,
deviceStaticSessionId,
}),
);
if (!isLabelingEnabled) {
return null;
}
return (
<EditableLabelLayout label={label}>
{({ onClose }) => (
<LabelEditForm
label={label ?? ''}
onSubmit={value => {
dispatch(
updateOutputLabelThunk({
deviceStaticSessionId,
txId,
outputIndex,
label: value,
}),
);
onClose();
}}
/>
)}
</EditableLabelLayout>
);
};

View File

@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { useSelector } from 'react-redux';
import { WithLabelingState, selectWalletLabel } from '@suite-common/local-first-storage';
import { Text } from '@suite-native/atoms';
import type { StaticSessionId } from '@trezor/connect';
import { useIsLabelingEnabled } from './useIsLabelingEnabled';
type WalletLabelProps = {
deviceStaticSessionId: StaticSessionId | undefined;
fallbackLabel: ReactNode;
};
export const WalletLabel = ({ deviceStaticSessionId, fallbackLabel }: WalletLabelProps) => {
const isLabelingEnabled = useIsLabelingEnabled();
const label = useSelector((state: WithLabelingState) =>
selectWalletLabel({ state, deviceStaticSessionId }),
);
return <Text>{!isLabelingEnabled || label === null ? fallbackLabel : label}</Text>;
};

View File

@@ -0,0 +1,18 @@
import { useSelector } from 'react-redux';
import { selectIsPortfolioTrackerDevice } from '@suite-common/wallet-core';
import {
FeatureFlag,
FeatureFlagsRootState,
selectIsFeatureFlagEnabled,
} from '@suite-native/feature-flags';
export const useIsLabelingEnabled = () => {
const isFeatureFlagOn = useSelector((state: FeatureFlagsRootState) =>
selectIsFeatureFlagEnabled(state, FeatureFlag.IsLocalFirstStorageEnabled),
);
const isPortfolioTracker = useSelector(selectIsPortfolioTrackerDevice);
return isFeatureFlagOn && !isPortfolioTracker;
};

View File

@@ -0,0 +1,8 @@
export * from './components/AccountLabel';
export * from './components/AddressLabel';
export * from './components/AddressLabelEditable';
export * from './components/SendFormLabelEditable';
export * from './components/TransactionOutputLabel';
export * from './components/TransactionOutputLabelEditable';
export * from './components/WalletLabel';
export * from './components/useIsLabelingEnabled';

View File

@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "libDev" },
"references": [
{
"path": "../../suite-common/local-first-storage"
},
{
"path": "../../suite-common/validators"
},
{
"path": "../../suite-common/wallet-types"
},
{ "path": "../atoms" },
{ "path": "../feature-flags" },
{ "path": "../forms" },
{ "path": "../intl" },
{ "path": "../../packages/connect" }
]
}

View File

@@ -0,0 +1,4 @@
{
"ignore-patterns": ["libDev"],
"ignores": ["expo", "expo-sqlite"]
}

View File

@@ -0,0 +1,19 @@
{
"name": "@suite-native/local-first-storage",
"version": "1.0.0",
"private": true,
"license": "See LICENSE.md in repo root",
"sideEffects": false,
"type": "module",
"main": "src/index",
"scripts": {
"depcheck": "yarn g:depcheck",
"type-check": "yarn g:tsc --build"
},
"dependencies": {
"@evolu/react-native": "^12.0.1-preview.4",
"@suite-common/local-first-storage": "workspace:*",
"expo": "^53.0.22",
"expo-sqlite": "^15.2.14"
}
}

View File

@@ -0,0 +1,6 @@
import { evoluReactNativeDeps } from '@evolu/react-native/expo-sqlite';
import { initLocalFirstStorageThunkFactory } from '@suite-common/local-first-storage';
export const initNativeLocalFirstStorageThunk =
initLocalFirstStorageThunkFactory(evoluReactNativeDeps);

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "libDev" },
"references": [
{
"path": "../../suite-common/local-first-storage"
}
]
}

View File

@@ -1,10 +1,10 @@
import { useSelector } from 'react-redux';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core';
import { Account } from '@suite-common/wallet-types';
import { parseDeviceStaticSessionId } from '@suite-common/wallet-utils';
import { HStack, IconButton, Text } from '@suite-native/atoms';
import { CryptoIconWithNetwork } from '@suite-native/icons';
import { AccountLabel } from '@suite-native/labeling';
import {
AccountsStackParamList,
RootStackParamList,
@@ -15,8 +15,7 @@ import {
} from '@suite-native/navigation';
type AccountDetailScreenHeaderProps = {
accountLabel: string | null;
accountKey: string;
account: Account;
};
type AccountDetailNavigationProps = StackToStackCompositeNavigationProps<
@@ -25,32 +24,24 @@ type AccountDetailNavigationProps = StackToStackCompositeNavigationProps<
RootStackParamList
>;
const AccountDetailScreenHeaderContent = ({
accountLabel,
accountKey,
}: AccountDetailScreenHeaderProps) => {
const symbol = useSelector((state: AccountsRootState) =>
selectAccountNetworkSymbol(state, accountKey),
);
if (!symbol) {
return null;
}
const AccountDetailScreenHeaderContent = ({ account }: AccountDetailScreenHeaderProps) => {
const { walletDescriptor } = parseDeviceStaticSessionId(account.deviceState);
return (
<HStack alignItems="center">
<CryptoIconWithNetwork symbol={symbol} size="small" />
<CryptoIconWithNetwork symbol={account.symbol} size="small" />
<Text variant="highlight" adjustsFontSizeToFit numberOfLines={1}>
{accountLabel}
<AccountLabel
walletDescriptor={walletDescriptor}
accountKey={account.key}
fallbackLabel={account.accountLabel}
/>
</Text>
</HStack>
);
};
export const AccountDetailScreenHeader = ({
accountLabel,
accountKey,
}: AccountDetailScreenHeaderProps) => {
export const AccountDetailScreenHeader = ({ account }: AccountDetailScreenHeaderProps) => {
const navigation = useNavigation<AccountDetailNavigationProps>();
const navigateToInitialScreen = useNavigateToInitialScreen();
const route = useRoute<RouteProp<RootStackParamList, RootStackRoutes.AccountDetail>>();
@@ -58,18 +49,13 @@ export const AccountDetailScreenHeader = ({
const handleSettingsNavigation = () => {
navigation.navigate(RootStackRoutes.AccountSettings, {
accountKey,
accountKey: account.key,
});
};
return (
<ScreenHeader
customContent={
<AccountDetailScreenHeaderContent
accountLabel={accountLabel}
accountKey={accountKey}
/>
}
customContent={<AccountDetailScreenHeaderContent account={account} />}
rightIcon={
<IconButton
colorScheme="tertiaryElevation0"

View File

@@ -1,12 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
AccountsRootState,
selectAccountByKey,
selectAccountLabel,
} from '@suite-common/wallet-core';
import { TokenAddress } from '@suite-common/wallet-types';
import { Account, TokenAddress } from '@suite-common/wallet-types';
import { EventType, analytics } from '@suite-native/analytics';
import { Screen } from '@suite-native/navigation';
import { TokensRootState, selectAccountTokenInfo } from '@suite-native/tokens';
@@ -17,23 +12,16 @@ import { TokenAccountDetailScreenHeader } from '../components/TokenAccountDetail
import { TransactionListHeader } from '../components/TransactionListHeader';
type AccountDetailContentScreenProps = {
accountKey: string;
account: Account;
tokenContract?: TokenAddress;
};
export const AccountDetailContentScreen = ({
accountKey,
account,
tokenContract,
}: AccountDetailContentScreenProps) => {
const account = useSelector((state: AccountsRootState) =>
selectAccountByKey(state, accountKey),
);
const accountLabel = useSelector((state: AccountsRootState) =>
selectAccountLabel(state, accountKey),
);
const token = useSelector((state: TokensRootState) =>
selectAccountTokenInfo(state, accountKey, tokenContract),
selectAccountTokenInfo(state, account.key, tokenContract),
);
useEffect(() => {
@@ -50,8 +38,8 @@ export const AccountDetailContentScreen = ({
}, [account, token?.symbol, token?.contract]);
const listHeaderComponent = useMemo(
() => <TransactionListHeader accountKey={accountKey} tokenContract={tokenContract} />,
[accountKey, tokenContract],
() => <TransactionListHeader accountKey={account.key} tokenContract={tokenContract} />,
[account.key, tokenContract],
);
return (
@@ -60,19 +48,16 @@ export const AccountDetailContentScreen = ({
tokenContract ? (
<TokenAccountDetailScreenHeader
tokenContract={tokenContract}
accountKey={accountKey}
accountKey={account.key}
/>
) : (
<AccountDetailScreenHeader
accountLabel={accountLabel}
accountKey={accountKey}
/>
<AccountDetailScreenHeader account={account} />
)
}
noHorizontalPadding
>
<TransactionList
accountKey={accountKey}
accountKey={account.key}
tokenContract={tokenContract}
listHeaderComponent={listHeaderComponent}
/>

View File

@@ -6,6 +6,7 @@ import { RouteProp, useRoute } from '@react-navigation/native';
import {
AccountsRootState,
DeviceRootState,
selectAccountByKey,
selectDeviceAccountKeyForNetworkSymbolAndAccountTypeWithIndex,
} from '@suite-common/wallet-core';
import { RootStackParamList, RootStackRoutes } from '@suite-native/navigation';
@@ -34,8 +35,12 @@ export const AccountDetailScreen = memo(() => {
const accountKey = routeAccountKey ?? foundAccountKey;
return accountKey ? (
<AccountDetailContentScreen accountKey={accountKey} tokenContract={tokenContract} />
const account = useSelector((state: AccountsRootState) =>
selectAccountByKey(state, accountKey),
);
return account ? (
<AccountDetailContentScreen account={account} tokenContract={tokenContract} />
) : (
<AccountDetailLoadingScreen />
);

View File

@@ -36,7 +36,7 @@
"@trezor/styles": "workspace:*",
"@trezor/theme": "workspace:*",
"@trezor/urls": "workspace:*",
"expo": "53.0.20",
"expo": "^53.0.22",
"react": "19.0.0",
"react-native": "0.79.3",
"react-redux": "9.2.0"

View File

@@ -1,10 +1,16 @@
import { useDispatch, useSelector } from 'react-redux';
import { disposeAllLocalFirstStorageThunk } from '@suite-common/local-first-storage';
import { Box, Card, CheckBox, Text, VStack } from '@suite-native/atoms';
import {
FeatureFlag as FeatureFlagEnum,
FeatureFlagsRootState,
featureFlagsInitialState,
selectIsFeatureFlagEnabled,
useFeatureFlag,
useToggleFeatureFlag,
} from '@suite-native/feature-flags';
import { initNativeLocalFirstStorageThunk } from '@suite-native/local-first-storage';
const featureFlagsTitleMap = {
[FeatureFlagEnum.IsDeviceConnectEnabled]: 'Connect device',
@@ -18,16 +24,34 @@ const featureFlagsTitleMap = {
[FeatureFlagEnum.IsTradingExchangeEnabled]: '💰 Trading Swap',
[FeatureFlagEnum.IsTradingSellEnabled]: '💰 Trading Sell',
[FeatureFlagEnum.IsLocalizationEnabled]: '🌍 Localization',
[FeatureFlagEnum.IsLocalFirstStorageEnabled]: 'Local First Storage (Labels)',
} as const satisfies Record<FeatureFlagEnum, string>;
const FeatureFlag = ({ featureFlag }: { featureFlag: FeatureFlagEnum }) => {
const dispatch = useDispatch();
const value = useFeatureFlag(featureFlag);
const toggleFeatureFlag = useToggleFeatureFlag(featureFlag);
const originalIsLocalFirstStorageEnabled = useSelector((state: FeatureFlagsRootState) =>
selectIsFeatureFlagEnabled(state, FeatureFlagEnum.IsLocalFirstStorageEnabled),
);
const onChange = () => {
if (featureFlag === FeatureFlagEnum.IsLocalFirstStorageEnabled) {
if (!originalIsLocalFirstStorageEnabled) {
dispatch(initNativeLocalFirstStorageThunk());
} else {
dispatch(disposeAllLocalFirstStorageThunk());
}
}
toggleFeatureFlag();
};
return (
<Box flexDirection="row" justifyContent="space-between">
<Text>{`${featureFlagsTitleMap[featureFlag]} [${featureFlagsInitialState[featureFlag]}]`}</Text>
<CheckBox isChecked={value} onChange={toggleFeatureFlag} />
<CheckBox isChecked={value} onChange={onChange} />
</Box>
);
};

View File

@@ -15,6 +15,7 @@ import { Button, HStack, Text, VStack } from '@suite-native/atoms';
import { isDebugEnv } from '@suite-native/config';
import { TextInputField, useFormContext } from '@suite-native/forms';
import { Translation } from '@suite-native/intl';
import { SendFormLabelEditable } from '@suite-native/labeling';
import { QrCodeBottomSheetIcon } from './QrCodeBottomSheetIcon';
import { useAddressValidationAlerts } from '../hooks/useAddressValidationAlerts/useAddressValidationAlerts';
@@ -28,7 +29,8 @@ type AddressInputProps = {
};
export const AddressInput = ({ index, accountKey }: AddressInputProps) => {
const addressFieldName = getOutputFieldName(index, 'address');
const { setValue } = useFormContext<SendOutputsFormValues>();
const utxoLabelFieldName = getOutputFieldName(index, 'label');
const { setValue, watch } = useFormContext<SendOutputsFormValues>();
const symbol = useSelector((state: AccountsRootState) =>
selectAccountNetworkSymbol(state, accountKey),
@@ -62,6 +64,8 @@ export const AddressInput = ({ index, accountKey }: AddressInputProps) => {
});
};
const utxoLabel = watch(utxoLabelFieldName);
return (
<VStack spacing="sp12">
<HStack flex={1} justifyContent="space-between" alignItems="center">
@@ -73,6 +77,12 @@ export const AddressInput = ({ index, accountKey }: AddressInputProps) => {
DEV: self address
</Button>
)}
<SendFormLabelEditable
label={utxoLabel ?? null}
onLabelChange={newUtxoLabel => {
setValue(utxoLabelFieldName, newUtxoLabel, { shouldValidate: true });
}}
/>
</HStack>
<TextInputField
multiline

View File

@@ -22,12 +22,14 @@ import {
CryptoAmountFormatter,
} from '@suite-native/formatters';
import { Translation } from '@suite-native/intl';
import { AddressLabel, TransactionOutputLabel } from '@suite-native/labeling';
import {
RootStackParamList,
RootStackRoutes,
StackNavigationProps,
} from '@suite-native/navigation';
import { Utxo } from '@trezor/blockchain-link-types';
import type { StaticSessionId } from '@trezor/connect';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
const accountAddressFormatterStyle = prepareNativeStyle(() => ({
@@ -40,6 +42,7 @@ const cardStyle = prepareNativeStyle(utils => ({
export type Props = {
utxo: Utxo;
deviceStaticSessionId: StaticSessionId;
onToggle: (utxo: Utxo) => void;
accountKey: AccountKey;
symbol: NetworkSymbol;
@@ -51,7 +54,14 @@ type TransactionDetailNavigation = StackNavigationProps<
RootStackRoutes.TransactionDetail
>;
export const UtxoCard = ({ utxo, onToggle, accountKey, symbol, isSelected = false }: Props) => {
export const UtxoCard = ({
utxo,
onToggle,
deviceStaticSessionId,
accountKey,
symbol,
isSelected = false,
}: Props) => {
const { DateFormatter } = useFormatters();
const { applyStyle } = useNativeStyles();
const navigation = useNavigation<TransactionDetailNavigation>();
@@ -119,12 +129,25 @@ export const UtxoCard = ({ utxo, onToggle, accountKey, symbol, isSelected = fals
)}
</HStack>
<AccountAddressFormatter
style={applyStyle(accountAddressFormatterStyle)}
value={utxo.address}
variant="hint"
color="textSubdued"
/>
<HStack>
<AddressLabel
address={utxo.address}
deviceStaticSessionId={deviceStaticSessionId}
fallback={
<AccountAddressFormatter
style={applyStyle(accountAddressFormatterStyle)}
value={utxo.address}
variant="hint"
color="textSubdued"
/>
}
/>
<TransactionOutputLabel
txId={utxo.txid}
outputIndex={utxo.vout}
deviceStaticSessionId={deviceStaticSessionId}
/>
</HStack>
</VStack>
<CheckBox isChecked={isSelected} onChange={handleToggle} />
</HStack>

View File

@@ -6,6 +6,7 @@ import { NetworkSymbol } from '@suite-common/wallet-config';
import { isSameUtxo } from '@suite-common/wallet-utils';
import { Box } from '@suite-native/atoms';
import { Utxo } from '@trezor/blockchain-link-types';
import { StaticSessionId } from '@trezor/connect';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { UtxoCard } from './UtxoCard';
@@ -17,6 +18,7 @@ const spacerStyle = prepareNativeStyle(utils => ({
}));
type UtxoListProps = {
deviceStaticSessionId: StaticSessionId;
accountKey: string;
utxos: Utxo[];
selectedUtxos: Utxo[];
@@ -25,8 +27,9 @@ type UtxoListProps = {
};
export const UtxoList = ({
utxos,
deviceStaticSessionId,
accountKey,
utxos,
selectedUtxos,
onUtxoToggle,
symbol,
@@ -46,9 +49,10 @@ export const UtxoList = ({
accountKey={accountKey}
utxo={item}
symbol={symbol}
deviceStaticSessionId={deviceStaticSessionId}
/>
),
[accountKey, onUtxoToggle, symbol, isSelected],
[accountKey, onUtxoToggle, symbol, isSelected, deviceStaticSessionId],
);
const rowSeparator = useCallback(() => <Box style={applyStyle(spacerStyle)} />, [applyStyle]);

View File

@@ -2,23 +2,18 @@ import { useEffect, useState } from 'react';
import Animated, { SlideInDown } from 'react-native-reanimated';
import { useDispatch, useSelector } from 'react-redux';
import { G } from '@mobily/ts-belt';
import { CommonActions, useNavigation } from '@react-navigation/native';
import { isFulfilled } from '@reduxjs/toolkit';
import { useAtomValue } from 'jotai';
import {
AccountsRootState,
SendRootState,
TransactionsRootState,
pushSendFormTransactionThunk,
selectAccountByKey,
selectSendFormDraftByKey,
selectTransactionByAccountKeyAndTxid,
} from '@suite-common/wallet-core';
import { AccountKey, TokenAddress } from '@suite-common/wallet-types';
import { useAlert } from '@suite-native/alerts';
import { EventType, analytics } from '@suite-native/analytics';
import { Button, Card } from '@suite-native/atoms';
import { Translation, useTranslate } from '@suite-native/intl';
import {
@@ -29,10 +24,9 @@ import {
SendStackRoutes,
StackToStackCompositeNavigationProps,
} from '@suite-native/navigation';
import { TokensRootState, selectAccountTokenSymbol } from '@suite-native/tokens';
import { wasAppLeftDuringReviewAtom } from '../atoms/wasAppLeftDuringReviewAtom';
import { cleanupSendFormThunk } from '../sendFormThunks';
import { cleanupSendFormThunk, sendTransactionThunk } from '../sendFormThunks';
import { SignSuccessMessage } from './SignSuccessMessage';
import { useUtxoSelection } from '../hooks/useUtxoSelection';
@@ -86,13 +80,12 @@ const navigateOutOfSendFlowAction = ({
});
};
export const OutputsReviewFooter = ({
accountKey,
tokenContract,
}: {
type OutputsReviewFooterParams = {
accountKey: AccountKey;
tokenContract?: TokenAddress;
}) => {
};
export const OutputsReviewFooter = ({ accountKey, tokenContract }: OutputsReviewFooterParams) => {
const [txid, setTxid] = useState<string>('');
const dispatch = useDispatch();
const navigation = useNavigation<NavigationProps>();
@@ -110,14 +103,6 @@ export const OutputsReviewFooter = ({
selectAccountByKey(state, accountKey),
);
const tokenSymbol = useSelector((state: TokensRootState) =>
selectAccountTokenSymbol(state, accountKey, tokenContract),
);
const formValues = useSelector((state: SendRootState) =>
selectSendFormDraftByKey(state, accountKey, tokenContract),
);
useEffect(() => {
// Navigate to transaction detail screen only at the moment when the transaction was already processed by backend and we have all its data.
if (isTransactionProcessedByBackend) {
@@ -143,29 +128,15 @@ export const OutputsReviewFooter = ({
setIsSendInProgress(true);
const sendResponse = await dispatch(
pushSendFormTransactionThunk({
sendTransactionThunk({
selectedAccount: account,
wasAppLeftDuringReview,
}),
);
if (isFulfilled(sendResponse)) {
const { txid: sentTxid } = sendResponse.payload.payload;
if (formValues) {
analytics.report({
type: EventType.SendTransactionDispatched,
payload: {
symbol: account.symbol,
tokenAddresses: tokenContract ? [tokenContract] : undefined,
tokenSymbols: tokenSymbol ? [tokenSymbol] : undefined,
outputsCount: formValues.outputs.length,
selectedFee: formValues.selectedFee ?? 'normal',
hasDestinationTag: G.isNotNullable(formValues.destinationTag),
wasAppLeftDuringReview,
},
});
}
setTxid(sentTxid);
if (account.networkType === 'bitcoin') setSelectedUtxos([]); // clear selected UTXOs after sending the transaction

View File

@@ -0,0 +1 @@
export const SEND_MODULE_PREFIX = '@suite-native/send';

View File

@@ -123,6 +123,7 @@ export const SendUtxoScreen = ({
{filteredUtxos.length > 0 ? (
<UtxoList
deviceStaticSessionId={account.deviceState}
utxos={filteredUtxos}
selectedUtxos={tempSelectedUtxos}
onUtxoToggle={handleUtxoSelect}

View File

@@ -0,0 +1,61 @@
import { updateOutputLabelThunk } from '@suite-common/local-first-storage';
import { createThunk } from '@suite-common/redux-utils';
import { selectPrecomposedSendForm, selectSendPrecomposedTx } from '@suite-common/wallet-core';
import { Account } from '@suite-common/wallet-types';
import { isCardanoTx } from '@suite-common/wallet-utils';
import { SEND_MODULE_PREFIX } from './constants';
type SendFormAddLabelingThunkParams = {
selectedAccount: Account;
txId: string;
};
// Todo: This code below is kinda copy-paste from `applySendFormMetadataLabelsThunk` in Desktop.
// However, desktop code is polluted by Legacy Labeling (Metadata) so it cannot be easily reused.
// After we get rid of old Labeling, this shall be unified and move to the wallet-core.
export const sendFormAddLabelingThunk = createThunk<void, SendFormAddLabelingThunkParams, void>(
`${SEND_MODULE_PREFIX}/sendTransactionThunk`,
async ({ selectedAccount, txId }, { getState, dispatch }) => {
const precomposedTransaction = selectSendPrecomposedTx(getState());
if (precomposedTransaction) {
const precomposedForm = selectPrecomposedSendForm(getState());
const outputsPermutation = isCardanoTx(selectedAccount, precomposedTransaction)
? precomposedTransaction?.outputs.map((_o, i) => i) // cardano preserves order of outputs
: precomposedTransaction?.outputsPermutation;
const transactionUtxoLabels = (precomposedForm?.outputs ?? []).reduce<
{
outputIndex: number;
value: string;
}[]
>((acc, formOutput, index) => {
const { label } = formOutput;
if (label) {
// final ordering of outputs differs from order in send form
// outputsPermutation contains mapping from @trezor/utxo-lib outputs to send form outputs
// mapping goes like this: Array<@trezor/utxo-lib index : send form index>
const outputIndex = outputsPermutation.findIndex(p => p === index);
acc.push({ outputIndex, value: label });
}
return acc;
}, []);
for (const label of transactionUtxoLabels) {
await dispatch(
updateOutputLabelThunk({
deviceStaticSessionId: selectedAccount.deviceState,
txId,
outputIndex: label.outputIndex,
label: label.value,
}),
);
}
}
},
);

View File

@@ -1,14 +1,16 @@
import { D, pipe } from '@mobily/ts-belt';
import { D, G, pipe } from '@mobily/ts-belt';
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
import { createThunk } from '@suite-common/redux-utils';
import { getNetwork } from '@suite-common/wallet-config';
import {
PushTransactionError,
SignTransactionError,
SignTransactionTimeoutError,
composeSendFormTransactionFeeLevelsThunk,
deviceActions,
enhancePrecomposedTransactionThunk,
pushSendFormTransactionThunk,
selectAccountByKey,
selectConvertedNetworkFeeInfo,
selectSelectedDevice,
@@ -18,6 +20,7 @@ import {
signTransactionThunk,
} from '@suite-common/wallet-core';
import {
Account,
AccountKey,
FormState,
GeneralPrecomposedTransactionFinal,
@@ -25,15 +28,19 @@ import {
isFinalPrecomposedTransaction,
} from '@suite-common/wallet-types';
import { hasNetworkFeatures } from '@suite-common/wallet-utils';
import { EventType, analytics } from '@suite-native/analytics';
import { requestPrioritizedDeviceAccess } from '@suite-native/device-mutex';
import { selectAccountTokenSymbol } from '@suite-native/tokens';
import {
FeeLevelsMaxAmount,
NativeSupportedFeeLevel,
storeFeeLevels,
} from '@suite-native/transaction-management';
import { BlockbookTransaction } from '@trezor/blockchain-link-types';
import { Success } from '@trezor/connect';
const SEND_MODULE_PREFIX = '@suite-native/send';
import { SEND_MODULE_PREFIX } from './constants';
import { sendFormAddLabelingThunk } from './sendFormAddLabelingThunk';
export const signTransactionNativeThunk = createThunk<
BlockbookTransaction | undefined,
@@ -272,3 +279,66 @@ export const calculateCustomFeeLevelThunk = createThunk(
}
},
);
type SendTransactionThunkParams = {
selectedAccount: Account;
wasAppLeftDuringReview: boolean;
tokenContract?: TokenAddress;
};
export const sendTransactionThunk = createThunk<
Success<{ txid: string }>,
SendTransactionThunkParams,
{ rejectValue: PushTransactionError }
>(
`${SEND_MODULE_PREFIX}/sendTransactionThunk`,
async (
{ selectedAccount, wasAppLeftDuringReview, tokenContract },
{ dispatch, getState, rejectWithValue, fulfillWithValue },
) => {
const sendResponse = await dispatch(pushSendFormTransactionThunk({ selectedAccount }));
if (sendResponse.payload === undefined) {
return rejectWithValue({
error: 'push-transaction-failed',
metadata: { success: false, payload: { error: 'Payload is undefined.' } },
});
}
if (!('success' in sendResponse.payload)) {
return rejectWithValue(sendResponse.payload);
}
const formValues = selectSendFormDraftByKey(getState(), selectedAccount.key, tokenContract);
if (formValues !== null) {
await dispatch(
sendFormAddLabelingThunk({
txId: sendResponse.payload.payload.txid,
selectedAccount,
}),
);
const tokenSymbol = selectAccountTokenSymbol(
getState(),
selectedAccount.key,
tokenContract,
);
analytics.report({
type: EventType.SendTransactionDispatched,
payload: {
symbol: selectedAccount.symbol,
tokenAddresses: tokenContract ? [tokenContract] : undefined,
tokenSymbols: tokenSymbol ? [tokenSymbol] : undefined,
outputsCount: formValues.outputs.length,
selectedFee: formValues.selectedFee ?? 'normal',
hasDestinationTag: G.isNotNullable(formValues.destinationTag),
wasAppLeftDuringReview,
},
});
}
return fulfillWithValue(sendResponse.payload);
},
);

View File

@@ -3,7 +3,7 @@ import { G } from '@mobily/ts-belt';
import { formInputsMaxLength, yup } from '@suite-common/validators';
import { type NetworkSymbol, getNetworkType } from '@suite-common/wallet-config';
import { U_INT_32 } from '@suite-common/wallet-constants';
import { FeeInfo } from '@suite-common/wallet-types';
import { FeeInfo, Output } from '@suite-common/wallet-types';
import {
formatNetworkAmount,
isAddressDeprecated,
@@ -104,125 +104,120 @@ const hasEnoughBalanceForFees = (context?: SendFormFormContext) => {
return amountBigNumber.gt(networkFeeInfo.minFee);
};
export const sendOutputsFormValidationSchema = yup.object({
outputs: yup
.array(
yup.object({
address: yup
.string()
.required()
.test(
'is-invalid-address',
'The address format is incorrect.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
if (!value || !context) {
return false;
}
const { symbol, isTaprootAvailable } = context;
const outputSchema = yup.object({
address: yup
.string()
.required()
.test(
'is-invalid-address',
'The address format is incorrect.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
if (!value || !context) {
return false;
}
const { symbol, isTaprootAvailable } = context;
if (!symbol) return false;
if (!symbol) return false;
const isTaprootValid =
isTaprootAvailable || !isTaprootAddress(value, symbol);
const isTaprootValid = isTaprootAvailable || !isTaprootAddress(value, symbol);
return (
isAddressValid(value, symbol) &&
!isAddressDeprecated(value, symbol) &&
!isBech32AddressUppercase(value) && // bech32 addresses are valid as uppercase but are not accepted by Trezor
isTaprootValid // bech32m/Taproot addresses are valid but may not be supported by older FW
);
},
)
.test(
'ripple-is-sending-to-self',
'Can`t send to myself.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
const { symbol, accountDescriptor } = context!;
if (!symbol || !accountDescriptor) return true;
if (getNetworkType(symbol) !== 'ripple') return true;
return value !== accountDescriptor;
},
),
amount: yup
.string()
.required('Amount is required.')
.matches(/^\d*\.?\d+$/, 'Invalid decimal value.')
.test(
'is-dust-amount',
'The value is lower than the dust limit.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) =>
!isAmountDust(value, context),
)
.test(
'ripple-higher-than-reserve',
'Amount is above the required unspendable reserve (1 XRP)',
function (
value,
{ options: { context } }: yup.TestContext<SendFormFormContext>,
) {
const { symbol, availableBalance, feeLevelsMaxAmount } = context!;
if (!availableBalance || !symbol || getNetworkType(symbol) !== 'ripple')
return true;
const amountBigNumber = new BigNumber(value);
if (
feeLevelsMaxAmount?.normal &&
amountBigNumber.gt(
formatNetworkAmount(
// availableBalance = balance - reserve
availableBalance,
symbol,
),
)
) {
return false;
}
return true;
},
)
.test(
'has-enough-balance-for-fees',
`Insufficient balance to cover the transaction fees.`,
function (
_,
{ options: { context } }: yup.TestContext<SendFormFormContext>,
) {
return hasEnoughBalanceForFees(context);
},
)
.test(
'is-higher-than-balance',
'You dont have enough balance to send this amount.',
function (
value,
{ options: { context } }: yup.TestContext<SendFormFormContext>,
) {
const isSendMaxEnabled = G.isNotNullable(
this.from?.[1]?.value.setMaxOutputId,
);
return !isAmountHigherThanBalance(value, isSendMaxEnabled, context);
},
)
.test(
'too-many-decimals',
'Too many decimals.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
const { decimals = 8 } = context!;
return isDecimalsValid(value, decimals);
},
),
fiat: yup.string(),
token: yup.string().required().nullable(),
}),
return (
isAddressValid(value, symbol) &&
!isAddressDeprecated(value, symbol) &&
!isBech32AddressUppercase(value) && // bech32 addresses are valid as uppercase but are not accepted by Trezor
isTaprootValid // bech32m/Taproot addresses are valid but may not be supported by older FW
);
},
)
.required(),
.test(
'ripple-is-sending-to-self',
'Can`t send to myself.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
const { symbol, accountDescriptor } = context!;
if (!symbol || !accountDescriptor) return true;
if (getNetworkType(symbol) !== 'ripple') return true;
return value !== accountDescriptor;
},
),
amount: yup
.string()
.required('Amount is required.')
.matches(/^\d*\.?\d+$/, 'Invalid decimal value.')
.test(
'is-dust-amount',
'The value is lower than the dust limit.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) =>
!isAmountDust(value, context),
)
.test(
'ripple-higher-than-reserve',
'Amount is above the required unspendable reserve (1 XRP)',
function (value, { options: { context } }: yup.TestContext<SendFormFormContext>) {
const { symbol, availableBalance, feeLevelsMaxAmount } = context!;
if (!availableBalance || !symbol || getNetworkType(symbol) !== 'ripple')
return true;
const amountBigNumber = new BigNumber(value);
if (
feeLevelsMaxAmount?.normal &&
amountBigNumber.gt(
formatNetworkAmount(
// availableBalance = balance - reserve
availableBalance,
symbol,
),
)
) {
return false;
}
return true;
},
)
.test(
'has-enough-balance-for-fees',
`Insufficient balance to cover the transaction fees.`,
function (_, { options: { context } }: yup.TestContext<SendFormFormContext>) {
return hasEnoughBalanceForFees(context);
},
)
.test(
'is-higher-than-balance',
'You dont have enough balance to send this amount.',
function (value, { options: { context } }: yup.TestContext<SendFormFormContext>) {
const isSendMaxEnabled = G.isNotNullable(this.from?.[1]?.value.setMaxOutputId);
return !isAmountHigherThanBalance(value, isSendMaxEnabled, context);
},
)
.test(
'too-many-decimals',
'Too many decimals.',
(value, { options: { context } }: yup.TestContext<SendFormFormContext>) => {
const { decimals = 8 } = context!;
return isDecimalsValid(value, decimals);
},
),
fiat: yup.string(),
token: yup.string().required().nullable(),
label: yup.string(),
});
export type OutputsFormValues = yup.InferType<typeof outputSchema>;
// Must correspond with `suite-common/wallet-types/src/transaction.ts:Output` type.
// This hacky code is here to somehow enforce it.
((_: Omit<Output, 'type' | 'currency' | 'fiat'> & { fiat?: string }) => {})(
{} as unknown as OutputsFormValues,
);
export const sendOutputsFormValidationSchema = yup.object({
outputs: yup.array(outputSchema).required(),
isDestinationTagEnabled: yup.boolean(),
destinationTag: yup
.string()

View File

@@ -20,9 +20,10 @@ export const constructFormDraft = ({
feeLevel?: Pick<FeeLevel, 'label' | 'feePerUnit' | 'feeLimit'>;
selectedUtxos?: Utxo[];
}): FormState => ({
outputs: outputs.map(({ address, amount, fiat = '' }) => ({
outputs: outputs.map(({ address, amount, label, fiat = '' }) => ({
address,
amount,
label,
type: 'payment',
token: tokenContract ?? null,
fiat,

View File

@@ -1,3 +1,6 @@
import { useSelector } from 'react-redux';
import { WithLabelingState, selectAddressLabel } from '@suite-common/local-first-storage';
import { Text } from '@suite-native/atoms';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
@@ -22,6 +25,14 @@ export const AccountListAddressItem = ({
const { applyStyle } = useNativeStyles();
const { address } = receiveAccount;
const addressLabel = useSelector((state: WithLabelingState) =>
selectAddressLabel({
state,
address: address?.address,
deviceStaticSessionId: receiveAccount.account.deviceState,
}),
);
if (!address) {
return null;
}
@@ -29,7 +40,7 @@ export const AccountListAddressItem = ({
return (
<AccountListBaseItem
receiveAccount={receiveAccount}
label={<AccountAddress address={address.address} form="full" />}
label={<AccountAddress address={addressLabel ?? address.address} form="full" />}
isAddressDetail={true}
info={
<Text variant="hint" style={applyStyle(labelTextStyle)}>

View File

@@ -1,6 +1,5 @@
import { Account } from '@suite-common/wallet-types';
import { fireEvent, renderWithStoreProviderAsync } from '@suite-native/test-utils';
import { Address } from '@trezor/blockchain-link-types';
import { ReceiveAccount } from '../../../../types/general';
import { AccountListAddressItem } from '../AccountListAddressItem';
@@ -14,7 +13,40 @@ jest.mock('@suite-common/wallet-core', () => {
};
});
describe('AccountListAddressItem', () => {
const createAccount = (
values: Pick<Account, 'key' | 'symbol' | 'accountLabel' | 'availableBalance'>,
): Account => ({
deviceState: 'a@b:1',
index: 0,
path: `m/0'/0'/0'`,
descriptor: '',
accountType: 'normal',
empty: false,
visible: false,
balance: '',
formattedBalance: '',
tokens: undefined,
utxo: undefined,
history: {
total: 0,
tokens: undefined,
unconfirmed: 0,
transactions: undefined,
txids: undefined,
addrTxCount: undefined,
},
metadata: { key: '' },
ts: 0,
networkType: 'ripple',
marker: undefined,
stellarCursor: undefined,
page: undefined,
backendType: 'blockbook',
misc: { sequence: 0, reserve: '' },
...values,
});
describe(AccountListAddressItem.name, () => {
const onPressMock = jest.fn();
const renderAccountListAddressItem = (receiveAccount: ReceiveAccount) =>
@@ -28,16 +60,20 @@ describe('AccountListAddressItem', () => {
it('should call onPress callback when pressed', async () => {
const receiveAccount: ReceiveAccount = {
account: {
account: createAccount({
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
}),
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
path: '',
transfers: 0,
sent: '',
received: '',
},
};
const { getByText } = await renderAccountListAddressItem(receiveAccount);
@@ -48,16 +84,20 @@ describe('AccountListAddressItem', () => {
it('should not display caret for address addresses', async () => {
const receiveAccount: ReceiveAccount = {
account: {
account: createAccount({
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
}),
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
path: '',
transfers: 0,
sent: '',
received: '',
},
};
const { getByText, queryByAccessibilityHint } =
await renderAccountListAddressItem(receiveAccount);
@@ -68,16 +108,20 @@ describe('AccountListAddressItem', () => {
it('should display address', async () => {
const receiveAccount: ReceiveAccount = {
account: {
account: createAccount({
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
}),
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
path: '',
transfers: 0,
sent: '',
received: '',
},
};
const { getByText, queryByText, queryByAccessibilityHint, getByLabelText } =
await renderAccountListAddressItem(receiveAccount);
@@ -91,16 +135,20 @@ describe('AccountListAddressItem', () => {
it('should display zero balance', async () => {
const receiveAccount: ReceiveAccount = {
account: {
account: createAccount({
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
}),
address: {
address: 'BTC_address',
balance: '0',
} as unknown as Address,
path: '',
transfers: 0,
sent: '',
received: '',
},
};
const { getByLabelText } = await renderAccountListAddressItem(receiveAccount);
@@ -108,33 +156,15 @@ describe('AccountListAddressItem', () => {
expect(getByLabelText('Balance in crypto')).toHaveTextContent('0 BTC');
});
it('should not display balance when address has no balance', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
} as unknown as Address,
};
const { queryByLabelText } = await renderAccountListAddressItem(receiveAccount);
expect(queryByLabelText('Balance in fiat')).toBeNull();
expect(queryByLabelText('Balance in crypto')).toBeNull();
});
it('should render nothing when no address is specified', async () => {
const receiveAccount: ReceiveAccount = {
account: {
account: createAccount({
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: undefined as unknown as Address,
}),
address: undefined,
};
const { toJSON } = await renderAccountListAddressItem(receiveAccount);

View File

@@ -16,6 +16,7 @@
"@suite-native/helpers": "workspace:*",
"@suite-native/icons": "workspace:*",
"@suite-native/intl": "workspace:*",
"@suite-native/labeling": "workspace:*",
"@suite-native/toasts": "workspace:*",
"@trezor/styles": "workspace:*",
"@trezor/theme": "workspace:*",

View File

@@ -3,20 +3,22 @@ import { Alert, Pressable, Share } from 'react-native';
import { Button, HStack, Text, VStack } from '@suite-native/atoms';
import { useCopyToClipboard } from '@suite-native/helpers';
import { Translation, useTranslate } from '@suite-native/intl';
import { AddressLabelEditable } from '@suite-native/labeling';
import type { StaticSessionId } from '@trezor/connect';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { QRCode } from './QRCode';
type AddressQRCodeProps = {
address: string;
deviceStaticSessionId: StaticSessionId;
};
const addressContainer = prepareNativeStyle(() => ({
justifyContent: 'center',
alignItems: 'center',
}));
export const AddressQRCode = ({ address }: AddressQRCodeProps) => {
export const AddressQRCode = ({ address, deviceStaticSessionId }: AddressQRCodeProps) => {
const copyToClipboard = useCopyToClipboard();
const { translate } = useTranslate();
const { applyStyle } = useNativeStyles();
@@ -47,6 +49,7 @@ export const AddressQRCode = ({ address }: AddressQRCodeProps) => {
{address}
</Text>
</Pressable>
<AddressLabelEditable address={address} deviceStaticSessionId={deviceStaticSessionId} />
<HStack spacing="sp8" justifyContent="center">
<Button
size="small"

View File

@@ -12,6 +12,7 @@
{ "path": "../helpers" },
{ "path": "../icons" },
{ "path": "../intl" },
{ "path": "../labeling" },
{ "path": "../toasts" },
{ "path": "../../packages/styles" },
{ "path": "../../packages/theme" }

View File

@@ -9,11 +9,13 @@ import {
import { Box, Card, InlineAlertBoxProps } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { AddressQRCode } from '@suite-native/qr-code';
import type { StaticSessionId } from '@trezor/connect';
import { UnverifiedAddress } from './UnverifiedAddress';
type ReceiveAddressCardProps = {
address: string;
deviceStaticSessionId: StaticSessionId;
isReceiveApproved: boolean;
isUnverifiedAddressRevealed: boolean;
symbol: NetworkSymbol;
@@ -23,6 +25,7 @@ type ReceiveAddressCardProps = {
export const ReceiveAddressCard = ({
address,
deviceStaticSessionId,
isUnverifiedAddressRevealed,
isReceiveApproved,
onShowAddress,
@@ -71,7 +74,10 @@ export const ReceiveAddressCard = ({
<Card alertProps={cardAlertProps}>
<Box paddingVertical="sp8">
{isReceiveApproved ? (
<AddressQRCode address={address} />
<AddressQRCode
address={address}
deviceStaticSessionId={deviceStaticSessionId}
/>
) : (
<UnverifiedAddress
address={address}

View File

@@ -157,6 +157,7 @@ export const ReceiveAddressScreen = ({
<ReceiveAddressCard
symbol={account.symbol}
address={address}
deviceStaticSessionId={account.deviceState}
isTokenAddress={!!tokenContract}
isReceiveApproved={isReceiveApproved}
isUnverifiedAddressRevealed={isUnverifiedAddressRevealed}

View File

@@ -2,6 +2,7 @@ import { Platform } from 'react-native';
import * as Device from 'expo-device';
import { subscribeLocalFirstStorageThunk } from '@suite-common/local-first-storage';
import { ExtraDependencies } from '@suite-common/redux-utils';
import { extraDependenciesMock } from '@suite-common/test-utils/src/extraDependenciesMock'; // precise import path to avoid circular dependencies
import { selectSelectedDevice } from '@suite-common/wallet-core';
@@ -30,6 +31,10 @@ const transports = transportsPerDeviceType[deviceType];
export const extraDependencies: ExtraDependencies = mergeDeepObject(extraDependenciesMock, {
selectors: {
selectSuiteSettings: state => ({
...extraDependenciesMock.selectors.selectSuiteSettings(state),
isLocalFirstStorageEnabled: state.featureFlags.isLocalFirstStorageEnabled,
}),
selectTokenDefinitionsEnabledNetworks,
selectDevice: selectSelectedDevice,
selectDebugSettings: () => ({
@@ -53,7 +58,9 @@ export const extraDependencies: ExtraDependencies = mergeDeepObject(extraDepende
knownCredentials: state.thp?.credentials,
}),
} as Partial<ExtraDependencies['selectors']>,
thunks: {} as Partial<ExtraDependencies['thunks']>,
thunks: {
subscribeLocalFirstStorage: subscribeLocalFirstStorageThunk,
} as Partial<ExtraDependencies['thunks']>,
actions: {} as Partial<ExtraDependencies['actions']>,
actionTypes: {} as Partial<ExtraDependencies['actionTypes']>,
reducers: {} as Partial<ExtraDependencies['reducers']>,

View File

@@ -5,6 +5,7 @@ import { prepareAnalyticsReducer } from '@suite-common/analytics';
import { prepareConnectPopupReducer } from '@suite-common/connect-popup';
import { prepareFirmwareReducer } from '@suite-common/firmware';
import { geolocationReducer } from '@suite-common/geolocation';
import { prepareLabelingReducer } from '@suite-common/local-first-storage';
import { logsSlice } from '@suite-common/logger';
import {
messageSystemPersistedWhitelist,
@@ -78,6 +79,7 @@ const walletConnectReducer = prepareWalletConnectReducer(extraDependencies);
const walletSettingsReducer = prepareWalletSettingsReducer(extraDependencies);
const bluetoothReducer = bluetoothSlice.prepareReducer(extraDependencies);
const thpReducer = prepareThpReducer(extraDependencies);
const labelingReducer = prepareLabelingReducer(extraDependencies);
export const prepareRootReducers = async () => {
const appSettingsPersistedReducer = await preparePersistReducer({
@@ -298,6 +300,7 @@ export const prepareRootReducers = async () => {
geolocation: geolocationReducer,
thp: thpPersistedReducer,
locale: localePersistedReducer,
labeling: labelingReducer,
} as const),
// 'wallet' and 'graph' need to be persisted at the top level to ensure device state
// is accessible for transformation.

View File

@@ -30,6 +30,7 @@
"@suite-native/helpers": "workspace:*",
"@suite-native/icons": "workspace:*",
"@suite-native/intl": "workspace:*",
"@suite-native/labeling": "workspace:*",
"@suite-native/link": "workspace:*",
"@suite-native/navigation": "workspace:*",
"@suite-native/tokens": "workspace:*",

View File

@@ -3,13 +3,10 @@ import { useSelector } from 'react-redux';
import { A } from '@mobily/ts-belt';
import { TokenDefinitionsRootState } from '@suite-common/token-definitions';
import {
TransactionsRootState,
selectTransactionByAccountKeyAndTxid,
} from '@suite-common/wallet-core';
import { TransactionsRootState } from '@suite-common/wallet-core';
import { AccountKey } from '@suite-common/wallet-types';
import { Box, ErrorMessage, VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { Box, VStack } from '@suite-native/atoms';
import { WalletAccountTransaction } from '@suite-native/tokens';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { TransactionDetailAddressesSection } from './TransactionDetailAddressesSection';
@@ -54,37 +51,26 @@ export const VerticalSeparator = ({ inputsCount }: VerticalSeparatorProps) => {
return <Box style={applyStyle(separatorStyle, { inputsCount })} />;
};
type NetworkTransactionDetailSummaryProps = {
transaction: WalletAccountTransaction;
accountKey: AccountKey;
onShowMore: () => void;
};
export const NetworkTransactionDetailSummary = ({
accountKey,
txid,
transaction,
onShowMore,
}: {
accountKey: AccountKey;
txid: string;
onShowMore: () => void;
}) => {
const transaction = useSelector((state: TransactionsRootState) =>
selectTransactionByAccountKeyAndTxid(state, accountKey, txid),
);
}: NetworkTransactionDetailSummaryProps) => {
const transactionInputAddresses = useSelector(
(state: TransactionsRootState & TokenDefinitionsRootState) =>
selectTransactionAddresses(state, accountKey, txid, 'inputs'),
selectTransactionAddresses(state, accountKey, transaction.txid, 'inputs'),
);
const transactionOutputAddresses = useSelector(
(state: TransactionsRootState & TokenDefinitionsRootState) =>
selectTransactionAddresses(state, accountKey, txid, 'outputs'),
selectTransactionAddresses(state, accountKey, transaction.txid, 'outputs'),
);
if (!transaction) {
return (
<ErrorMessage
errorMessage={
<Translation id="transactions.TransactionDetailScreen.unknownTarget" />
}
/>
);
}
return (
<VStack spacing="sp24">
{A.isNotEmpty(transactionInputAddresses) && (
@@ -93,6 +79,7 @@ export const NetworkTransactionDetailSummary = ({
addresses={transactionInputAddresses}
onShowMore={onShowMore}
symbol={transaction.symbol}
transaction={transaction}
/>
)}
{A.isNotEmpty(transactionOutputAddresses) && (
@@ -100,6 +87,7 @@ export const NetworkTransactionDetailSummary = ({
addressesType="outputs"
addresses={transactionOutputAddresses}
onShowMore={onShowMore}
transaction={transaction}
/>
)}
<VerticalSeparator inputsCount={transactionInputAddresses.length} />

View File

@@ -3,36 +3,40 @@ import { useSelector } from 'react-redux';
import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core';
import { AccountKey } from '@suite-common/wallet-types';
import { VStack } from '@suite-native/atoms';
import { TypedTokenTransfer } from '@suite-native/tokens';
import { TypedTokenTransfer, WalletAccountTransaction } from '@suite-native/tokens';
import { VerticalSeparator } from './NetworkTransactionDetailSummary';
import { TransactionDetailAddressesSection } from './TransactionDetailAddressesSection';
import { VinVoutAddress } from '../../types';
type TokenTransactionDetailSummaryProps = {
transaction: WalletAccountTransaction;
accountKey: AccountKey;
tokenTransfer: TypedTokenTransfer;
onShowMore: () => void;
};
export const TokenTransactionDetailSummary = ({
transaction,
accountKey,
tokenTransfer,
onShowMore,
}: {
accountKey: AccountKey;
txid: string;
tokenTransfer: TypedTokenTransfer;
onShowMore: () => void;
}) => {
}: TokenTransactionDetailSummaryProps) => {
const symbol = useSelector((state: AccountsRootState) =>
selectAccountNetworkSymbol(state, accountKey),
);
// Token transfer has always only one address, so we need to wrap it to an array.
const inputAddresses: VinVoutAddress[] = [
{ address: tokenTransfer.from, isChangeAddress: false },
{ address: tokenTransfer.from, isChangeAddress: false, outputIndex: 0 },
];
const outputAddresses: VinVoutAddress[] = [
{ address: tokenTransfer.to, isChangeAddress: false },
{ address: tokenTransfer.to, isChangeAddress: false, outputIndex: 0 },
];
return (
<VStack>
<TransactionDetailAddressesSection
transaction={transaction}
addressesType="inputs"
addresses={inputAddresses}
symbol={symbol ?? undefined}
@@ -40,6 +44,7 @@ export const TokenTransactionDetailSummary = ({
onShowMore={onShowMore}
/>
<TransactionDetailAddressesSection
transaction={transaction}
addressesType="outputs"
addresses={outputAddresses}
onShowMore={onShowMore}

View File

@@ -4,17 +4,19 @@ import { TouchableOpacity } from 'react-native-gesture-handler';
import { NetworkSymbol } from '@suite-common/wallet-config';
import { TokenAddress } from '@suite-common/wallet-types';
import { Box, CardDivider, Text, VStack } from '@suite-native/atoms';
import { AccountAddressFormatter } from '@suite-native/formatters';
import { CryptoIconWithNetwork } from '@suite-native/icons';
import { Translation, TxKeyPath } from '@suite-native/intl';
import { WalletAccountTransaction } from '@suite-native/tokens';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { ChangeAddressesHeader } from './ChangeAddressesHeader';
import { formatAddressesCount } from './TransactionDetailAddressesSheet';
import { SummaryRow } from './TransactionSummaryRow';
import { TransactionUtxoAddress } from './TransactionUtxoAddress';
import { VinVoutAddress } from '../../types';
type TransactionDetailAddressesSectionProps = {
transaction: WalletAccountTransaction;
addresses: VinVoutAddress[];
addressesType: 'inputs' | 'outputs';
onShowMore: () => void;
@@ -37,10 +39,6 @@ const hiddenTransactionsCountStyle = prepareNativeStyle(utils => ({
paddingVertical: utils.spacings.sp2,
}));
const addressTextStyle = prepareNativeStyle(_ => ({
maxWidth: '80%',
}));
const stepperDotWrapperStyle = prepareNativeStyle(utils => ({
justifyContent: 'center',
alignItems: 'center',
@@ -69,6 +67,7 @@ const TransactionDetailSummaryStepper = () => {
};
export const TransactionDetailAddressesSection = ({
transaction,
addressesType,
addresses,
onShowMore,
@@ -105,11 +104,17 @@ export const TransactionDetailAddressesSection = ({
values={{ count: formatAddressesCount(targetAddresses.length) }}
/>
</Text>
{targetAddresses.slice(0, 2).map(({ address }) => (
<AccountAddressFormatter
key={address}
value={address}
style={applyStyle(addressTextStyle)}
{targetAddresses.slice(0, 2).map(({ address, outputIndex }) => (
<TransactionUtxoAddress
key={`target-${outputIndex}`}
address={address}
outputIndex={outputIndex}
deviceStaticSessionId={transaction.deviceState}
txId={transaction.txid}
// Todo: input not implemented yet. The idea is, that transaction input is just output
// of the previous transaction. So for inputs we would need to pass previous txid
// (and figure out correct `n` output index of the utxo on the previous transaction)
showLabels={addressesType === 'outputs'}
/>
))}
</Box>
@@ -146,11 +151,17 @@ export const TransactionDetailAddressesSection = ({
<Box flexDirection="row" justifyContent="space-between" alignItems="center">
<Box>
<ChangeAddressesHeader addressesCount={changeAddresses.length} />
{changeAddresses.map(({ address }) => (
<AccountAddressFormatter
key={address}
value={address}
style={applyStyle(addressTextStyle)}
{changeAddresses.map(({ address, outputIndex }) => (
<TransactionUtxoAddress
key={`change-${addressesType}:${outputIndex}`}
address={address}
outputIndex={outputIndex}
deviceStaticSessionId={transaction.deviceState}
txId={transaction.txid}
// Todo: input not implemented yet. The idea is, that transaction input is just output
// of the previous transaction. So for inputs we would need to pass previous txid
// (and figure out correct `n` output index of the utxo on the previous transaction)
showLabels={addressesType === 'outputs'}
/>
))}
</Box>

View File

@@ -76,8 +76,8 @@ const AddressRow = ({ address }: { address: string }) => {
const AddressesListCard = ({ addresses }: { addresses: VinVoutAddress[] }) => (
<Card>
<VStack spacing="sp16">
{addresses.map(({ address }) => (
<AddressRow key={address} address={address} />
{addresses.map(({ address, outputIndex }) => (
<AddressRow key={outputIndex} address={address} />
))}
</VStack>
</Card>

View File

@@ -129,7 +129,7 @@ export const TransactionDetailData = ({
</VStack>
</Card>
<TransactionDetailSummary
txid={transaction.txid}
transaction={transaction}
accountKey={accountKey}
tokenTransfer={tokenTransfer}
/>

View File

@@ -1,6 +1,6 @@
import { AccountKey } from '@suite-common/wallet-types';
import { Card, useBottomSheetModal } from '@suite-native/atoms';
import { TypedTokenTransfer } from '@suite-native/tokens';
import { TypedTokenTransfer, WalletAccountTransaction } from '@suite-native/tokens';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { NetworkTransactionDetailSummary } from './NetworkTransactionDetailSummary';
@@ -8,7 +8,7 @@ import { TokenTransactionDetailSummary } from './TokenTransactionDetailSummary';
import { TransactionDetailAddressesSheet } from './TransactionDetailAddressesSheet';
type TransactionDetailSummaryProps = {
txid: string;
transaction: WalletAccountTransaction;
accountKey: AccountKey;
tokenTransfer?: TypedTokenTransfer;
};
@@ -19,7 +19,7 @@ export const cardStyle = prepareNativeStyle(utils => ({
}));
export const TransactionDetailSummary = ({
txid,
transaction,
accountKey,
tokenTransfer,
}: TransactionDetailSummaryProps) => {
@@ -33,20 +33,20 @@ export const TransactionDetailSummary = ({
{isTokenTransferDetail ? (
<TokenTransactionDetailSummary
accountKey={accountKey}
txid={txid}
transaction={transaction}
tokenTransfer={tokenTransfer}
onShowMore={openModal}
/>
) : (
<NetworkTransactionDetailSummary
accountKey={accountKey}
txid={txid}
transaction={transaction}
onShowMore={openModal}
/>
)}
<TransactionDetailAddressesSheet
ref={bottomSheetRef}
txid={txid}
txid={transaction.txid}
accountKey={accountKey}
onClose={closeModal}
/>

View File

@@ -0,0 +1,61 @@
import { HStack, Text, VStack } from '@suite-native/atoms';
import { isDebugEnv } from '@suite-native/config';
import { AccountAddressFormatter } from '@suite-native/formatters';
import {
AddressLabel,
TransactionOutputLabelEditable,
useIsLabelingEnabled,
} from '@suite-native/labeling';
import type { StaticSessionId } from '@trezor/connect';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
const addressTextStyle = prepareNativeStyle(_ => ({
maxWidth: '80%',
}));
type TransactionUtxoAddressProps = {
address: string;
outputIndex: number;
txId: string;
deviceStaticSessionId: StaticSessionId;
showLabels?: boolean;
};
export const TransactionUtxoAddress = ({
deviceStaticSessionId,
txId,
outputIndex,
address,
showLabels,
}: TransactionUtxoAddressProps) => {
const { applyStyle } = useNativeStyles();
const isLabelingEnabled = useIsLabelingEnabled();
return (
<VStack>
<HStack spacing={2}>
<AddressLabel
address={address}
deviceStaticSessionId={deviceStaticSessionId}
fallback={
<AccountAddressFormatter
key={address}
value={address}
style={applyStyle(addressTextStyle)}
/>
}
/>
{isLabelingEnabled && isDebugEnv() && <Text>[{outputIndex}]</Text>}
</HStack>
{showLabels && (
<TransactionOutputLabelEditable
txId={txId}
outputIndex={outputIndex}
deviceStaticSessionId={deviceStaticSessionId}
/>
)}
</VStack>
);
};

View File

@@ -1,9 +1,12 @@
import { WalletAccountTransaction } from '@suite-common/wallet-types';
import { Text } from '@suite-native/atoms';
import { Translation, TxKeyPath } from '@suite-native/intl';
import { NativeTypographyStyle } from '@trezor/theme';
type TransactionNameProps = {
transaction: WalletAccountTransaction;
isPending: boolean;
variant?: NativeTypographyStyle;
};
interface GetSelfTransactionMessageByTypeProps {
@@ -52,13 +55,15 @@ export const getTransactionName = (
}
};
export const TransactionName = ({ transaction, isPending }: TransactionNameProps) => {
export const TransactionName = ({ transaction, isPending, variant }: TransactionNameProps) => {
const ethName = transaction.ethereumSpecific?.parsedData?.name;
// use name of eth txns, but not for recv or sent Transfer
if (ethName) {
return ethName;
}
return <Translation id={getTransactionName(transaction, isPending)} />;
return (
<Text variant={variant}>
{
// use name of eth txns, but not for recv or sent Transfer
ethName ? ethName : <Translation id={getTransactionName(transaction, isPending)} />
}
</Text>
);
};

View File

@@ -154,12 +154,10 @@ export const TransactionListItemContainer = ({
<Box marginLeft="sp16" flex={1}>
<HStack flexDirection="row" alignItems="center" spacing="sp4">
<Box style={applyStyle(titleStyle)}>
<Text variant="body">
<TransactionName
transaction={transaction}
isPending={isTransactionPending}
/>
</Text>
<TransactionName
transaction={transaction}
isPending={isTransactionPending}
/>
{isPhishingTransaction && (
<Badge
label={<Translation id="transactions.phishing.badge" />}

View File

@@ -77,6 +77,7 @@ export const TransactionDetailScreen = ({
<TransactionName
transaction={transaction}
isPending={isPending}
variant="highlight"
/>
),
}}

View File

@@ -55,7 +55,9 @@ export const selectTransactionAddresses = createMemoizedSelector(
if (networkType === 'ripple') {
// For ripple, we don't have inputs (input is always the same address - account descriptor)
if (addressesType === 'inputs') {
return [{ address: transaction.descriptor, isChangeAddress: false }];
return [
{ address: transaction.descriptor, isChangeAddress: false, outputIndex: 0 },
];
}
// We have only one output so we don't need to sort it

View File

@@ -5,7 +5,7 @@ import {
transactionWithTargetInOutputs,
} from './fixtures/transactions';
describe('mapTransactionInputsOutputsToAddresses', () => {
describe(mapTransactionInputsOutputsToAddresses.name, () => {
test('should return an empty array when input is empty', () => {
expect(
mapTransactionInputsOutputsToAddresses({
@@ -18,8 +18,16 @@ describe('mapTransactionInputsOutputsToAddresses', () => {
test('should return correct concatenated non-null addresses for transaction inputs', () => {
const expectedOutput: VinVoutAddress[] = [
{ address: 'bc1q39kuc35n722fmy0nw3qqhpvg0ch8f0a6rt22xs', isChangeAddress: false },
{ address: 'bc346cd7c787e903ac4b41e4fd2e038a81cb696d5dbf87', isChangeAddress: false },
{
address: 'bc1q39kuc35n722fmy0nw3qqhpvg0ch8f0a6rt22xs',
isChangeAddress: false,
outputIndex: 0,
},
{
address: 'bc346cd7c787e903ac4b41e4fd2e038a81cb696d5dbf87',
isChangeAddress: false,
outputIndex: 0,
},
];
expect(
mapTransactionInputsOutputsToAddresses({
@@ -32,8 +40,16 @@ describe('mapTransactionInputsOutputsToAddresses', () => {
test('should return correct concatenated non-null addresses for Target input', () => {
const expectedOutput: VinVoutAddress[] = [
{ address: '3BcXPstZ4ZHhvLxPFkjFocuFySKt8nsGgs', isChangeAddress: false },
{ address: '3QpCQP3A2q7kCr8QgsWuqG1Bg1P6RySonw', isChangeAddress: false },
{
address: '3BcXPstZ4ZHhvLxPFkjFocuFySKt8nsGgs',
isChangeAddress: false,
outputIndex: 0,
},
{
address: '3QpCQP3A2q7kCr8QgsWuqG1Bg1P6RySonw',
isChangeAddress: false,
outputIndex: 1,
},
];
expect(
mapTransactionInputsOutputsToAddresses({
@@ -45,7 +61,7 @@ describe('mapTransactionInputsOutputsToAddresses', () => {
});
});
describe('sortTargetAddressesToBeginning', () => {
describe(sortTargetAddressesToBeginning.name, () => {
test('should return an empty array when both inputs and targets are empty', () => {
expect(sortTargetAddressesToBeginning([], [])).toEqual([]);
});
@@ -100,8 +116,16 @@ describe('sortTargetAddressesToBeginning', () => {
});
const expectedResult: VinVoutAddress[] = [
{ address: '3QpCQP3A2q7kCr8QgsWuqG1Bg1P6RySonw', isChangeAddress: false },
{ address: '3BcXPstZ4ZHhvLxPFkjFocuFySKt8nsGgs', isChangeAddress: false },
{
address: '3BcXPstZ4ZHhvLxPFkjFocuFySKt8nsGgs',
isChangeAddress: false,
outputIndex: 0,
},
{
address: '3QpCQP3A2q7kCr8QgsWuqG1Bg1P6RySonw',
isChangeAddress: false,
outputIndex: 1,
},
];
expect(sortTargetAddressesToBeginning(outputAddresses, targetAddresses)).toEqual(
@@ -122,8 +146,16 @@ describe('sortTargetAddressesToBeginning', () => {
});
const expectedResult: VinVoutAddress[] = [
{ address: 'bc1ql2ntmq4jlq5g2q53q89c7f7d27s35se96jq6kw', isChangeAddress: false },
{ address: 'bc1qt5mjvp7nt4lpq77s4c3trvyre2smtcxz4zmmjs', isChangeAddress: true },
{
address: 'bc1ql2ntmq4jlq5g2q53q89c7f7d27s35se96jq6kw',
isChangeAddress: false,
outputIndex: 1,
},
{
address: 'bc1qt5mjvp7nt4lpq77s4c3trvyre2smtcxz4zmmjs',
isChangeAddress: true,
outputIndex: 0,
},
];
expect(sortTargetAddressesToBeginning(outputAddresses, targetAddresses)).toEqual(

View File

@@ -3,4 +3,5 @@ export type AddressesType = 'inputs' | 'outputs';
export type VinVoutAddress = {
address: string;
isChangeAddress: boolean;
outputIndex: number;
};

View File

@@ -28,6 +28,7 @@ export const mapTransactionInputsOutputsToAddresses = ({
(address): VinVoutAddress => ({
address,
isChangeAddress,
outputIndex: target.n,
}),
);
}),
@@ -40,10 +41,12 @@ export const sortTargetAddressesToBeginning = (
addresses: readonly VinVoutAddress[],
targetAddresses: readonly VinVoutAddress[],
) =>
A.concat(
A.intersection(addresses, targetAddresses),
A.difference(addresses, targetAddresses),
) as VinVoutAddress[];
F.toMutable(
A.concat(
A.intersection(addresses, targetAddresses),
A.difference(addresses, targetAddresses),
),
);
// describes if '+' or '-' sign should be shown as part of the transaction amount.
const transactionTypeToSignValueMap = {

View File

@@ -33,6 +33,7 @@
{ "path": "../helpers" },
{ "path": "../icons" },
{ "path": "../intl" },
{ "path": "../labeling" },
{ "path": "../link" },
{ "path": "../navigation" },
{ "path": "../tokens" },

View File

@@ -13,7 +13,7 @@
},
"dependencies": {
"@trezor/styles": "workspace:*",
"expo": "53.0.20",
"expo": "^53.0.22",
"expo-video": "2.2.2",
"prettier": "^3.6.2",
"react": "19.0.0",

221
yarn.lock
View File

@@ -3553,6 +3553,27 @@ __metadata:
languageName: node
linkType: hard
"@evolu/react-native@npm:^12.0.1-preview.4":
version: 12.0.1-preview.4
resolution: "@evolu/react-native@npm:12.0.1-preview.4"
peerDependencies:
"@evolu/common": ^6.0.1-preview.18
"@evolu/react": ^9.0.1-preview.4
"@op-engineering/op-sqlite": ">=12"
expo: ">=52"
expo-sqlite: ">=15"
react-native: ">=0.76"
peerDependenciesMeta:
"@op-engineering/op-sqlite":
optional: true
expo:
optional: true
expo-sqlite:
optional: true
checksum: 10/e510b81cfb0617f5345f676aba595e53308a5997be2e0db200cf5a4210980a12945bffd5729ebfebc47fcd36f09cfdf5ae26e03442bb8789d2cf7ab11f5b1311
languageName: node
linkType: hard
"@evolu/web@npm:^1.0.1-preview.5":
version: 1.0.1-preview.5
resolution: "@evolu/web@npm:1.0.1-preview.5"
@@ -3571,9 +3592,9 @@ __metadata:
languageName: node
linkType: hard
"@expo/cli@npm:0.24.20":
version: 0.24.20
resolution: "@expo/cli@npm:0.24.20"
"@expo/cli@npm:0.24.21":
version: 0.24.21
resolution: "@expo/cli@npm:0.24.21"
dependencies:
"@0no-co/graphql.web": "npm:^1.0.8"
"@babel/runtime": "npm:^7.20.0"
@@ -3589,10 +3610,11 @@ __metadata:
"@expo/package-manager": "npm:^1.8.6"
"@expo/plist": "npm:^0.3.5"
"@expo/prebuild-config": "npm:^9.0.11"
"@expo/schema-utils": "npm:^0.1.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/ws-tunnel": "npm:^1.0.1"
"@expo/xcpretty": "npm:^4.3.0"
"@react-native/dev-middleware": "npm:0.79.5"
"@react-native/dev-middleware": "npm:0.79.6"
"@urql/core": "npm:^5.0.6"
"@urql/exchange-retry": "npm:^1.3.0"
accepts: "npm:^1.3.8"
@@ -3638,7 +3660,7 @@ __metadata:
ws: "npm:^8.12.1"
bin:
expo-internal: build/bin/cli
checksum: 10/f6fb154c08dbc04761590b5b0eeb8b7936c1120fe50c06a7fa9948fe2ead17ee64c6eb466c3dda5441df205d968159af9a85bf147a8e78916bf53d5aab5706c9
checksum: 10/56c133116b800c3cd4a2f25496bee90f03934bf58b7527a9a13ff5b595cde6c4dd8b5eb6bbfc99cd810d61f59782af744774439407d86c99a57c197241c0211e
languageName: node
linkType: hard
@@ -3864,6 +3886,13 @@ __metadata:
languageName: node
linkType: hard
"@expo/schema-utils@npm:^0.1.0":
version: 0.1.6
resolution: "@expo/schema-utils@npm:0.1.6"
checksum: 10/75329e6d5f175003b07c3ca8000ede81ff1dc322d275810e18ea204a60e43ae0d37b2b184fed6e1bb9325966466701bc6b11a393636a4b3022e194c436c3c40c
languageName: node
linkType: hard
"@expo/sdk-runtime-versions@npm:^1.0.0":
version: 1.0.0
resolution: "@expo/sdk-runtime-versions@npm:1.0.0"
@@ -7099,16 +7128,6 @@ __metadata:
languageName: node
linkType: hard
"@react-native/babel-plugin-codegen@npm:0.79.5":
version: 0.79.5
resolution: "@react-native/babel-plugin-codegen@npm:0.79.5"
dependencies:
"@babel/traverse": "npm:^7.25.3"
"@react-native/codegen": "npm:0.79.5"
checksum: 10/fdc87144f1bf3ce71f60be8c477e442c9cf69ad3d1ad1b9231f8e12290dc10dfa27f7d7ae2870d637eb9400cb0a277903b6d8565405db0becaa97598aed06a01
languageName: node
linkType: hard
"@react-native/babel-plugin-codegen@npm:0.79.6":
version: 0.79.6
resolution: "@react-native/babel-plugin-codegen@npm:0.79.6"
@@ -7129,61 +7148,6 @@ __metadata:
languageName: node
linkType: hard
"@react-native/babel-preset@npm:0.79.5":
version: 0.79.5
resolution: "@react-native/babel-preset@npm:0.79.5"
dependencies:
"@babel/core": "npm:^7.25.2"
"@babel/plugin-proposal-export-default-from": "npm:^7.24.7"
"@babel/plugin-syntax-dynamic-import": "npm:^7.8.3"
"@babel/plugin-syntax-export-default-from": "npm:^7.24.7"
"@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3"
"@babel/plugin-syntax-optional-chaining": "npm:^7.8.3"
"@babel/plugin-transform-arrow-functions": "npm:^7.24.7"
"@babel/plugin-transform-async-generator-functions": "npm:^7.25.4"
"@babel/plugin-transform-async-to-generator": "npm:^7.24.7"
"@babel/plugin-transform-block-scoping": "npm:^7.25.0"
"@babel/plugin-transform-class-properties": "npm:^7.25.4"
"@babel/plugin-transform-classes": "npm:^7.25.4"
"@babel/plugin-transform-computed-properties": "npm:^7.24.7"
"@babel/plugin-transform-destructuring": "npm:^7.24.8"
"@babel/plugin-transform-flow-strip-types": "npm:^7.25.2"
"@babel/plugin-transform-for-of": "npm:^7.24.7"
"@babel/plugin-transform-function-name": "npm:^7.25.1"
"@babel/plugin-transform-literals": "npm:^7.25.2"
"@babel/plugin-transform-logical-assignment-operators": "npm:^7.24.7"
"@babel/plugin-transform-modules-commonjs": "npm:^7.24.8"
"@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.24.7"
"@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.24.7"
"@babel/plugin-transform-numeric-separator": "npm:^7.24.7"
"@babel/plugin-transform-object-rest-spread": "npm:^7.24.7"
"@babel/plugin-transform-optional-catch-binding": "npm:^7.24.7"
"@babel/plugin-transform-optional-chaining": "npm:^7.24.8"
"@babel/plugin-transform-parameters": "npm:^7.24.7"
"@babel/plugin-transform-private-methods": "npm:^7.24.7"
"@babel/plugin-transform-private-property-in-object": "npm:^7.24.7"
"@babel/plugin-transform-react-display-name": "npm:^7.24.7"
"@babel/plugin-transform-react-jsx": "npm:^7.25.2"
"@babel/plugin-transform-react-jsx-self": "npm:^7.24.7"
"@babel/plugin-transform-react-jsx-source": "npm:^7.24.7"
"@babel/plugin-transform-regenerator": "npm:^7.24.7"
"@babel/plugin-transform-runtime": "npm:^7.24.7"
"@babel/plugin-transform-shorthand-properties": "npm:^7.24.7"
"@babel/plugin-transform-spread": "npm:^7.24.7"
"@babel/plugin-transform-sticky-regex": "npm:^7.24.7"
"@babel/plugin-transform-typescript": "npm:^7.25.2"
"@babel/plugin-transform-unicode-regex": "npm:^7.24.7"
"@babel/template": "npm:^7.25.0"
"@react-native/babel-plugin-codegen": "npm:0.79.5"
babel-plugin-syntax-hermes-parser: "npm:0.25.1"
babel-plugin-transform-flow-enums: "npm:^0.0.2"
react-refresh: "npm:^0.14.0"
peerDependencies:
"@babel/core": "*"
checksum: 10/07ee06dec35eccb915af455cf574320c5c85e577e92913c986c8c9a4ef4731a08c416f05aee8c0531457f6a22db02a1f60d4ad816075d67640cae47b608bae2d
languageName: node
linkType: hard
"@react-native/babel-preset@npm:0.79.6":
version: 0.79.6
resolution: "@react-native/babel-preset@npm:0.79.6"
@@ -7309,21 +7273,6 @@ __metadata:
languageName: node
linkType: hard
"@react-native/codegen@npm:0.79.5":
version: 0.79.5
resolution: "@react-native/codegen@npm:0.79.5"
dependencies:
glob: "npm:^7.1.1"
hermes-parser: "npm:0.25.1"
invariant: "npm:^2.2.4"
nullthrows: "npm:^1.1.1"
yargs: "npm:^17.6.2"
peerDependencies:
"@babel/core": "*"
checksum: 10/a642d32e416d261799eb0677a6618bbb15d1274fb14951cf76e3c37b3d427bc2cba5e34885ffcb76131c5c50b3982771de8668917916ab86a1a1e3b3c49ea0e0
languageName: node
linkType: hard
"@react-native/codegen@npm:0.79.6":
version: 0.79.6
resolution: "@react-native/codegen@npm:0.79.6"
@@ -7384,10 +7333,10 @@ __metadata:
languageName: node
linkType: hard
"@react-native/debugger-frontend@npm:0.79.5":
version: 0.79.5
resolution: "@react-native/debugger-frontend@npm:0.79.5"
checksum: 10/d9556110ad0e59b0d800fb159bfb1b118056f3176c5940a242a058e8deee2c2f0eeb47b3d7ab474127b37e3caebe07738cd0022bfa9c4865b7e42336a4f52c53
"@react-native/debugger-frontend@npm:0.79.6":
version: 0.79.6
resolution: "@react-native/debugger-frontend@npm:0.79.6"
checksum: 10/fca04379d556a62367628f3ec8478bd0eceeef34d6f7e434e5285edba6d8f7d1b833ec62ff8b96a3444d55da569cdd9c36690439736b263898478e23a2b7fe12
languageName: node
linkType: hard
@@ -7410,12 +7359,12 @@ __metadata:
languageName: node
linkType: hard
"@react-native/dev-middleware@npm:0.79.5":
version: 0.79.5
resolution: "@react-native/dev-middleware@npm:0.79.5"
"@react-native/dev-middleware@npm:0.79.6":
version: 0.79.6
resolution: "@react-native/dev-middleware@npm:0.79.6"
dependencies:
"@isaacs/ttlcache": "npm:^1.4.1"
"@react-native/debugger-frontend": "npm:0.79.5"
"@react-native/debugger-frontend": "npm:0.79.6"
chrome-launcher: "npm:^0.15.2"
chromium-edge-launcher: "npm:^0.2.0"
connect: "npm:^3.6.5"
@@ -7425,7 +7374,7 @@ __metadata:
open: "npm:^7.0.3"
serve-static: "npm:^1.16.2"
ws: "npm:^6.2.3"
checksum: 10/baa7b37711211aec9537725de77d082f5f9ec777107f3e7a7e93a67aa07b1da79f851f37dacc65bec3d012df4d352941e02fc1153ad19602258b641c78318065
checksum: 10/e1073a149e054bdc10028e65505d9f9db8bf47d9902a37f2a1afd283d9607a70e605bc20cd4c1ea445061b6840c28730edb500833e08e5e601c076e48c9a7274
languageName: node
linkType: hard
@@ -10003,6 +9952,7 @@ __metadata:
"@suite-native/forms": "workspace:*"
"@suite-native/icons": "workspace:*"
"@suite-native/intl": "workspace:*"
"@suite-native/labeling": "workspace:*"
"@suite-native/navigation": "workspace:*"
"@suite-native/staking": "workspace:*"
"@suite-native/test-utils": "workspace:*"
@@ -10095,6 +10045,7 @@ __metadata:
"@suite-native/discovery": "workspace:*"
"@suite-native/icons": "workspace:*"
"@suite-native/intl": "workspace:*"
"@suite-native/local-first-storage": "workspace:*"
"@suite-native/message-system": "workspace:*"
"@suite-native/module-accounts-import": "workspace:*"
"@suite-native/module-accounts-management": "workspace:*"
@@ -10142,7 +10093,7 @@ __metadata:
detox: "npm:^20.40.2"
dotenv: "npm:^17.2.1"
event-target-shim: "npm:6.0.2"
expo: "npm:53.0.20"
expo: "npm:^53.0.22"
expo-atlas: "npm:0.4.3"
expo-build-properties: "npm:0.14.8"
expo-camera: "npm:16.1.11"
@@ -10402,6 +10353,7 @@ __metadata:
"@suite-native/formatters": "workspace:*"
"@suite-native/icons": "workspace:*"
"@suite-native/intl": "workspace:*"
"@suite-native/labeling": "workspace:*"
"@suite-native/link": "workspace:*"
"@suite-native/navigation": "workspace:*"
"@trezor/styles": "workspace:*"
@@ -10673,6 +10625,24 @@ __metadata:
languageName: unknown
linkType: soft
"@suite-native/labeling@workspace:*, @suite-native/labeling@workspace:suite-native/labeling":
version: 0.0.0-use.local
resolution: "@suite-native/labeling@workspace:suite-native/labeling"
dependencies:
"@reduxjs/toolkit": "npm:2.8.2"
"@suite-common/local-first-storage": "workspace:*"
"@suite-common/validators": "workspace:*"
"@suite-common/wallet-types": "workspace:*"
"@suite-native/atoms": "workspace:*"
"@suite-native/feature-flags": "workspace:*"
"@suite-native/forms": "workspace:*"
"@suite-native/intl": "workspace:*"
"@trezor/connect": "workspace:*"
react: "npm:19.0.0"
react-redux: "npm:9.2.0"
languageName: unknown
linkType: soft
"@suite-native/link@workspace:*, @suite-native/link@workspace:suite-native/link":
version: 0.0.0-use.local
resolution: "@suite-native/link@workspace:suite-native/link"
@@ -10688,6 +10658,17 @@ __metadata:
languageName: unknown
linkType: soft
"@suite-native/local-first-storage@workspace:*, @suite-native/local-first-storage@workspace:suite-native/local-first-storage":
version: 0.0.0-use.local
resolution: "@suite-native/local-first-storage@workspace:suite-native/local-first-storage"
dependencies:
"@evolu/react-native": "npm:^12.0.1-preview.4"
"@suite-common/local-first-storage": "workspace:*"
expo: "npm:^53.0.22"
expo-sqlite: "npm:^15.2.14"
languageName: unknown
linkType: soft
"@suite-native/message-system@workspace:*, @suite-native/message-system@workspace:suite-native/message-system":
version: 0.0.0-use.local
resolution: "@suite-native/message-system@workspace:suite-native/message-system"
@@ -10951,7 +10932,7 @@ __metadata:
"@trezor/styles": "workspace:*"
"@trezor/theme": "workspace:*"
"@trezor/urls": "workspace:*"
expo: "npm:53.0.20"
expo: "npm:^53.0.22"
react: "npm:19.0.0"
react-native: "npm:0.79.3"
react-redux: "npm:9.2.0"
@@ -11291,6 +11272,7 @@ __metadata:
"@suite-native/helpers": "workspace:*"
"@suite-native/icons": "workspace:*"
"@suite-native/intl": "workspace:*"
"@suite-native/labeling": "workspace:*"
"@suite-native/toasts": "workspace:*"
"@trezor/styles": "workspace:*"
"@trezor/theme": "workspace:*"
@@ -11598,6 +11580,7 @@ __metadata:
"@suite-native/helpers": "workspace:*"
"@suite-native/icons": "workspace:*"
"@suite-native/intl": "workspace:*"
"@suite-native/labeling": "workspace:*"
"@suite-native/link": "workspace:*"
"@suite-native/navigation": "workspace:*"
"@suite-native/tokens": "workspace:*"
@@ -11619,7 +11602,7 @@ __metadata:
resolution: "@suite-native/video-assets@workspace:suite-native/video-assets"
dependencies:
"@trezor/styles": "workspace:*"
expo: "npm:53.0.20"
expo: "npm:^53.0.22"
expo-video: "npm:2.2.2"
prettier: "npm:^3.6.2"
react: "npm:19.0.0"
@@ -16466,6 +16449,13 @@ __metadata:
languageName: node
linkType: hard
"await-lock@npm:^2.2.2":
version: 2.2.2
resolution: "await-lock@npm:2.2.2"
checksum: 10/feb11f36768a8545879ed2d214b46aae484e6564ffa466af9212d5782897203770795cae01f813de04a46f66c0b8ee6bc690a0c435b04e00cad5a18ef0842e25
languageName: node
linkType: hard
"axe-core@npm:^4.10.0":
version: 4.10.2
resolution: "axe-core@npm:4.10.2"
@@ -16739,9 +16729,9 @@ __metadata:
languageName: node
linkType: hard
"babel-preset-expo@npm:~13.2.3":
version: 13.2.3
resolution: "babel-preset-expo@npm:13.2.3"
"babel-preset-expo@npm:~13.2.4":
version: 13.2.4
resolution: "babel-preset-expo@npm:13.2.4"
dependencies:
"@babel/helper-module-imports": "npm:^7.25.9"
"@babel/plugin-proposal-decorators": "npm:^7.12.9"
@@ -16757,7 +16747,7 @@ __metadata:
"@babel/plugin-transform-runtime": "npm:^7.24.7"
"@babel/preset-react": "npm:^7.22.15"
"@babel/preset-typescript": "npm:^7.23.0"
"@react-native/babel-preset": "npm:0.79.5"
"@react-native/babel-preset": "npm:0.79.6"
babel-plugin-react-native-web: "npm:~0.19.13"
babel-plugin-syntax-hermes-parser: "npm:^0.25.1"
babel-plugin-transform-flow-enums: "npm:^0.0.2"
@@ -16769,7 +16759,7 @@ __metadata:
peerDependenciesMeta:
babel-plugin-react-compiler:
optional: true
checksum: 10/9081ab23348dc94cf5c2d89a01cab7c591fe64fc6520f770c90112202fc6ecd4bef7526bbf3c083c2341e00617ea6c6d9ae23813103905159dd1c9a03f5b7f49
checksum: 10/645501ff71738f9fbf34559a135f36b3ed6539f8ba88f204c5e72948e621f1216c8a4bbd0775137e7763ba93d5ecae9d9031e797f71ee69d88e8ace5c15e47fe
languageName: node
linkType: hard
@@ -18856,7 +18846,7 @@ __metadata:
"@babel/core": "npm:^7.28.0"
"@trezor/connect-mobile": "workspace:*"
"@types/react": "npm:^19.0.0"
expo: "npm:53.0.20"
expo: "npm:^53.0.22"
expo-linking: "npm:7.1.7"
expo-status-bar: "npm:^2.0.0"
react: "npm:19.0.0"
@@ -23230,6 +23220,19 @@ __metadata:
languageName: node
linkType: hard
"expo-sqlite@npm:^15.2.14":
version: 15.2.14
resolution: "expo-sqlite@npm:15.2.14"
dependencies:
await-lock: "npm:^2.2.2"
peerDependencies:
expo: "*"
react: "*"
react-native: "*"
checksum: 10/1b5e2ca65a7fa236f870722033f81090f83743ca013eda5478886e6ffa36ac209bb32a09759bcc3e6db91d231b369f59969362383bf103a47a49ae4cb2ec1b9b
languageName: node
linkType: hard
"expo-status-bar@npm:^2.0.0, expo-status-bar@npm:~2.2.3":
version: 2.2.3
resolution: "expo-status-bar@npm:2.2.3"
@@ -23313,18 +23316,18 @@ __metadata:
languageName: node
linkType: hard
"expo@npm:53.0.20":
version: 53.0.20
resolution: "expo@npm:53.0.20"
"expo@npm:^53.0.22":
version: 53.0.22
resolution: "expo@npm:53.0.22"
dependencies:
"@babel/runtime": "npm:^7.20.0"
"@expo/cli": "npm:0.24.20"
"@expo/cli": "npm:0.24.21"
"@expo/config": "npm:~11.0.13"
"@expo/config-plugins": "npm:~10.1.2"
"@expo/fingerprint": "npm:0.13.4"
"@expo/metro-config": "npm:0.20.17"
"@expo/vector-icons": "npm:^14.0.0"
babel-preset-expo: "npm:~13.2.3"
babel-preset-expo: "npm:~13.2.4"
expo-asset: "npm:~11.1.7"
expo-constants: "npm:~17.1.7"
expo-file-system: "npm:~18.1.11"
@@ -23351,7 +23354,7 @@ __metadata:
expo: bin/cli
expo-modules-autolinking: bin/autolinking
fingerprint: bin/fingerprint
checksum: 10/73cb5ad2e3eb8927390ff685b3f53b0a496fdaa99ec73f6cc3faa732534ad5ec0b5378abdf3da2d0c692397b758eadeed1eabad7f412f0b7576c29f2518e6f93
checksum: 10/76011df132cef57f957974cd14f5eefadfb6eb1a54e72749701b8cfe214b136d7bcd8f050b28e0feee5bd6af97cfee3932936e6ae77a3e0346113e9e295cb61c
languageName: node
linkType: hard