Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
- fixed: Stealth Send note no longer shows under the toggle on a plain send
- fixed: Android app icon showing as a blank dark circle when drawn small, such as on Wear OS watch notifications
- fixed: Stealth Swap transactions titled "Swap Funds" instead of "Stealth Swap"
- fixed: MoonPay Trade logo cropped in transactions and invisible on the light theme
- removed: LetsExchange swap provider

## 4.51.3 (2026-09-30)
Expand Down
13 changes: 13 additions & 0 deletions src/__tests__/actions/CategoriesActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,19 @@ describe('getPluginIdIcon', () => {
expect(getPluginIdIcon('lifi', edgeLight)).toEqual(lifiIcon)
})

it('gives MoonPay Trade its themed swap logo, fitted', () => {
for (const pluginId of ['mptrade', 'mptradedefi']) {
expect(getPluginIdIcon(pluginId, edgeDark)).toEqual({
uri: `https://content.edge.app/exchangeIcons/${pluginId}/icon.png`,
fit: true
})
expect(getPluginIdIcon(pluginId, edgeLight)).toEqual({
uri: `https://content.edge.app/exchangeIcons/${pluginId}/icon-light.png`,
fit: true
})
}
})

it('has no logo for an unknown or missing provider', () => {
expect(getPluginIdIcon('notAProvider', edgeDark)).toBeUndefined()
expect(getPluginIdIcon(undefined, edgeDark)).toBeUndefined()
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/hooks/useContactThumbnail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from '@jest/globals'

import { getMerchantThumbnail } from '../../hooks/redux/useContactThumbnail'
import { edgeDark } from '../../theme/variables/edgeDark'
import { edgeLight } from '../../theme/variables/edgeLight'

describe('getMerchantThumbnail', () => {
it('gives a MoonPay Trade merchant its themed swap logo, fitted', () => {
expect(getMerchantThumbnail('MoonPay Trade (DeFi)', edgeDark)).toEqual({
uri: 'https://content.edge.app/exchangeIcons/mptradedefi/icon.png',
fit: true
})
expect(
getMerchantThumbnail('MoonPay Trade (Centralized)', edgeLight)
).toEqual({
uri: 'https://content.edge.app/exchangeIcons/mptrade/icon-light.png',
fit: true
})
})

it('keeps the cropped image for every other merchant', () => {
expect(getMerchantThumbnail('Bitrefill', edgeLight)).toEqual({
uri: 'https://content.edge.app/bitrefill.png',
fit: false
})
})

it('has no thumbnail for a name no merchant matches', () => {
expect(getMerchantThumbnail('Alice', edgeDark)).toBeUndefined()
})
})
36 changes: 34 additions & 2 deletions src/__tests__/scenes/SettingsScene.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { describe, expect, it } from '@jest/globals'
import { render } from '@testing-library/react-native'
import { afterEach, describe, expect, it, jest } from '@jest/globals'
import { fireEvent, render, waitFor } from '@testing-library/react-native'
import * as React from 'react'

import * as DeviceSettingsActions from '../../actions/DeviceSettingsActions'
import { SettingsScene } from '../../components/scenes/SettingsScene'
import { Airship } from '../../components/services/AirshipInstance'
import { lstrings } from '../../locales/strings'
import { initialState as initialSettings } from '../../reducers/scenes/SettingsReducer'
import { FakeProviders, type FakeState } from '../../util/fake/FakeProviders'
import { fakeEdgeAppSceneProps } from '../../util/fake/fakeSceneProps'

Expand All @@ -22,6 +26,10 @@ describe('SettingsScene', () => {
}
}

afterEach(() => {
jest.restoreAllMocks()
})

it('should render SettingsScene', () => {
const rendered = render(
<FakeProviders initialState={mockState}>
Expand All @@ -34,4 +42,28 @@ describe('SettingsScene', () => {
expect(rendered.toJSON()).toMatchSnapshot()
rendered.unmount()
})

it('saves the mode picked in the theme modal', async () => {
const developerState: FakeState = {
...mockState,
ui: { settings: { ...initialSettings, developerModeOn: true } }
}
const write = jest
.spyOn(DeviceSettingsActions, 'writeThemeMode')
.mockResolvedValue(undefined)
jest.spyOn(Airship, 'show').mockResolvedValue('light' as never)

const rendered = render(
<FakeProviders initialState={developerState}>
<SettingsScene
{...fakeEdgeAppSceneProps('settingsOverview', undefined)}
/>
</FakeProviders>
)
fireEvent.press(rendered.getByText(lstrings.settings_theme))
await waitFor(() => {
expect(write).toHaveBeenCalledWith('light')
})
rendered.unmount()
})
})
2 changes: 0 additions & 2 deletions src/actions/CategoriesActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,6 @@ const pluginIdIcons: Record<string, string> = {
letsexchange: EDGE_CONTENT_SERVER_URI + '/letsexchange-logo.png',
lifi: EDGE_CONTENT_SERVER_URI + '/lifi.png',
mayaprotocol: EDGE_CONTENT_SERVER_URI + '/mayaprotocol.png',
mptrade: EDGE_CONTENT_SERVER_URI + '/exchangeIcons/mptrade/icon.png',
mptradedefi: EDGE_CONTENT_SERVER_URI + '/exchangeIcons/mptradedefi/icon.png',
nexchange: EDGE_CONTENT_SERVER_URI + '/exchangeIcons/nexchange/icon.png',
nymswap: EDGE_CONTENT_SERVER_URI + '/exchangeIcons/nymswap/icon.png',
rango: EDGE_CONTENT_SERVER_URI + '/rango.png',
Expand Down
14 changes: 12 additions & 2 deletions src/components/modals/ContactListModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AirshipBridge } from 'react-native-airship'
import IonIcon from 'react-native-vector-icons/Ionicons'
import { sprintf } from 'sprintf-js'

import { getMerchantThumbnail } from '../../hooks/redux/useContactThumbnail'
import { useAsyncEffect } from '../../hooks/useAsyncEffect'
import { lstrings } from '../../locales/strings'
import { useDispatch, useSelector } from '../../types/reactRedux'
Expand Down Expand Up @@ -52,12 +53,21 @@ export const ContactListModal: React.FC<Props> = ({
(company != null && company !== '' ? company : '')

const label = primaryLabel ?? ''
const thumbnail =
getMerchantThumbnail(label, theme) ??
(hasThumbnail && thumbnailPath != null
? { uri: thumbnailPath, fit: false }
: undefined)

return (
<SelectableRow
icon={
hasThumbnail && thumbnailPath != null ? (
<Image style={styles.image} source={{ uri: thumbnailPath }} />
thumbnail != null ? (
<Image
style={styles.image}
source={{ uri: thumbnail.uri }}
resizeMode={thumbnail.fit ? 'contain' : 'cover'}
/>
) : (
<IonIcon
style={styles.tileAvatarIcon}
Expand Down
20 changes: 8 additions & 12 deletions src/components/scenes/SettingsScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,26 +290,22 @@ export const SettingsScene: React.FC<Props> = props => {
value: mode
}))

// Rows carry the mode as their value, so the modal matches `selected`
// against it and resolves with it:
const result = await Airship.show<string | undefined>(bridge => (
<RadioListModal
bridge={bridge}
title={lstrings.settings_theme}
items={items}
selected={themeModeLabels[themeMode]}
selected={themeMode}
/>
))

if (result != null) {
// Find the mode by label
const newMode = Object.entries(themeModeLabels).find(
([, label]) => label === result
)?.[0] as ThemeMode | undefined

if (newMode != null && newMode !== themeMode) {
setThemeMode(newMode)
applyTheme(newMode)
await writeThemeMode(newMode)
}
const newMode = items.find(item => item.value === result)?.value
Comment thread
j0ntz marked this conversation as resolved.
if (newMode != null && newMode !== themeMode) {
setThemeMode(newMode)
applyTheme(newMode)
await writeThemeMode(newMode)
}
})

Expand Down
5 changes: 3 additions & 2 deletions src/components/scenes/TransactionDetailsScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,9 @@ export const TransactionDetailsComponent: React.FC<Props> = props => {

const contactThumbnail = useContactThumbnail(mergedData.name)
const pluginIdIcon = getPluginIdIcon(iconPluginId, theme)
const thumbnailPath = contactThumbnail ?? pluginIdIcon?.uri
const fitThumbnail = contactThumbnail == null && pluginIdIcon?.fit === true
const thumbnail = contactThumbnail ?? pluginIdIcon
const thumbnailPath = thumbnail?.uri
const fitThumbnail = thumbnail?.fit === true

// Check if this is a gift card transaction
const giftCardAction =
Expand Down
5 changes: 3 additions & 2 deletions src/components/themed/TransactionListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ const TransactionViewInner: React.FC<TransactionViewInnerProps> = props => {
// Icon & Thumbnail
const contactThumbnail = useContactThumbnail(name)
const pluginIdIcon = getPluginIdIcon(iconPluginId, theme)
const thumbnailPath = contactThumbnail ?? pluginIdIcon?.uri
const fitThumbnail = contactThumbnail == null && pluginIdIcon?.fit === true
const thumbnail = contactThumbnail ?? pluginIdIcon
const thumbnailPath = thumbnail?.uri
const fitThumbnail = thumbnail?.fit === true
if (thumbnailPath != null) {
arrowIconSize = theme.rem(1)
arrowContainerStyle.push(styles.arrowIconOverlayContainer)
Expand Down
12 changes: 10 additions & 2 deletions src/constants/MerchantContacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { EDGE_CONTENT_SERVER_URI } from './CdnConstants'
export interface MerchantContact {
displayName: string
thumbnailPath: string

/**
* The swap provider behind this merchant, when its transaction thumbnail
* should be the provider's themed logo rather than `thumbnailPath`.
*/
pluginId?: string
}

export const MERCHANT_CONTACTS: MerchantContact[] = [
Expand Down Expand Up @@ -68,11 +74,13 @@ export const MERCHANT_CONTACTS: MerchantContact[] = [
},
{
displayName: 'MoonPay Trade (Centralized)',
thumbnailPath: `${EDGE_CONTENT_SERVER_URI}/exchangeIcons/mptrade/icon.png`
thumbnailPath: `${EDGE_CONTENT_SERVER_URI}/exchangeIcons/mptrade/icon.png`,
Comment thread
j0ntz marked this conversation as resolved.
pluginId: 'mptrade'
},
{
displayName: 'MoonPay Trade (DeFi)',
thumbnailPath: `${EDGE_CONTENT_SERVER_URI}/exchangeIcons/mptradedefi/icon.png`
thumbnailPath: `${EDGE_CONTENT_SERVER_URI}/exchangeIcons/mptradedefi/icon.png`,
pluginId: 'mptradedefi'
},
{
displayName: 'ShapeShift',
Expand Down
44 changes: 35 additions & 9 deletions src/hooks/redux/useContactThumbnail.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import * as React from 'react'

import {
getPluginIdIcon,
type PluginIdIcon
} from '../../actions/CategoriesActions'
import { useTheme } from '../../components/services/ThemeContext'
import { MERCHANT_CONTACTS } from '../../constants/MerchantContacts'
import { useSelector } from '../../types/reactRedux'
import type { Theme } from '../../types/Theme'
import { normalizeForSearch } from '../../util/utils'

/**
* Looks up a thumbnail image for a contact using existing contacts data.
*/
export const useContactThumbnail = (name?: string): string | undefined => {
export const useContactThumbnail = (
name?: string
): PluginIdIcon | undefined => {
const contacts = useSelector(state => state.contacts)
const theme = useTheme()

return React.useMemo(() => {
if (name == null) return

const searchName = normalizeForSearch(name)

// First: Try merchant "contacts" (priority). Return on first match.
for (const merchant of MERCHANT_CONTACTS) {
if (normalizeForSearch(merchant.displayName) === searchName) {
return merchant.thumbnailPath
}
}
// First: Try merchant "contacts" (priority).
const merchantThumbnail = getMerchantThumbnail(name, theme)
if (merchantThumbnail != null) return merchantThumbnail

// Second: Try device contacts, returning only if no ambiguity.
const matchingThumbnails: string[] = []
Expand Down Expand Up @@ -49,7 +55,27 @@ export const useContactThumbnail = (name?: string): string | undefined => {
}

// Ambiguity rule: If multiple contacts match, do not show an icon.
if (matchingThumbnails.length === 1) return matchingThumbnails[0]
if (matchingThumbnails.length === 1) {
return { uri: matchingThumbnails[0], fit: false }
}
return undefined
}, [contacts, name])
}, [contacts, name, theme])
}

/**
* The thumbnail for the first merchant whose name matches. A merchant backed
* by a swap provider gets that provider's row logo, so it matches the
* provider's other transaction thumbnails.
*/
export function getMerchantThumbnail(
name: string,
theme: Theme
): PluginIdIcon | undefined {
const searchName = normalizeForSearch(name)
const merchant = MERCHANT_CONTACTS.find(
contact => normalizeForSearch(contact.displayName) === searchName
)
if (merchant == null) return
const { pluginId, thumbnailPath } = merchant
return getPluginIdIcon(pluginId, theme) ?? { uri: thumbnailPath, fit: false }
}
6 changes: 5 additions & 1 deletion src/util/CdnUris.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export const getBorrowPluginIconUri = (
* Swap plugins whose icon is a single-color mark on a transparent background.
* The CDN has an `icon-light.png` beside their `icon.png` for light themes.
*/
const themedSwapPluginIcons = new Set<string>(['houdini'])
const themedSwapPluginIcons = new Set<string>([
'houdini',
'mptrade',
'mptradedefi'
Comment thread
j0ntz marked this conversation as resolved.
])

export function hasThemedSwapPluginIcon(pluginId: string): boolean {
return themedSwapPluginIcons.has(pluginId)
Expand Down
Loading