diff --git a/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx b/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx index c2fb823ba..27d0e02bd 100644 --- a/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx +++ b/apps/mobile/src/components/AddressDisplay/AddressDisplay.tsx @@ -19,6 +19,7 @@ import { type PWTouchableOpacityProps, PWView, } from '@components/core' +import { dedupeSecondaryLabel } from '@perawallet/wallet-core-shared' import { CopyableText } from '@components/CopyableText' import { ContactAvatar } from '@components/ContactAvatar' import type { ContactAvatarVariant } from '@components/ContactAvatar/styles' @@ -140,7 +141,9 @@ export const AddressDisplay = ({ /> ) } else if (contact) { - const showSecondary = contact.name !== truncatedAddress + const showSecondary = Boolean( + dedupeSecondaryLabel(contact.name, truncatedAddress), + ) const primaryText = ( { return address } case 'long': { - return truncateAlgorandAddress(address, LONG_ADDRESS_FORMAT) + return truncateAlgorandAddress(address, LONG_ADDRESS_LENGTH) } case 'short': default: { diff --git a/apps/mobile/src/components/AddressListItem/AddressListItem.tsx b/apps/mobile/src/components/AddressListItem/AddressListItem.tsx index 354261d9d..21d742627 100644 --- a/apps/mobile/src/components/AddressListItem/AddressListItem.tsx +++ b/apps/mobile/src/components/AddressListItem/AddressListItem.tsx @@ -11,6 +11,7 @@ */ import { getAccountDisplayName } from '@perawallet/wallet-core-accounts' +import { dedupeSecondaryLabel } from '@perawallet/wallet-core-shared' import { PWListItemLayout, PWText, PWView } from '@components/core' import { ContactAvatar } from '@components/ContactAvatar' import { AccountIcon } from '@modules/accounts/components/AccountIcon' @@ -65,8 +66,7 @@ export const AddressListItem = ({ : (contact?.name ?? nfdName ?? truncatedAddress) // Show the address underneath only when the primary line is a distinct // label (name/NFD); otherwise the primary line already *is* the address. - const secondary = - primary === truncatedAddress ? undefined : truncatedAddress + const secondary = dedupeSecondaryLabel(primary, truncatedAddress) const avatar = account ? ( ({ })) vi.mock('@perawallet/wallet-core-shared', () => ({ - truncateAlgorandAddress: (address: string, length?: number) => { - const half = Math.floor((length ?? 6) / 2) + // Defaults must mirror the real constants so the mock reproduces the + // production 5…5 / 10…10 forms. + SHORT_ADDRESS_LENGTH: 11, + LONG_ADDRESS_LENGTH: 20, + truncateAlgorandAddress: (address: string, maxLength = 11) => { + const half = Math.floor(maxLength / 2) return `${address.slice(0, half)}...${address.slice(-half)}` }, })) diff --git a/apps/mobile/src/hooks/useResolvedAddress.ts b/apps/mobile/src/hooks/useResolvedAddress.ts index 75cd7a272..78f631104 100644 --- a/apps/mobile/src/hooks/useResolvedAddress.ts +++ b/apps/mobile/src/hooks/useResolvedAddress.ts @@ -10,12 +10,13 @@ limitations under the License */ -import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' +import { + LONG_ADDRESS_LENGTH, + truncateAlgorandAddress, +} from '@perawallet/wallet-core-shared' import { useNfdForAddressQuery } from '@perawallet/wallet-core-nfd' import { useMemo } from 'react' -const LONG_ADDRESS_LENGTH = 20 - export type AddressFormat = 'short' | 'long' | 'full' type UseResolvedAddressResult = { diff --git a/apps/mobile/src/modules/accounts/components/AccountDisplay/AccountDisplay.tsx b/apps/mobile/src/modules/accounts/components/AccountDisplay/AccountDisplay.tsx index b7f99af9d..b7a3693b3 100644 --- a/apps/mobile/src/modules/accounts/components/AccountDisplay/AccountDisplay.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountDisplay/AccountDisplay.tsx @@ -11,11 +11,7 @@ */ import { useTheme } from '@rneui/themed' -import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' -import { - getAccountDisplayName, - type WalletAccount, -} from '@perawallet/wallet-core-accounts' +import { type WalletAccount } from '@perawallet/wallet-core-accounts' import { PWIcon, type PWIconProps, @@ -27,11 +23,9 @@ import { } from '@components/core' import peraCardImage from '@assets/images/pera-card.png' import { useStyles } from './styles' +import { useAccountDisplay } from './useAccountDisplay' import { AccountIcon, type AccountIconProps } from '../AccountIcon' -import { useMemo } from 'react' -import { useNfdForAddressQuery } from '@perawallet/wallet-core-nfd' -import { useAccountTypeLabel } from '@modules/accounts/hooks/useAccountTypeLabel' /** Pera Card identity rendered in place of an account (header trigger). */ export type AccountDisplayCard = { @@ -75,22 +69,8 @@ export const AccountDisplay = ({ }: AccountDisplayProps) => { const { theme } = useTheme() const styles = useStyles({ noBorder }) - const displayName = useMemo( - () => (account ? getAccountDisplayName(account) : 'No Account'), - [account], - ) - const address = useMemo( - () => (account ? truncateAlgorandAddress(account?.address) : undefined), - [account], - ) - - const { data: nfdNames } = useNfdForAddressQuery(account?.address ?? '', { - enabled: !!account?.address, - }) - - const nfdName = useMemo(() => nfdNames?.at(0)?.name, [nfdNames]) - - const { label: accountTypeLabel } = useAccountTypeLabel(account) + const { displayName, secondaryText, renderSecondary, showTypeAsSecondary } = + useAccountDisplay({ account, compact, showAccountType }) if (card) { return ( @@ -131,14 +111,6 @@ export const AccountDisplay = ({ ) } - const hasName = Boolean(nfdName) || displayName !== address - const showTypeAsSecondary = - showAccountType && !hasName && Boolean(accountTypeLabel) - const renderSecondary = hasName || showTypeAsSecondary - const secondaryText = showTypeAsSecondary - ? accountTypeLabel - : (nfdName ?? address) - return ( )} - {(compact || renderSecondary) && ( + {renderSecondary && ( + importOriginal(), +) + +vi.mock('@perawallet/wallet-core-nfd', () => ({ + useNfdForAddressQuery: () => ({ data: mockNfdNames }), +})) + +vi.mock('@modules/accounts/hooks/useAccountTypeLabel', () => ({ + useAccountTypeLabel: () => ({ label: mockAccountTypeLabel }), +})) + +const ADDRESS = 'A'.repeat(58) + +const makeAccount = (name?: string): WalletAccount => + ({ address: ADDRESS, name }) as unknown as WalletAccount + +describe('useAccountDisplay', () => { + beforeEach(() => { + mockNfdNames = undefined + mockAccountTypeLabel = undefined + }) + + it('shows the truncated address as secondary for a named account', () => { + const { result } = renderHook(() => + useAccountDisplay({ + account: makeAccount('My Wallet'), + compact: false, + showAccountType: false, + }), + ) + + expect(result.current.displayName).toBe('My Wallet') + expect(result.current.secondaryText).toBe( + truncateAlgorandAddress(ADDRESS), + ) + expect(result.current.renderSecondary).toBe(true) + }) + + it('suppresses the secondary line when it repeats the primary', () => { + mockNfdNames = [{ name: 'pera.algo' }] + + const { result } = renderHook(() => + useAccountDisplay({ + account: makeAccount('pera.algo'), + compact: false, + showAccountType: false, + }), + ) + + expect(result.current.displayName).toBe('pera.algo') + expect(result.current.renderSecondary).toBe(false) + }) + + it('keeps the raw secondary in compact mode where it is the only label', () => { + mockNfdNames = [{ name: 'pera.algo' }] + + const { result } = renderHook(() => + useAccountDisplay({ + account: makeAccount('pera.algo'), + compact: true, + showAccountType: false, + }), + ) + + expect(result.current.secondaryText).toBe('pera.algo') + expect(result.current.renderSecondary).toBe(true) + }) + + it('shows the account type for unnamed accounts when requested', () => { + mockAccountTypeLabel = 'Ledger account' + + const { result } = renderHook(() => + useAccountDisplay({ + account: makeAccount(), + compact: false, + showAccountType: true, + }), + ) + + expect(result.current.secondaryText).toBe('Ledger account') + expect(result.current.showTypeAsSecondary).toBe(true) + }) +}) diff --git a/apps/mobile/src/modules/accounts/components/AccountDisplay/useAccountDisplay.ts b/apps/mobile/src/modules/accounts/components/AccountDisplay/useAccountDisplay.ts new file mode 100644 index 000000000..566b4e546 --- /dev/null +++ b/apps/mobile/src/modules/accounts/components/AccountDisplay/useAccountDisplay.ts @@ -0,0 +1,76 @@ +/* + Copyright 2022-2026 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useMemo } from 'react' +import { + dedupeSecondaryLabel, + truncateAlgorandAddress, + type Optional, +} from '@perawallet/wallet-core-shared' +import { + getAccountDisplayName, + type WalletAccount, +} from '@perawallet/wallet-core-accounts' +import { useNfdForAddressQuery } from '@perawallet/wallet-core-nfd' +import { useAccountTypeLabel } from '@modules/accounts/hooks/useAccountTypeLabel' + +type UseAccountDisplayParams = { + account: Optional + compact: boolean + showAccountType: boolean +} + +type UseAccountDisplayResult = { + displayName: string + secondaryText: Optional + renderSecondary: boolean + showTypeAsSecondary: boolean +} + +/** Derives the primary/secondary labels an account renders with. */ +export const useAccountDisplay = ({ + account, + compact, + showAccountType, +}: UseAccountDisplayParams): UseAccountDisplayResult => { + const displayName = useMemo( + () => (account ? getAccountDisplayName(account) : 'No Account'), + [account], + ) + const address = useMemo( + () => (account ? truncateAlgorandAddress(account?.address) : undefined), + [account], + ) + + const { data: nfdNames } = useNfdForAddressQuery(account?.address ?? '', { + enabled: !!account?.address, + }) + + const nfdName = useMemo(() => nfdNames?.at(0)?.name, [nfdNames]) + + const { label: accountTypeLabel } = useAccountTypeLabel(account) + + const hasName = Boolean(nfdName) || displayName !== address + const showTypeAsSecondary = + showAccountType && !hasName && Boolean(accountTypeLabel) + const rawSecondary = showTypeAsSecondary + ? accountTypeLabel + : (nfdName ?? address) + // Suppress the secondary line when it would just repeat the primary name + // (e.g. a custom name identical to its NFD). Not in `compact`, where the + // primary is hidden and the secondary stands alone as the only label. + const dedupedSecondary = dedupeSecondaryLabel(displayName, rawSecondary) + const secondaryText = compact ? rawSecondary : dedupedSecondary + const renderSecondary = compact || Boolean(dedupedSecondary) + + return { displayName, secondaryText, renderSecondary, showTypeAsSecondary } +} diff --git a/apps/mobile/src/modules/accounts/components/AccountInfoCard/RekeyedToRow.tsx b/apps/mobile/src/modules/accounts/components/AccountInfoCard/RekeyedToRow.tsx index f664c1387..5a271ea8c 100644 --- a/apps/mobile/src/modules/accounts/components/AccountInfoCard/RekeyedToRow.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountInfoCard/RekeyedToRow.tsx @@ -16,7 +16,10 @@ import { getAccountDisplayName, type WalletAccount, } from '@perawallet/wallet-core-accounts' -import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' +import { + dedupeSecondaryLabel, + truncateAlgorandAddress, +} from '@perawallet/wallet-core-shared' import { useClipboard } from '@hooks/useClipboard' import { AccountIcon } from '../AccountIcon' import type { useStyles } from './styles' @@ -41,8 +44,8 @@ export const RekeyedToRow = ({ const { copyToClipboard } = useClipboard() const address = authAccount?.address ?? authAddress const truncated = truncateAlgorandAddress(address) - const hasCustomName = !!authAccount?.name - const title = hasCustomName ? getAccountDisplayName(authAccount) : truncated + const title = authAccount ? getAccountDisplayName(authAccount) : truncated + const secondary = dedupeSecondaryLabel(title, truncated) const handleCopyAddress = useCallback(() => { void copyToClipboard(address) @@ -76,13 +79,13 @@ export const RekeyedToRow = ({ > {title} - {hasCustomName && ( + {!!secondary && ( - {truncated} + {secondary} )} diff --git a/apps/mobile/src/modules/assets/screens/AssetDetailsScreen/AssetDetailsScreen.tsx b/apps/mobile/src/modules/assets/screens/AssetDetailsScreen/AssetDetailsScreen.tsx index 8791a594f..5744126af 100644 --- a/apps/mobile/src/modules/assets/screens/AssetDetailsScreen/AssetDetailsScreen.tsx +++ b/apps/mobile/src/modules/assets/screens/AssetDetailsScreen/AssetDetailsScreen.tsx @@ -15,6 +15,7 @@ import { getAccountDisplayName, useSelectedAccount, } from '@perawallet/wallet-core-accounts' +import { dedupeSecondaryLabel } from '@perawallet/wallet-core-shared' import { useResolvedAddress } from '@hooks/useResolvedAddress' import { useEffect, useState } from 'react' import { trackEvent, AssetDetailsEvent, AnalyticsMetadataKey } from '@analytics' @@ -69,6 +70,15 @@ export const AssetDetailsScreen = ({ route }: AssetDetailsScreenProps) => { } }, [assetId, isCollectible]) + // Dedupe compares rendered strings, so both sides must share the same + // ('short') truncation — a 'long'-format secondary would never match and + // the address would render twice again. + const headerPrimary = getAccountDisplayName(account) + const headerSecondary = dedupeSecondaryLabel( + headerPrimary, + accountDisplayName, + ) + useNavigationHeader({ title: ( @@ -76,16 +86,18 @@ export const AssetDetailsScreen = ({ route }: AssetDetailsScreenProps) => { variant='h4' truncate > - {account?.name || getAccountDisplayName(account)} - - - {accountDisplayName} + {headerPrimary} + {!!headerSecondary && ( + + {headerSecondary} + + )} ), right: account ? ( diff --git a/apps/mobile/src/modules/signing/components/SigningWarnings/WarningItem.tsx b/apps/mobile/src/modules/signing/components/SigningWarnings/WarningItem.tsx index 1c154f636..c95969136 100644 --- a/apps/mobile/src/modules/signing/components/SigningWarnings/WarningItem.tsx +++ b/apps/mobile/src/modules/signing/components/SigningWarnings/WarningItem.tsx @@ -19,9 +19,9 @@ import { useLanguage } from '@hooks/useLanguage' import { PWDivider, PWRoundIcon, PWText, PWView } from '@components/core' import { formatNumber, + LONG_ADDRESS_LENGTH, truncateAlgorandAddress, } from '@perawallet/wallet-core-shared' -import { LONG_ADDRESS_FORMAT } from '@constants/ui' type WarningItemProps = { warning: TransactionWarning @@ -129,7 +129,7 @@ export const WarningItem = ({ {t(messageKey, { address: truncateAlgorandAddress( warning.targetAddress, - LONG_ADDRESS_FORMAT, + LONG_ADDRESS_LENGTH, ), })} @@ -141,7 +141,7 @@ export const WarningItem = ({ {t('transactions.warning.from_account', { address: truncateAlgorandAddress( warning.senderAddress, - LONG_ADDRESS_FORMAT, + LONG_ADDRESS_LENGTH, ), })} diff --git a/apps/mobile/src/modules/transactions/components/TransactionWarnings/TransactionWarningsContent.tsx b/apps/mobile/src/modules/transactions/components/TransactionWarnings/TransactionWarningsContent.tsx index 9552c92f9..2fc4315c1 100644 --- a/apps/mobile/src/modules/transactions/components/TransactionWarnings/TransactionWarningsContent.tsx +++ b/apps/mobile/src/modules/transactions/components/TransactionWarnings/TransactionWarningsContent.tsx @@ -21,9 +21,11 @@ import { SheetHeader } from '@modules/bottom-sheet' import { useStyles } from './styles' import { useLanguage } from '@hooks/useLanguage' import type { PeraDisplayableTransaction } from '@perawallet/wallet-core-blockchain' -import { truncateAlgorandAddress } from '@perawallet/wallet-core-shared' +import { + LONG_ADDRESS_LENGTH, + truncateAlgorandAddress, +} from '@perawallet/wallet-core-shared' import { useTheme } from '@rneui/themed' -import { LONG_ADDRESS_FORMAT } from '@constants/ui' import { useTransactionWarnings } from './useTransactionWarnings' export type TransactionWarningsContentProps = { @@ -70,7 +72,7 @@ export const TransactionWarningsContent = ({ {t('transactions.warning.close_warning', { address: truncateAlgorandAddress( warning.targetAddress, - LONG_ADDRESS_FORMAT, + LONG_ADDRESS_LENGTH, ), })} @@ -106,7 +108,7 @@ export const TransactionWarningsContent = ({ {t('transactions.warning.rekey_warning', { address: truncateAlgorandAddress( warning.targetAddress, - LONG_ADDRESS_FORMAT, + LONG_ADDRESS_LENGTH, ), })} diff --git a/apps/mobile/vitest.setup.ts b/apps/mobile/vitest.setup.ts index ae8c79490..c1e237b62 100644 --- a/apps/mobile/vitest.setup.ts +++ b/apps/mobile/vitest.setup.ts @@ -2372,6 +2372,10 @@ vi.mock('@perawallet/wallet-core-shared', async () => { return value }, truncateAlgorandAddress: vi.fn(a => a), + SHORT_ADDRESS_LENGTH: 11, + LONG_ADDRESS_LENGTH: 20, + dedupeSecondaryLabel: (primary: string, secondary?: string | null) => + secondary && secondary !== primary ? secondary : undefined, stripUrlScheme: vi.fn(url => url), // Real implementations — serialized route params round-trip through // these, so a stub would silently corrupt every public key fixture. diff --git a/packages/accounts/src/__tests__/utils.test.ts b/packages/accounts/src/__tests__/utils.test.ts index f684a3674..d23a46cfc 100644 --- a/packages/accounts/src/__tests__/utils.test.ts +++ b/packages/accounts/src/__tests__/utils.test.ts @@ -146,6 +146,28 @@ describe('services/accounts/utils - getAccountDisplayName', () => { expect(getAccountDisplayName(acc2)).toEqual('ABCDE...VWXYZ') }) + test('falls back to the truncated address when the name is the full address', () => { + const acc = { + id: '7', + type: 'hdWallet', + address: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + name: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + canSign: true, + } as any + expect(getAccountDisplayName(acc)).toEqual('ABCDE...VWXYZ') + }) + + test('falls back to the truncated address when the name is the truncated address', () => { + const acc = { + id: '8', + type: 'hdWallet', + address: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + name: 'ABCDE...VWXYZ', + canSign: true, + } as any + expect(getAccountDisplayName(acc)).toEqual('ABCDE...VWXYZ') + }) + test('returns "No Account" when account is null', () => { expect(getAccountDisplayName(null)).toEqual('No Account') }) diff --git a/packages/accounts/src/utils.ts b/packages/accounts/src/utils.ts index 13b9ecb62..3d323d30c 100644 --- a/packages/accounts/src/utils.ts +++ b/packages/accounts/src/utils.ts @@ -31,9 +31,15 @@ import { RekeyTargetNotFoundError } from './errors' export const getAccountDisplayName = (account: Nullable) => { if (!account) return 'No Account' - if (account.name) return account.name - if (!account.address) return 'No Address Found' - return truncateAlgorandAddress(account.address) + if (!account.address) return account.name || 'No Address Found' + const truncated = truncateAlgorandAddress(account.address) + // A name that is just the account's own address — full OR truncated — isn't + // a distinguishing label. Treat it as unnamed so callers render a single + // truncated address instead of the address twice. + const isAddressName = + account.name === account.address || account.name === truncated + if (account.name && !isAddressName) return account.name + return truncated } export const isHDWalletAccount = ( diff --git a/packages/shared/src/utils/__tests__/strings.test.ts b/packages/shared/src/utils/__tests__/strings.test.ts index 03454d9d2..57f7ccbd2 100644 --- a/packages/shared/src/utils/__tests__/strings.test.ts +++ b/packages/shared/src/utils/__tests__/strings.test.ts @@ -14,6 +14,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { Decimal } from 'decimal.js' import { encodeToBase64, decodeFromBase64 } from '../strings' import { hexToBytes, bytesToHex, utf8ByteLength } from '../strings' +import { dedupeSecondaryLabel } from '../strings' import { decodeLongString, formatCurrency, @@ -26,6 +27,22 @@ import { } from '../strings' import { logger } from '../logging' +describe('utils/strings - dedupeSecondaryLabel', () => { + test('hides the secondary when it equals the primary', () => { + expect(dedupeSecondaryLabel('main.algo', 'main.algo')).toBeUndefined() + }) + + test('keeps a distinct secondary', () => { + expect(dedupeSecondaryLabel('Main', 'ABCD…WXYZ')).toBe('ABCD…WXYZ') + }) + + test('hides an empty, null, or undefined secondary', () => { + expect(dedupeSecondaryLabel('Main', '')).toBeUndefined() + expect(dedupeSecondaryLabel('Main', null)).toBeUndefined() + expect(dedupeSecondaryLabel('Main', undefined)).toBeUndefined() + }) +}) + describe('utils/strings - base64 encoding', () => { test('encodeToBase64 encodes bytes correctly', () => { const bytes = new Uint8Array([80, 82, 73, 86, 75, 69, 89]) // 'PRIVKEY' diff --git a/packages/shared/src/utils/addresses.ts b/packages/shared/src/utils/addresses.ts index d9f84763a..8d7f16761 100644 --- a/packages/shared/src/utils/addresses.ts +++ b/packages/shared/src/utils/addresses.ts @@ -10,11 +10,17 @@ limitations under the License */ -const MAX_ADDRESS_DISPLAY = 11 +/** + * Total characters retained by {@link truncateAlgorandAddress}, split evenly + * between prefix and suffix: 11 renders as `5…5`, 20 renders as `10…10`. + * Canonical source of truth — import these instead of re-declaring the length. + */ +export const SHORT_ADDRESS_LENGTH = 11 +export const LONG_ADDRESS_LENGTH = 20 export const truncateAlgorandAddress = ( address: string, - maxLength: number = MAX_ADDRESS_DISPLAY, + maxLength: number = SHORT_ADDRESS_LENGTH, ) => { const prefixLength = maxLength % 2 === 0 ? maxLength / 2 : (maxLength - 1) / 2 diff --git a/packages/shared/src/utils/strings.ts b/packages/shared/src/utils/strings.ts index e98a5b107..c4356e04b 100644 --- a/packages/shared/src/utils/strings.ts +++ b/packages/shared/src/utils/strings.ts @@ -45,6 +45,17 @@ export const bytesToHex = (bytes: Uint8Array): string => { export const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length +/** + * Drops a secondary/subtitle label when it duplicates the primary, so identity + * displays (account/contact/address name + subtitle) never show the same text + * twice. Returns `undefined` to signal "hide the secondary line". + */ +export const dedupeSecondaryLabel = ( + primary: string, + secondary: string | null | undefined, +): string | undefined => + secondary && secondary !== primary ? secondary : undefined + /** * Decode a value that may be a JS number, a string-encoded number, or null. *