diff --git a/components/ListItem.tsx b/components/ListItem.tsx index de781c26d..bc507532f 100644 --- a/components/ListItem.tsx +++ b/components/ListItem.tsx @@ -21,7 +21,7 @@ interface ListItemProps { switch?: object; // Define more specific type if needed leftIcon?: any; // Define more specific type if needed title: string; - subtitle?: string; + subtitle?: string | React.ReactNode; subtitleNumberOfLines?: number; rightTitle?: string; rightTitleStyle?: object; diff --git a/components/TransactionListItem.tsx b/components/TransactionListItem.tsx index b3a13be03..ad7403df5 100644 --- a/components/TransactionListItem.tsx +++ b/components/TransactionListItem.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import Clipboard from '@react-native-clipboard/clipboard'; -import { Linking, View } from 'react-native'; +import { Linking, View, ViewStyle } from 'react-native'; import Lnurl from '../class/lnurl'; import { LightningTransaction, Transaction } from '../class/wallets/types'; import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon'; @@ -23,312 +23,321 @@ import { DetailViewStackParamList } from '../navigation/DetailViewStackParamList import { useStorage } from '../hooks/context/useStorage'; import ToolTipMenu from './TooltipMenu'; import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import { pop } from '../NavigationService'; interface TransactionListItemProps { itemPriceUnit: BitcoinUnit; walletID: string; item: Transaction & LightningTransaction; // using type intersection to have less issues with ts + searchQuery?: string; + style?: ViewStyle; + renderHighlightedText?: (text: string, query: string) => JSX.Element; } type NavigationProps = NativeStackNavigationProp; -export const TransactionListItem: React.FC = React.memo(({ item, itemPriceUnit = BitcoinUnit.BTC, walletID }) => { - const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1); - const { colors } = useTheme(); - const { navigate } = useExtendedNavigation(); - const menuRef = useRef(); - const { txMetadata, counterpartyMetadata, wallets } = useStorage(); - const { language } = useSettings(); - const containerStyle = useMemo( - () => ({ - backgroundColor: 'transparent', - borderBottomColor: colors.lightBorder, - }), - [colors.lightBorder], - ); +export const TransactionListItem: React.FC = React.memo( + ({ item, itemPriceUnit = BitcoinUnit.BTC, walletID, searchQuery, style, renderHighlightedText }) => { + const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1); + const { colors } = useTheme(); + const { navigate } = useExtendedNavigation(); + const menuRef = useRef(); + const { txMetadata, counterpartyMetadata, wallets } = useStorage(); + const { language } = useSettings(); + const containerStyle = useMemo( + () => ({ + backgroundColor: 'transparent', + borderBottomColor: colors.lightBorder, + }), + [colors.lightBorder], + ); - const shortenContactName = (name: string): string => { - if (name.length < 16) return name; - return name.substr(0, 7) + '...' + name.substr(name.length - 7, 7); - }; + const combinedStyle = useMemo(() => [containerStyle, style], [containerStyle, style]); - const title = useMemo(() => { - if (item.confirmations === 0) { - return loc.transactions.pending; - } else { - return transactionTimeToReadable(item.received!); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item.confirmations, item.received, language]); + const shortenContactName = (name: string): string => { + if (name.length < 16) return name; + return name.substr(0, 7) + '...' + name.substr(name.length - 7, 7); + }; - let counterparty; - if (item.counterparty) { - counterparty = counterpartyMetadata?.[item.counterparty]?.label ?? item.counterparty; - } - const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? ''); - const subtitle = useMemo(() => { - let sub = Number(item.confirmations) < 7 ? loc.formatString(loc.transactions.list_conf, { number: item.confirmations }) : ''; - if (sub !== '') sub += ' '; - sub += txMemo; - if (item.memo) sub += item.memo; - return sub || undefined; - }, [txMemo, item.confirmations, item.memo]); - - const rowTitle = useMemo(() => { - if (item.type === 'user_invoice' || item.type === 'payment_request') { - if (isNaN(Number(item.value))) { - item.value = 0; - } - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = item.timestamp! + item.expire_time!; - - if (invoiceExpiration > now) { - return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); + const title = useMemo(() => { + if (item.confirmations === 0) { + return loc.transactions.pending; } else { - if (item.ispaid) { + return transactionTimeToReadable(item.received!); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [item.confirmations, item.received, language]); + + let counterparty; + if (item.counterparty) { + counterparty = counterpartyMetadata?.[item.counterparty]?.label ?? item.counterparty; + } + const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? ''); + const subtitle = useMemo(() => { + let sub = Number(item.confirmations) < 7 ? loc.formatString(loc.transactions.list_conf, { number: item.confirmations }) : ''; + if (sub !== '') sub += ' '; + sub += txMemo; + if (item.memo) sub += item.memo; + return sub || undefined; + }, [txMemo, item.confirmations, item.memo]); + + const rowTitle = useMemo(() => { + if (item.type === 'user_invoice' || item.type === 'payment_request') { + if (isNaN(Number(item.value))) { + item.value = 0; + } + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise + const invoiceExpiration = item.timestamp! + item.expire_time!; + + if (invoiceExpiration > now) { return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); } else { - return loc.lnd.expired; + if (item.ispaid) { + return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); + } else { + return loc.lnd.expired; + } } + } else { + return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); } - } else { - return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); - } - }, [item, itemPriceUnit]); + }, [item, itemPriceUnit]); - const rowTitleStyle = useMemo(() => { - let color = colors.successColor; + const rowTitleStyle = useMemo(() => { + let color = colors.successColor; - if (item.type === 'user_invoice' || item.type === 'payment_request') { - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = item.timestamp! + item.expire_time!; + if (item.type === 'user_invoice' || item.type === 'payment_request') { + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise + const invoiceExpiration = item.timestamp! + item.expire_time!; - if (invoiceExpiration > now) { - color = colors.successColor; - } else if (invoiceExpiration < now) { - if (item.ispaid) { + if (invoiceExpiration > now) { color = colors.successColor; + } else if (invoiceExpiration < now) { + if (item.ispaid) { + color = colors.successColor; + } else { + color = '#9AA0AA'; + } + } + } else if (item.value! / 100000000 < 0) { + color = colors.foregroundColor; + } + + return { + color, + fontSize: 14, + fontWeight: '600', + textAlign: 'right', + }; + }, [item, colors.foregroundColor, colors.successColor]); + + const determineTransactionTypeAndAvatar = () => { + if (item.category === 'receive' && item.confirmations! < 3) { + return { + label: loc.transactions.pending_transaction, + icon: , + }; + } + + if (item.type && item.type === 'bitcoind_tx') { + return { + label: loc.transactions.onchain, + icon: , + }; + } + + if (item.type === 'paid_invoice') { + return { + label: loc.transactions.offchain, + icon: , + }; + } + + if (item.type === 'user_invoice' || item.type === 'payment_request') { + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise + const invoiceExpiration = item.timestamp! + item.expire_time!; + if (!item.ispaid && invoiceExpiration < now) { + return { + label: loc.transactions.expired_transaction, + icon: , + }; } else { - color = '#9AA0AA'; + return { + label: loc.transactions.incoming_transaction, + icon: , + }; } } - } else if (item.value! / 100000000 < 0) { - color = colors.foregroundColor; - } - return { - color, - fontSize: 14, - fontWeight: '600', - textAlign: 'right', - }; - }, [item, colors.foregroundColor, colors.successColor]); - - const determineTransactionTypeAndAvatar = () => { - if (item.category === 'receive' && item.confirmations! < 3) { - return { - label: loc.transactions.pending_transaction, - icon: , - }; - } - - if (item.type && item.type === 'bitcoind_tx') { - return { - label: loc.transactions.onchain, - icon: , - }; - } - - if (item.type === 'paid_invoice') { - return { - label: loc.transactions.offchain, - icon: , - }; - } - - if (item.type === 'user_invoice' || item.type === 'payment_request') { - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = item.timestamp! + item.expire_time!; - if (!item.ispaid && invoiceExpiration < now) { + if (!item.confirmations) { return { - label: loc.transactions.expired_transaction, - icon: , + label: loc.transactions.pending_transaction, + icon: , + }; + } else if (item.value! < 0) { + return { + label: loc.transactions.outgoing_transaction, + icon: , }; } else { return { label: loc.transactions.incoming_transaction, - icon: , + icon: , }; } - } - - if (!item.confirmations) { - return { - label: loc.transactions.pending_transaction, - icon: , - }; - } else if (item.value! < 0) { - return { - label: loc.transactions.outgoing_transaction, - icon: , - }; - } else { - return { - label: loc.transactions.incoming_transaction, - icon: , - }; - } - }; - - const { label: transactionTypeLabel, icon: avatar } = determineTransactionTypeAndAvatar(); - - const amountWithUnit = useMemo(() => { - const amount = formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); - const unit = itemPriceUnit === BitcoinUnit.BTC || itemPriceUnit === BitcoinUnit.SATS ? ` ${itemPriceUnit}` : ' '; - return `${amount}${unit}`; - }, [item.value, itemPriceUnit]); - - useEffect(() => { - setSubtitleNumberOfLines(1); - }, [subtitle]); - - const onPress = useCallback(async () => { - menuRef?.current?.dismissMenu?.(); - if (item.hash) { - navigate('TransactionStatus', { hash: item.hash, walletID }); - } else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') { - const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID); - if (lightningWallet.length === 1) { - try { - // is it a successful lnurl-pay? - const LN = new Lnurl(false, AsyncStorage); - let paymentHash = item.payment_hash!; - if (typeof paymentHash === 'object') { - paymentHash = Buffer.from(paymentHash.data).toString('hex'); - } - const loaded = await LN.loadSuccessfulPayment(paymentHash); - if (loaded) { - navigate('ScanLndInvoiceRoot', { - screen: 'LnurlPaySuccess', - params: { - paymentHash, - justPaid: false, - fromWalletID: lightningWallet[0].getID(), - }, - }); - return; - } - } catch (e) { - console.log(e); - } - - navigate('LNDViewInvoice', { - invoice: item, - walletID: lightningWallet[0].getID(), - }); - } - } - }, [item, wallets, navigate, walletID]); - - const handleOnExpandNote = useCallback(() => { - setSubtitleNumberOfLines(0); - }, []); - - const subtitleProps = useMemo(() => ({ numberOfLines: subtitleNumberOfLines }), [subtitleNumberOfLines]); - - const handleOnCopyAmountTap = useCallback(() => Clipboard.setString(rowTitle.replace(/[\s\\-]/g, '')), [rowTitle]); - const handleOnCopyTransactionID = useCallback(() => Clipboard.setString(item.hash), [item.hash]); - const handleOnCopyNote = useCallback(() => Clipboard.setString(subtitle ?? ''), [subtitle]); - const handleOnViewOnBlockExplorer = useCallback(() => { - const url = `https://mempool.space/tx/${item.hash}`; - Linking.canOpenURL(url).then(supported => { - if (supported) { - Linking.openURL(url); - } - }); - }, [item.hash]); - const handleCopyOpenInBlockExplorerPress = useCallback(() => { - Clipboard.setString(`https://mempool.space/tx/${item.hash}`); - }, [item.hash]); - - const onToolTipPress = useCallback( - (id: any) => { - if (id === CommonToolTipActions.CopyAmount.id) { - handleOnCopyAmountTap(); - } else if (id === CommonToolTipActions.CopyNote.id) { - handleOnCopyNote(); - } else if (id === CommonToolTipActions.OpenInBlockExplorer.id) { - handleOnViewOnBlockExplorer(); - } else if (id === CommonToolTipActions.ExpandNote.id) { - handleOnExpandNote(); - } else if (id === CommonToolTipActions.CopyBlockExplorerLink.id) { - handleCopyOpenInBlockExplorerPress(); - } else if (id === CommonToolTipActions.CopyTXID.id) { - handleOnCopyTransactionID(); - } - }, - [ - handleCopyOpenInBlockExplorerPress, - handleOnCopyAmountTap, - handleOnCopyNote, - handleOnCopyTransactionID, - handleOnExpandNote, - handleOnViewOnBlockExplorer, - ], - ); - const toolTipActions = useMemo((): Action[] | Action[][] => { - const actions: (Action | Action[])[] = []; - - if (rowTitle !== loc.lnd.expired) { - actions.push(CommonToolTipActions.CopyAmount); - } - - if (subtitle) { - actions.push(CommonToolTipActions.CopyNote); - } - - if (item.hash) { - actions.push(CommonToolTipActions.CopyTXID, CommonToolTipActions.CopyBlockExplorerLink, [CommonToolTipActions.OpenInBlockExplorer]); - } - - if (subtitle && subtitleNumberOfLines === 1) { - actions.push([CommonToolTipActions.ExpandNote]); - } - - return actions as Action[] | Action[][]; - }, [item.hash, subtitle, rowTitle, subtitleNumberOfLines]); - - const accessibilityState = useMemo(() => { - return { - expanded: subtitleNumberOfLines === 0, }; - }, [subtitleNumberOfLines]); - return ( - - - - ); -}); + const { label: transactionTypeLabel, icon: avatar } = determineTransactionTypeAndAvatar(); + + const amountWithUnit = useMemo(() => { + const amount = formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); + const unit = itemPriceUnit === BitcoinUnit.BTC || itemPriceUnit === BitcoinUnit.SATS ? ` ${itemPriceUnit}` : ' '; + return `${amount}${unit}`; + }, [item.value, itemPriceUnit]); + + useEffect(() => { + setSubtitleNumberOfLines(1); + }, [subtitle]); + + const onPress = useCallback(async () => { + menuRef?.current?.dismissMenu?.(); + if (item.hash) { + pop(); + navigate('TransactionStatus', { hash: item.hash, walletID }); + } else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') { + const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID); + if (lightningWallet.length === 1) { + try { + // is it a successful lnurl-pay? + const LN = new Lnurl(false, AsyncStorage); + let paymentHash = item.payment_hash!; + if (typeof paymentHash === 'object') { + paymentHash = Buffer.from(paymentHash.data).toString('hex'); + } + const loaded = await LN.loadSuccessfulPayment(paymentHash); + if (loaded) { + navigate('ScanLndInvoiceRoot', { + screen: 'LnurlPaySuccess', + params: { + paymentHash, + justPaid: false, + fromWalletID: lightningWallet[0].getID(), + }, + }); + return; + } + } catch (e) { + console.debug(e); + } + + navigate('LNDViewInvoice', { + invoice: item, + walletID: lightningWallet[0].getID(), + }); + } + } + }, [item, wallets, navigate, walletID]); + + const handleOnExpandNote = useCallback(() => { + setSubtitleNumberOfLines(0); + }, []); + + const subtitleProps = useMemo(() => ({ numberOfLines: subtitleNumberOfLines }), [subtitleNumberOfLines]); + + const handleOnCopyAmountTap = useCallback(() => Clipboard.setString(rowTitle.replace(/[\s\\-]/g, '')), [rowTitle]); + const handleOnCopyTransactionID = useCallback(() => Clipboard.setString(item.hash), [item.hash]); + const handleOnCopyNote = useCallback(() => Clipboard.setString(subtitle ?? ''), [subtitle]); + const handleOnViewOnBlockExplorer = useCallback(() => { + const url = `https://mempool.space/tx/${item.hash}`; + Linking.canOpenURL(url).then(supported => { + if (supported) { + Linking.openURL(url); + } + }); + }, [item.hash]); + const handleCopyOpenInBlockExplorerPress = useCallback(() => { + Clipboard.setString(`https://mempool.space/tx/${item.hash}`); + }, [item.hash]); + + const onToolTipPress = useCallback( + (id: any) => { + if (id === CommonToolTipActions.CopyAmount.id) { + handleOnCopyAmountTap(); + } else if (id === CommonToolTipActions.CopyNote.id) { + handleOnCopyNote(); + } else if (id === CommonToolTipActions.OpenInBlockExplorer.id) { + handleOnViewOnBlockExplorer(); + } else if (id === CommonToolTipActions.ExpandNote.id) { + handleOnExpandNote(); + } else if (id === CommonToolTipActions.CopyBlockExplorerLink.id) { + handleCopyOpenInBlockExplorerPress(); + } else if (id === CommonToolTipActions.CopyTXID.id) { + handleOnCopyTransactionID(); + } + }, + [ + handleCopyOpenInBlockExplorerPress, + handleOnCopyAmountTap, + handleOnCopyNote, + handleOnCopyTransactionID, + handleOnExpandNote, + handleOnViewOnBlockExplorer, + ], + ); + const toolTipActions = useMemo((): Action[] | Action[][] => { + const actions: (Action | Action[])[] = []; + + if (rowTitle !== loc.lnd.expired) { + actions.push(CommonToolTipActions.CopyAmount); + } + + if (subtitle) { + actions.push(CommonToolTipActions.CopyNote); + } + + if (item.hash) { + actions.push(CommonToolTipActions.CopyTXID, CommonToolTipActions.CopyBlockExplorerLink, [CommonToolTipActions.OpenInBlockExplorer]); + } + + if (subtitle && subtitleNumberOfLines === 1) { + actions.push([CommonToolTipActions.ExpandNote]); + } + + return actions as Action[] | Action[][]; + }, [item.hash, subtitle, rowTitle, subtitleNumberOfLines]); + + const accessibilityState = useMemo(() => { + return { + expanded: subtitleNumberOfLines === 0, + }; + }, [subtitleNumberOfLines]); + + return ( + + + + ); + }, +); diff --git a/components/WalletsCarousel.tsx b/components/WalletsCarousel.tsx index 07fca7eaf..c358b2dc4 100644 --- a/components/WalletsCarousel.tsx +++ b/components/WalletsCarousel.tsx @@ -8,7 +8,6 @@ import { Pressable, StyleSheet, Text, - TouchableOpacity, useWindowDimensions, View, FlatListProps, @@ -73,12 +72,7 @@ const NewWalletPanel: React.FC = ({ onPress }) => { }); return ( - + = ({ onPress }) => { {loc.wallets.list_create_a_button} - + ); }; @@ -105,7 +99,8 @@ interface WalletCarouselItemProps { customStyle?: ViewStyle; horizontal?: boolean; isActive?: boolean; - allowOnPressAnimation?: boolean; + searchQuery?: string; + renderHighlightedText?: (text: string, query: string) => JSX.Element; } const iStyles = StyleSheet.create({ @@ -168,7 +163,7 @@ const iStyles = StyleSheet.create({ }); export const WalletCarouselItem: React.FC = React.memo( - ({ item, onPress, handleLongPress, isSelectedWallet, customStyle, horizontal, allowOnPressAnimation = true }) => { + ({ item, onPress, handleLongPress, isSelectedWallet, customStyle, horizontal, searchQuery, renderHighlightedText }) => { const scaleValue = useRef(new Animated.Value(1.0)).current; const { colors } = useTheme(); const { walletTransactionUpdateStatus } = useStorage(); @@ -233,8 +228,8 @@ export const WalletCarouselItem: React.FC = React.memo( { if (handleLongPress) handleLongPress(); }} @@ -245,7 +240,7 @@ export const WalletCarouselItem: React.FC = React.memo( - {item.getLabel()} + {renderHighlightedText && searchQuery ? renderHighlightedText(item.getLabel(), searchQuery) : item.getLabel()} {item.hideBalance ? ( @@ -287,6 +282,8 @@ interface WalletsCarouselProps extends Partial> { handleLongPress?: () => void; data: TWallet[]; scrollEnabled?: boolean; + searchQuery?: string; + renderHighlightedText?: (text: string, query: string) => JSX.Element; } type FlatListRefType = FlatList & { @@ -315,7 +312,17 @@ const cStyles = StyleSheet.create({ const ListHeaderComponent: React.FC = () => ; const WalletsCarousel = forwardRef((props, ref) => { - const { horizontal, data, handleLongPress, onPress, selectedWallet, scrollEnabled, onNewWalletPress } = props; + const { + horizontal, + data, + handleLongPress, + onPress, + selectedWallet, + scrollEnabled, + onNewWalletPress, + searchQuery, + renderHighlightedText, + } = props; const renderItem = useCallback( ({ item, index }: ListRenderItemInfo) => item ? ( @@ -325,9 +332,11 @@ const WalletsCarousel = forwardRef((props handleLongPress={handleLongPress} onPress={onPress} horizontal={horizontal} + searchQuery={searchQuery} + renderHighlightedText={renderHighlightedText} /> ) : null, - [horizontal, selectedWallet, handleLongPress, onPress], + [horizontal, selectedWallet, handleLongPress, onPress, searchQuery, renderHighlightedText], ); const flatListRef = useRef>(null); @@ -401,6 +410,8 @@ const WalletsCarousel = forwardRef((props handleLongPress={handleLongPress} onPress={onPress} key={index} + searchQuery={props.searchQuery} + renderHighlightedText={props.renderHighlightedText} /> ) : null, )} diff --git a/hooks/useBounceAnimation.ts b/hooks/useBounceAnimation.ts new file mode 100644 index 000000000..461346555 --- /dev/null +++ b/hooks/useBounceAnimation.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; +import { Animated } from 'react-native'; + +const useBounceAnimation = (query: string) => { + const bounceAnim = useRef(new Animated.Value(1.0)).current; + + useEffect(() => { + if (query) { + Animated.timing(bounceAnim, { + toValue: 1.2, + duration: 150, + useNativeDriver: true, + }).start(() => { + Animated.timing(bounceAnim, { + toValue: 1.0, + duration: 150, + useNativeDriver: true, + }).start(); + }); + } + }, [bounceAnim, query]); + + return bounceAnim; +}; + +export default useBounceAnimation; diff --git a/loc/en.json b/loc/en.json index 5171d3600..5c22294d9 100644 --- a/loc/en.json +++ b/loc/en.json @@ -467,7 +467,8 @@ "list_tryagain": "Try again", "no_ln_wallet_error": "Before paying a Lightning invoice, you must first add a Lightning wallet.", "looks_like_bip38": "This looks like a password-protected private key (BIP38).", - "reorder_title": "Re-order Wallets", + "manage_title": "Manage Wallets", + "no_results_found": "No results found.", "please_continue_scanning": "Please continue scanning.", "select_no_bitcoin": "There are currently no Bitcoin wallets available.", "select_no_bitcoin_exp": "A Bitcoin wallet is required to refill Lightning wallets. Please create or import one.", @@ -478,7 +479,7 @@ "add_ln_wallet_first": "You must first add a Lightning wallet.", "identity_pubkey": "Identity Pubkey", "xpub_title": "Wallet XPUB", - "search_wallets": "Search Wallets" + "manage_wallets_search_placeholder": "Search wallets, memos" }, "multisig": { "multisig_vault": "Vault", diff --git a/navigation/DetailViewScreensStack.tsx b/navigation/DetailViewScreensStack.tsx index 91f9e4829..32f760506 100644 --- a/navigation/DetailViewScreensStack.tsx +++ b/navigation/DetailViewScreensStack.tsx @@ -52,7 +52,6 @@ import { import PaymentCodesListComponent from './LazyLoadPaymentCodeStack'; import LNDCreateInvoiceRoot from './LNDCreateInvoiceStack'; import ReceiveDetailsStackRoot from './ReceiveDetailsStack'; -import ReorderWalletsStackRoot from './ReorderWalletsStack'; import ScanLndInvoiceRoot from './ScanLndInvoiceStack'; import ScanQRCodeStackRoot from './ScanQRCodeStack'; import SendDetailsStack from './SendDetailsStack'; @@ -63,6 +62,7 @@ import WalletXpubStackRoot from './WalletXpubStack'; import PlusIcon from '../components/icons/PlusIcon'; import SettingsButton from '../components/icons/SettingsButton'; import ExportMultisigCoordinationSetupStack from './ExportMultisigCoordinationSetupStack'; +import ManageWallets from '../screen/wallets/ManageWallets'; const DetailViewStackScreensStack = () => { const theme = useTheme(); @@ -362,13 +362,15 @@ const DetailViewStackScreensStack = () => { }} /> ); diff --git a/navigation/DetailViewStackParamList.ts b/navigation/DetailViewStackParamList.ts index 4c699ebf9..fc64cceff 100644 --- a/navigation/DetailViewStackParamList.ts +++ b/navigation/DetailViewStackParamList.ts @@ -100,5 +100,5 @@ export type DetailViewStackParamList = { paymentCode: string; walletID: string; }; - ReorderWallets: undefined; + ManageWallets: undefined; }; diff --git a/navigation/ReorderWalletsStack.tsx b/navigation/ReorderWalletsStack.tsx deleted file mode 100644 index 0ff01e0eb..000000000 --- a/navigation/ReorderWalletsStack.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import React from 'react'; - -import navigationStyle from '../components/navigationStyle'; -import { useTheme } from '../components/themes'; -import loc from '../loc'; -import ReorderWallets from '../screen/wallets/ReorderWallets'; - -const Stack = createNativeStackNavigator(); - -const ReorderWalletsStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -export default ReorderWalletsStackRoot; diff --git a/navigation/SendDetailsStackParamList.ts b/navigation/SendDetailsStackParamList.ts index 9dd8d696d..5a6ddb35b 100644 --- a/navigation/SendDetailsStackParamList.ts +++ b/navigation/SendDetailsStackParamList.ts @@ -210,5 +210,5 @@ export type DetailViewStackParamList = { paymentCode: string; walletID: string; }; - ReorderWallets: undefined; + ManageWallets: undefined; }; diff --git a/screen/wallets/DrawerList.tsx b/screen/wallets/DrawerList.tsx index 6cf7ca8ab..a692e222e 100644 --- a/screen/wallets/DrawerList.tsx +++ b/screen/wallets/DrawerList.tsx @@ -124,7 +124,7 @@ const DrawerList: React.FC = memo(({ navigation }) => { const handleLongPress = useCallback(() => { if (state.wallets.length > 1) { - navigation.navigate('ReorderWallets'); + navigation.navigate('ManageWallets'); } else { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); } diff --git a/screen/wallets/ManageWallets.tsx b/screen/wallets/ManageWallets.tsx new file mode 100644 index 000000000..d1d657350 --- /dev/null +++ b/screen/wallets/ManageWallets.tsx @@ -0,0 +1,377 @@ +import React, { useEffect, useLayoutEffect, useRef, useReducer, useCallback, useMemo } from 'react'; +import { Platform, StyleSheet, useColorScheme, TouchableOpacity, Image, Animated, Text, I18nManager } from 'react-native'; +// @ts-ignore: no declaration file +import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatlist'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; +import { useTheme } from '../../components/themes'; +import { WalletCarouselItem } from '../../components/WalletsCarousel'; +import { TransactionListItem } from '../../components/TransactionListItem'; +import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; +import loc from '../../loc'; +import { useStorage } from '../../hooks/context/useStorage'; +import useDebounce from '../../hooks/useDebounce'; +import { TTXMetadata } from '../../class'; +import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from '../../class/wallets/types'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import useBounceAnimation from '../../hooks/useBounceAnimation'; + +enum ItemType { + WalletSection = 'wallet', + TransactionSection = 'transaction', +} + +interface WalletItem { + type: ItemType.WalletSection; + data: TWallet; +} + +interface TransactionItem { + type: ItemType.TransactionSection; + data: ExtendedTransaction & LightningTransaction; +} + +type Item = WalletItem | TransactionItem; + +const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +const SET_IS_SEARCH_FOCUSED = 'SET_IS_SEARCH_FOCUSED'; +const SET_WALLET_DATA = 'SET_WALLET_DATA'; +const SET_TX_METADATA = 'SET_TX_METADATA'; +const SET_ORDER = 'SET_ORDER'; + +interface SetSearchQueryAction { + type: typeof SET_SEARCH_QUERY; + payload: string; +} + +interface SetIsSearchFocusedAction { + type: typeof SET_IS_SEARCH_FOCUSED; + payload: boolean; +} + +interface SetWalletDataAction { + type: typeof SET_WALLET_DATA; + payload: TWallet[]; +} + +interface SetTxMetadataAction { + type: typeof SET_TX_METADATA; + payload: TTXMetadata; +} + +interface SetOrderAction { + type: typeof SET_ORDER; + payload: Item[]; +} + +type Action = SetSearchQueryAction | SetIsSearchFocusedAction | SetWalletDataAction | SetTxMetadataAction | SetOrderAction; + +interface State { + searchQuery: string; + isSearchFocused: boolean; + walletData: TWallet[]; + txMetadata: TTXMetadata; + order: Item[]; +} + +const initialState: State = { + searchQuery: '', + isSearchFocused: false, + walletData: [], + txMetadata: {}, + order: [], +}; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case SET_SEARCH_QUERY: + return { ...state, searchQuery: action.payload }; + case SET_IS_SEARCH_FOCUSED: + return { ...state, isSearchFocused: action.payload }; + case SET_WALLET_DATA: + return { ...state, walletData: action.payload }; + case SET_TX_METADATA: + return { ...state, txMetadata: action.payload }; + case SET_ORDER: + return { ...state, order: action.payload }; + default: + return state; + } +}; + +const ManageWallets: React.FC = () => { + const sortableList = useRef(null); + const { colors, closeImage } = useTheme(); + const { wallets, setWalletsWithNewOrder, txMetadata } = useStorage(); + const colorScheme = useColorScheme(); + const { navigate, setOptions, goBack } = useExtendedNavigation(); + const [state, dispatch] = useReducer(reducer, initialState); + + const stylesHook = { + root: { + backgroundColor: colors.elevated, + }, + tip: { + backgroundColor: colors.ballOutgoingExpired, + }, + noResultsText: { + color: colors.foregroundColor, + }, + }; + + useEffect(() => { + const initialOrder: Item[] = wallets.map(wallet => ({ type: ItemType.WalletSection, data: wallet })); + dispatch({ type: SET_WALLET_DATA, payload: wallets }); + dispatch({ type: SET_TX_METADATA, payload: txMetadata }); + dispatch({ type: SET_ORDER, payload: initialOrder }); + }, [wallets, txMetadata]); + + const handleClose = useCallback(() => { + const walletOrder = state.order.filter(item => item.type === ItemType.WalletSection).map(item => item.data); + setWalletsWithNewOrder(walletOrder); + goBack(); + }, [goBack, setWalletsWithNewOrder, state.order]); + + const HeaderRightButton = useMemo( + () => ( + + + + ), + [handleClose, closeImage], + ); + + useEffect(() => { + setOptions({ + statusBarStyle: Platform.select({ ios: 'light', default: colorScheme === 'dark' ? 'light' : 'dark' }), + headerRight: () => HeaderRightButton, + }); + }, [colorScheme, setOptions, HeaderRightButton]); + + const debouncedSearchQuery = useDebounce(state.searchQuery, 300); + + useEffect(() => { + if (debouncedSearchQuery) { + const filteredWallets = wallets.filter(wallet => wallet.getLabel()?.toLowerCase().includes(debouncedSearchQuery.toLowerCase())); + const filteredTxMetadata = Object.entries(txMetadata).filter(([_, tx]) => + tx.memo?.toLowerCase().includes(debouncedSearchQuery.toLowerCase()), + ); + + // Filter transactions + const filteredTransactions = wallets.flatMap(wallet => + wallet + .getTransactions() + .filter((tx: Transaction) => + filteredTxMetadata.some( + ([txid, txMeta]) => tx.hash === txid && txMeta.memo?.toLowerCase().includes(debouncedSearchQuery.toLowerCase()), + ), + ), + ); + + const filteredOrder: Item[] = [ + ...filteredWallets.map(wallet => ({ type: ItemType.WalletSection, data: wallet })), + ...filteredTransactions.map(tx => ({ type: ItemType.TransactionSection, data: tx })), + ]; + + dispatch({ type: SET_WALLET_DATA, payload: filteredWallets }); + dispatch({ type: SET_TX_METADATA, payload: Object.fromEntries(filteredTxMetadata) }); + dispatch({ type: SET_ORDER, payload: filteredOrder }); + } else { + const initialOrder: Item[] = wallets.map(wallet => ({ type: ItemType.WalletSection, data: wallet })); + dispatch({ type: SET_WALLET_DATA, payload: wallets }); + dispatch({ type: SET_TX_METADATA, payload: {} }); + dispatch({ type: SET_ORDER, payload: initialOrder }); + } + }, [wallets, txMetadata, debouncedSearchQuery]); + + useLayoutEffect(() => { + setOptions({ + headerSearchBarOptions: { + hideWhenScrolling: false, + onChangeText: (event: { nativeEvent: { text: any } }) => dispatch({ type: SET_SEARCH_QUERY, payload: event.nativeEvent.text }), + onClear: () => dispatch({ type: SET_SEARCH_QUERY, payload: '' }), + onFocus: () => dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: true }), + onBlur: () => dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: false }), + placeholder: loc.wallets.manage_wallets_search_placeholder, + }, + }); + }, [setOptions]); + + const navigateToWallet = useCallback( + (wallet: TWallet) => { + const walletID = wallet.getID(); + goBack(); + navigate('WalletTransactions', { + walletID, + walletType: wallet.type, + }); + }, + [goBack, navigate], + ); + + const isDraggingDisabled = state.searchQuery.length > 0 || state.isSearchFocused; + + const bounceAnim = useBounceAnimation(state.searchQuery); + + const renderHighlightedText = useCallback( + (text: string, query: string) => { + const parts = text.split(new RegExp(`(${query})`, 'gi')); + return ( + + {parts.map((part, index) => + query && part.toLowerCase().includes(query.toLowerCase()) ? ( + + {part} + + ) : ( + + {part} + + ), + )} + + ); + }, + [bounceAnim], + ); + const renderItem = useCallback( + // eslint-disable-next-line react/no-unused-prop-types + ({ item, drag, isActive }: { item: Item; drag: () => void; isActive: boolean }) => { + if (item.type === ItemType.TransactionSection && item.data) { + const w = wallets.find(wallet => wallet.getTransactions().some((tx: ExtendedTransaction) => tx.hash === item.data.hash)); + const walletID = w ? w.getID() : ''; + return ( + + ); + } else if (item.type === ItemType.WalletSection) { + return ( + + navigateToWallet(item.data)} + customStyle={styles.padding16} + searchQuery={state.searchQuery} + renderHighlightedText={renderHighlightedText} + /> + + ); + } + return null; + }, + [wallets, isDraggingDisabled, navigateToWallet, state.searchQuery, renderHighlightedText], + ); + + const onChangeOrder = useCallback(() => { + triggerHapticFeedback(HapticFeedbackTypes.ImpactMedium); + }, []); + + const onDragBegin = useCallback(() => { + triggerHapticFeedback(HapticFeedbackTypes.Selection); + }, []); + + const onRelease = useCallback(() => { + triggerHapticFeedback(HapticFeedbackTypes.ImpactLight); + }, []); + + const onDragEnd = useCallback(({ data }: any) => { + dispatch({ type: SET_ORDER, payload: data }); + }, []); + + const _keyExtractor = useCallback((_item: any, index: number) => index.toString(), []); + + const renderHeader = useMemo(() => { + if (!state.searchQuery) return null; + const hasWallets = state.walletData.length > 0; + const filteredTxMetadata = Object.entries(state.txMetadata).filter(([_, tx]) => + tx.memo?.toLowerCase().includes(state.searchQuery.toLowerCase()), + ); + const hasTransactions = filteredTxMetadata.length > 0; + + return ( + !hasWallets && + !hasTransactions && {loc.wallets.no_results_found} + ); + }, [state.searchQuery, state.walletData.length, state.txMetadata, stylesHook.noResultsText]); + + return ( + + + + ); +}; + +export default ManageWallets; + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + padding16: { + padding: 16, + }, + button: { + padding: 16, + }, + noResultsText: { + fontSize: 19, + fontWeight: 'bold', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + textAlign: 'center', + justifyContent: 'center', + marginTop: 34, + }, +}); + +const iStyles = StyleSheet.create({ + highlightedContainer: { + backgroundColor: 'white', + borderColor: 'black', + borderWidth: 1, + borderRadius: 5, + padding: 2, + alignSelf: 'flex-start', + textDecorationLine: 'underline', + textDecorationStyle: 'double', + textShadowColor: '#000', + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 1, + }, + highlighted: { + color: 'black', + fontSize: 19, + fontWeight: '600', + }, + defaultText: { + fontSize: 19, + }, + dimmedText: { + opacity: 0.8, + }, +}); diff --git a/screen/wallets/ReorderWallets.tsx b/screen/wallets/ReorderWallets.tsx deleted file mode 100644 index c9c1ac207..000000000 --- a/screen/wallets/ReorderWallets.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { useEffect, useLayoutEffect, useRef, useReducer, useCallback } from 'react'; -import { Platform, StyleSheet, useColorScheme } from 'react-native'; -// @ts-ignore: fix later -import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatlist'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; -import { useTheme } from '../../components/themes'; -import { WalletCarouselItem } from '../../components/WalletsCarousel'; -import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; -import loc from '../../loc'; -import { useStorage } from '../../hooks/context/useStorage'; - -// Action Types -const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; -const SET_IS_SEARCH_FOCUSED = 'SET_IS_SEARCH_FOCUSED'; -const SET_WALLET_DATA = 'SET_WALLET_DATA'; - -// Action Interfaces -interface SetSearchQueryAction { - type: typeof SET_SEARCH_QUERY; - payload: string; -} - -interface SetIsSearchFocusedAction { - type: typeof SET_IS_SEARCH_FOCUSED; - payload: boolean; -} - -interface SetWalletDataAction { - type: typeof SET_WALLET_DATA; - payload: any[]; -} - -type Action = SetSearchQueryAction | SetIsSearchFocusedAction | SetWalletDataAction; - -// State Interface -interface State { - searchQuery: string; - isSearchFocused: boolean; - walletData: any[]; -} - -// Initial State -const initialState: State = { - searchQuery: '', - isSearchFocused: false, - walletData: [], -}; - -// Reducer -const reducer = (state: State, action: Action): State => { - switch (action.type) { - case SET_SEARCH_QUERY: - return { ...state, searchQuery: action.payload }; - case SET_IS_SEARCH_FOCUSED: - return { ...state, isSearchFocused: action.payload }; - case SET_WALLET_DATA: - return { ...state, walletData: action.payload }; - default: - return state; - } -}; - -const ReorderWallets: React.FC = () => { - const sortableList = useRef(null); - const { colors } = useTheme(); - const { wallets, setWalletsWithNewOrder } = useStorage(); - const colorScheme = useColorScheme(); - const { navigate, setOptions, goBack } = useExtendedNavigation(); - const [state, dispatch] = useReducer(reducer, initialState); - - const stylesHook = { - root: { - backgroundColor: colors.elevated, - }, - tip: { - backgroundColor: colors.ballOutgoingExpired, - }, - }; - - useEffect(() => { - dispatch({ type: SET_WALLET_DATA, payload: wallets }); - }, [wallets]); - - useEffect(() => { - setOptions({ - statusBarStyle: Platform.select({ ios: 'light', default: colorScheme === 'dark' ? 'light' : 'dark' }), - }); - }, [colorScheme, setOptions]); - - useEffect(() => { - const filteredWallets = wallets.filter(wallet => wallet.getLabel().toLowerCase().includes(state.searchQuery.toLowerCase())); - dispatch({ type: SET_WALLET_DATA, payload: filteredWallets }); - }, [wallets, state.searchQuery]); - - useLayoutEffect(() => { - setOptions({ - headerSearchBarOptions: { - hideWhenScrolling: false, - onChangeText: (event: { nativeEvent: { text: any } }) => dispatch({ type: SET_SEARCH_QUERY, payload: event.nativeEvent.text }), - onClear: () => dispatch({ type: SET_SEARCH_QUERY, payload: '' }), - onFocus: () => dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: true }), - onBlur: () => dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: false }), - placeholder: loc.wallets.search_wallets, - }, - }); - }, [setOptions]); - - const navigateToWallet = useCallback( - (wallet: any) => { - const walletID = wallet.getID(); - goBack(); - navigate('WalletTransactions', { - walletID, - walletType: wallet.type, - }); - }, - [goBack, navigate], - ); - - const isDraggingDisabled = state.searchQuery.length > 0 || state.isSearchFocused; - - const renderItem = useCallback( - ({ item, drag, isActive }: any) => { - const itemOpacity = isActive ? 1 : 0.5; - - return ( - - - - ); - }, - [isDraggingDisabled, navigateToWallet], - ); - - const onChangeOrder = useCallback(() => { - triggerHapticFeedback(HapticFeedbackTypes.ImpactMedium); - }, []); - - const onDragBegin = useCallback(() => { - triggerHapticFeedback(HapticFeedbackTypes.Selection); - }, []); - - const onRelease = useCallback(() => { - triggerHapticFeedback(HapticFeedbackTypes.ImpactLight); - }, []); - - const onDragEnd = useCallback( - ({ data }: any) => { - setWalletsWithNewOrder(data); - dispatch({ type: SET_WALLET_DATA, payload: data }); - }, - [setWalletsWithNewOrder], - ); - - const _keyExtractor = useCallback((_item: any, index: number) => index.toString(), []); - - return ( - - - - ); -}; - -export default ReorderWallets; - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - padding16: { - padding: 16, - }, -}); diff --git a/screen/wallets/WalletsList.tsx b/screen/wallets/WalletsList.tsx index d7a26adf6..62c93f0e7 100644 --- a/screen/wallets/WalletsList.tsx +++ b/screen/wallets/WalletsList.tsx @@ -239,7 +239,7 @@ const WalletsList: React.FC = () => { const handleLongPress = useCallback(() => { if (wallets.length > 1) { - navigate('ReorderWallets'); + navigate('ManageWallets'); } else { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); }