From 4fce30760576bb5692c6924bfb91d3f9722fdfa9 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 16 Oct 2026 11:10:10 -0700 Subject: [PATCH 1/2] Fit the MoonPay Trade logo to the theme MoonPay Trade rows cropped the provider's full-bleed white mark to a circle, and on the light theme the white mark vanished. The CDN icons now carry the mark scaled inside the crop circle, with a black icon-light.png beside each, so both plugins join the themed swap logo set: transaction rows and details show the same fitted logo as the powered-by badge, in the color that matches the theme. --- CHANGELOG.md | 1 + .../actions/CategoriesActions.test.ts | 13 ++++++ .../hooks/useContactThumbnail.test.ts | 31 +++++++++++++ src/actions/CategoriesActions.ts | 2 - src/components/modals/ContactListModal.tsx | 14 +++++- .../scenes/TransactionDetailsScene.tsx | 5 ++- src/components/themed/TransactionListRow.tsx | 5 ++- src/constants/MerchantContacts.ts | 12 ++++- src/hooks/redux/useContactThumbnail.ts | 44 +++++++++++++++---- src/util/CdnUris.ts | 6 ++- 10 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/hooks/useContactThumbnail.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6ba62b916..77fd6cc9e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/__tests__/actions/CategoriesActions.test.ts b/src/__tests__/actions/CategoriesActions.test.ts index 32dd1c0468e..7918948342f 100644 --- a/src/__tests__/actions/CategoriesActions.test.ts +++ b/src/__tests__/actions/CategoriesActions.test.ts @@ -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() diff --git a/src/__tests__/hooks/useContactThumbnail.test.ts b/src/__tests__/hooks/useContactThumbnail.test.ts new file mode 100644 index 00000000000..3714cdefbc7 --- /dev/null +++ b/src/__tests__/hooks/useContactThumbnail.test.ts @@ -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() + }) +}) diff --git a/src/actions/CategoriesActions.ts b/src/actions/CategoriesActions.ts index a733b1793ef..4d6fbe34db1 100644 --- a/src/actions/CategoriesActions.ts +++ b/src/actions/CategoriesActions.ts @@ -780,8 +780,6 @@ const pluginIdIcons: Record = { 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', diff --git a/src/components/modals/ContactListModal.tsx b/src/components/modals/ContactListModal.tsx index 1025742c895..9417893acb0 100644 --- a/src/components/modals/ContactListModal.tsx +++ b/src/components/modals/ContactListModal.tsx @@ -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' @@ -52,12 +53,21 @@ export const ContactListModal: React.FC = ({ (company != null && company !== '' ? company : '') const label = primaryLabel ?? '' + const thumbnail = + getMerchantThumbnail(label, theme) ?? + (hasThumbnail && thumbnailPath != null + ? { uri: thumbnailPath, fit: false } + : undefined) return ( + thumbnail != null ? ( + ) : ( = 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 = diff --git a/src/components/themed/TransactionListRow.tsx b/src/components/themed/TransactionListRow.tsx index d85a9a5812f..3f6b1737e1b 100644 --- a/src/components/themed/TransactionListRow.tsx +++ b/src/components/themed/TransactionListRow.tsx @@ -182,8 +182,9 @@ const TransactionViewInner: React.FC = 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) diff --git a/src/constants/MerchantContacts.ts b/src/constants/MerchantContacts.ts index 6f6628e6622..8bc3f3e555a 100644 --- a/src/constants/MerchantContacts.ts +++ b/src/constants/MerchantContacts.ts @@ -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[] = [ @@ -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`, + 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', diff --git a/src/hooks/redux/useContactThumbnail.ts b/src/hooks/redux/useContactThumbnail.ts index 30c8152426d..0d2ed785e41 100644 --- a/src/hooks/redux/useContactThumbnail.ts +++ b/src/hooks/redux/useContactThumbnail.ts @@ -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[] = [] @@ -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 } } diff --git a/src/util/CdnUris.ts b/src/util/CdnUris.ts index e207b0cb9a6..15b6928e9ba 100644 --- a/src/util/CdnUris.ts +++ b/src/util/CdnUris.ts @@ -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(['houdini']) +const themedSwapPluginIcons = new Set([ + 'houdini', + 'mptrade', + 'mptradedefi' +]) export function hasThemedSwapPluginIcon(pluginId: string): boolean { return themedSwapPluginIcons.has(pluginId) From 01da5db6d28271ec22f205c5aeafef2177f1e242 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 16 Oct 2026 11:50:16 -0700 Subject: [PATCH 2/2] Apply the theme picked in Settings "Key radio rows on a value" (0db8346576) made RadioListModal match `selected` against each row's value and resolve with it. The Theme rows already carried the mode as their value, but the handler still passed the label as `selected` and looked the result up by label, so neither the checkmark nor the pick worked. Match on the value instead. --- src/__tests__/scenes/SettingsScene.test.tsx | 36 +++++++++++++++++++-- src/components/scenes/SettingsScene.tsx | 20 +++++------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/__tests__/scenes/SettingsScene.test.tsx b/src/__tests__/scenes/SettingsScene.test.tsx index 2161ce2ba87..d3e7dc8bb24 100644 --- a/src/__tests__/scenes/SettingsScene.test.tsx +++ b/src/__tests__/scenes/SettingsScene.test.tsx @@ -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' @@ -22,6 +26,10 @@ describe('SettingsScene', () => { } } + afterEach(() => { + jest.restoreAllMocks() + }) + it('should render SettingsScene', () => { const rendered = render( @@ -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( + + + + ) + fireEvent.press(rendered.getByText(lstrings.settings_theme)) + await waitFor(() => { + expect(write).toHaveBeenCalledWith('light') + }) + rendered.unmount() + }) }) diff --git a/src/components/scenes/SettingsScene.tsx b/src/components/scenes/SettingsScene.tsx index f8395ead423..140baec17d6 100644 --- a/src/components/scenes/SettingsScene.tsx +++ b/src/components/scenes/SettingsScene.tsx @@ -290,26 +290,22 @@ export const SettingsScene: React.FC = 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(bridge => ( )) - 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 + if (newMode != null && newMode !== themeMode) { + setThemeMode(newMode) + applyTheme(newMode) + await writeThemeMode(newMode) } })