From 58842e362adfdd60991b53559f3878fb9a3f8202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOSE=20MOISES=20NU=C3=91EZ=20IGLESIAS?= Date: Mon, 25 Aug 2025 16:25:38 -0600 Subject: [PATCH 01/28] feat: home screen transaction filter --- .../galoy-currency-bubble-text.stories.tsx | 34 +++++++ .../galoy-currency-bubble-text.tsx | 95 +++++++++++++++++++ .../galoy-currency-bubble-text/index.ts | 1 + .../wallet-overview/wallet-overview.tsx | 53 +++++++++-- app/navigation/stack-param-lists.ts | 1 + app/screens/home-screen/home-screen.tsx | 76 +-------------- .../transaction-history-screen.tsx | 4 +- 7 files changed, 182 insertions(+), 82 deletions(-) create mode 100644 app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.stories.tsx create mode 100644 app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx create mode 100644 app/components/atomic/galoy-currency-bubble-text/index.ts diff --git a/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.stories.tsx b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.stories.tsx new file mode 100644 index 0000000000..bcaed60420 --- /dev/null +++ b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.stories.tsx @@ -0,0 +1,34 @@ +import React from "react" + +import { WalletCurrency } from "@app/graphql/generated" + +import { GaloyCurrencyBubbleText } from "./galoy-currency-bubble-text" +import { Story, UseCase } from "../../../../.storybook/views" + +const UseCaseWrapper = ({ children, text, style }) => ( + + {children} + +) + +const styles = { + wrapper: { flexDirection: "row", gap: 12 }, +} + +export default { + title: "Galoy Currency Bubble", + component: GaloyCurrencyBubbleText, +} + +export const Default = () => ( + + + + + + + + + + +) diff --git a/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx new file mode 100644 index 0000000000..ed4534bfc3 --- /dev/null +++ b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx @@ -0,0 +1,95 @@ +import React from "react" +import { View } from "react-native" +import { useTheme, TextProps, Text, makeStyles } from "@rneui/themed" + +import { WalletCurrency } from "@app/graphql/generated" + +export const GaloyCurrencyBubbleText = ({ + currency, + textSize, + highlighted = true, + containerSize = "small", +}: { + currency: WalletCurrency + textSize?: TextProps["type"] + containerSize?: "small" | "medium" | "large" + highlighted?: boolean +}) => { + const { + theme: { colors }, + } = useTheme() + + return currency === WalletCurrency.Btc ? ( + + ) : ( + + ) +} + +const ContainerBubble = ({ + text, + textSize, + color, + backgroundColor, + containerSize = "small", +}: { + text: string + textSize?: TextProps["type"] + highlighted?: boolean + color?: string + backgroundColor?: string + containerSize?: "small" | "medium" | "large" +}) => { + const styles = useStyles({ backgroundColor, containerSize, color }) + + return ( + + + {text} + + + ) +} + +const useStyles = makeStyles( + ( + _theme, + { + backgroundColor, + containerSize, + color, + }: { + backgroundColor?: string + containerSize: "small" | "medium" | "large" + color?: string + }, + ) => ({ + container: { + backgroundColor, + paddingHorizontal: + containerSize === "small" ? 8 : containerSize === "medium" ? 12 : 16, + paddingVertical: containerSize === "small" ? 4 : containerSize === "medium" ? 5 : 6, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + }, + text: { + color, + fontWeight: "bold", + }, + }), +) diff --git a/app/components/atomic/galoy-currency-bubble-text/index.ts b/app/components/atomic/galoy-currency-bubble-text/index.ts new file mode 100644 index 0000000000..e8e5a0dd21 --- /dev/null +++ b/app/components/atomic/galoy-currency-bubble-text/index.ts @@ -0,0 +1 @@ +export * from "./galoy-currency-bubble-text" diff --git a/app/components/wallet-overview/wallet-overview.tsx b/app/components/wallet-overview/wallet-overview.tsx index eee3a61829..5bff45ac0c 100644 --- a/app/components/wallet-overview/wallet-overview.tsx +++ b/app/components/wallet-overview/wallet-overview.tsx @@ -1,20 +1,23 @@ import React from "react" import ContentLoader, { Rect } from "react-content-loader/native" -import { Pressable, View } from "react-native" +import { Pressable, StyleProp, View, ViewStyle } from "react-native" import { gql } from "@apollo/client" import { useWalletOverviewScreenQuery, WalletCurrency } from "@app/graphql/generated" import { useHideAmount } from "@app/graphql/hide-amount-context" import { useIsAuthed } from "@app/graphql/is-authed-context" -import { getBtcWallet, getUsdWallet } from "@app/graphql/wallets-utils" +import { getBtcWallet, getUsdWallet, WalletBalance } from "@app/graphql/wallets-utils" import { useDisplayCurrency } from "@app/hooks/use-display-currency" import { useI18nContext } from "@app/i18n/i18n-react" import { toBtcMoneyAmount, toUsdMoneyAmount } from "@app/types/amounts" import { testProps } from "@app/utils/testProps" import { makeStyles, Text, useTheme } from "@rn-vui/themed" -import { GaloyCurrencyBubble } from "../atomic/galoy-currency-bubble" import { GaloyIcon } from "../atomic/galoy-icon" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { GaloyCurrencyBubbleText } from "../atomic/galoy-currency-bubble-text" const Loader = () => { const styles = useStyles() @@ -52,9 +55,14 @@ gql` type Props = { loading: boolean setIsStablesatModalVisible: (value: boolean) => void + wallets?: readonly WalletBalance[] } -const WalletOverview: React.FC = ({ loading, setIsStablesatModalVisible }) => { +const WalletOverview: React.FC = ({ + loading, + setIsStablesatModalVisible, + wallets, +}) => { const { hideAmount, switchMemoryHideAmount } = useHideAmount() const { LL } = useI18nContext() @@ -64,6 +72,7 @@ const WalletOverview: React.FC = ({ loading, setIsStablesatModalVisible } } = useTheme() const styles = useStyles() const { data } = useWalletOverviewScreenQuery({ skip: !isAuthed }) + const navigation = useNavigation>() const { formatMoneyAmount, displayCurrency, moneyAmountToDisplayCurrencyString } = useDisplayCurrency() @@ -98,6 +107,15 @@ const WalletOverview: React.FC = ({ loading, setIsStablesatModalVisible } } } + const openTransactionHistory = (currencyFilter: WalletCurrency) => + wallets && navigation.navigate("transactionHistory", { wallets, currencyFilter }) + + const pressableStyle = ({ pressed }: { pressed: boolean }): StyleProp => { + if (pressed) { + return [styles.interactiveOpacity] + } + return [] + } return ( @@ -111,8 +129,16 @@ const WalletOverview: React.FC = ({ loading, setIsStablesatModalVisible } - - Bitcoin + openTransactionHistory(WalletCurrency.Btc)} + > + + {loading ? ( @@ -130,8 +156,16 @@ const WalletOverview: React.FC = ({ loading, setIsStablesatModalVisible } - - Dollar + openTransactionHistory(WalletCurrency.Usd)} + > + + setIsStablesatModalVisible(true)}> @@ -219,4 +253,7 @@ const useStyles = makeStyles(({ colors }) => ({ height: 45, marginTop: 5, }, + interactiveOpacity: { + opacity: 0.5, + }, })) diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index 875c153ef1..86e19fa53e 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -96,6 +96,7 @@ export type RootStackParamList = { readonly id: string readonly walletCurrency: WalletCurrency }> + currencyFilter?: WalletCurrency } Earn: undefined accountScreen: undefined diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index b55c0e3dcc..2e962c729b 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -3,16 +3,11 @@ import { useMemo } from "react" import { RefreshControl, View, Alert } from "react-native" import { gql } from "@apollo/client" import Modal from "react-native-modal" -import { LocalizedString } from "typesafe-i18n" import Icon from "react-native-vector-icons/Ionicons" import { useNavigation, useIsFocused, useFocusEffect } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { Text, makeStyles, useTheme } from "@rn-vui/themed" -import { - ScrollView, - TouchableOpacity, - TouchableWithoutFeedback, -} from "react-native-gesture-handler" +import { ScrollView, TouchableWithoutFeedback } from "react-native-gesture-handler" import { AppUpdate } from "@app/components/app-update/app-update" import { GaloyErrorBox } from "@app/components/atomic/galoy-error-box" @@ -25,7 +20,6 @@ import { StableSatsModal } from "@app/components/stablesats-modal" import WalletOverview from "@app/components/wallet-overview/wallet-overview" import { BalanceHeader, useTotalBalance } from "@app/components/balance-header" import { TrialAccountLimitsModal } from "@app/components/upgrade-account-modal" -import { MemoizedTransactionItem } from "@app/components/transaction-item" import { Screen } from "@app/components/screen" import { RootStackParamList } from "@app/navigation/stack-param-lists" @@ -306,11 +300,6 @@ export const HomeScreen: React.FC = () => { return } - if (target === "transactionHistory" && wallets) { - navigation.navigate("transactionHistory", { wallets }) - return - } - // we are using any because Typescript complain on the fact we are not passing any params // but there is no need for a params and the types should not necessitate it // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -350,45 +339,7 @@ export const HomeScreen: React.FC = () => { }, [openUpgradeModal, triggerUpgradeModal]), ) - let recentTransactionsData: - | { - title: LocalizedString - details: React.ReactNode - } - | undefined = undefined - - const TRANSACTIONS_TO_SHOW = 1 - - if (isAuthed && transactions.length > 0) { - recentTransactionsData = { - title: LL.TransactionScreen.title(), - details: ( - <> - {transactions - .slice(0, TRANSACTIONS_TO_SHOW) - .map( - (tx, index, array) => - tx && ( - - ), - )} - - ), - } - } - - type Target = - | "scanningQRCode" - | "sendBitcoinDestination" - | "receiveBitcoin" - | "transactionHistory" + type Target = "scanningQRCode" | "sendBitcoinDestination" | "receiveBitcoin" type IconNamesType = keyof typeof icons const buttons = [ @@ -497,6 +448,7 @@ export const HomeScreen: React.FC = () => { {error && } @@ -512,28 +464,6 @@ export const HomeScreen: React.FC = () => { ))} - - {recentTransactionsData && ( - <> - onMenuClick("transactionHistory")} - activeOpacity={0.6} - > - - {recentTransactionsData?.title} - - - {recentTransactionsData?.details} - - )} - - = } = useTheme() const styles = useStyles() const { LL, locale } = useI18nContext() - const [walletFilter, setWalletFilter] = React.useState("ALL") + const [walletFilter, setWalletFilter] = React.useState( + route.params?.currencyFilter ?? "ALL", + ) const walletIdsByCurrency = React.useMemo(() => { const wallets = route.params?.wallets ?? [] From 4203a30bb782afbe2b3b13001f4cbff0104e47e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOSE=20MOISES=20NU=C3=91EZ=20IGLESIAS?= Date: Tue, 26 Aug 2025 09:31:58 -0600 Subject: [PATCH 02/28] feat: transaction filter by route param test --- .../transaction-history-screen.spec.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/__tests__/screens/transaction-history-screen.spec.tsx b/__tests__/screens/transaction-history-screen.spec.tsx index 800fb1e134..566dc0846f 100644 --- a/__tests__/screens/transaction-history-screen.spec.tsx +++ b/__tests__/screens/transaction-history-screen.spec.tsx @@ -7,8 +7,10 @@ import { TransactionHistoryScreen } from "@app/screens/transaction-history" import { ContextForScreen } from "./helper" -const mockRoute: RouteProp = { - key: "transactionHistory-test", +const mockRouteWithCurrencyFilter = ( + currency?: "BTC" | "USD", +): RouteProp => ({ + key: `transactionHistory-test`, name: "transactionHistory", params: { wallets: [ @@ -21,8 +23,9 @@ const mockRoute: RouteProp = { walletCurrency: "USD", }, ], + ...(currency ? { currencyFilter: currency } : {}), }, -} +}) describe("TransactionHistoryScreen", () => { afterEach(cleanup) @@ -30,7 +33,7 @@ describe("TransactionHistoryScreen", () => { it("shows all transactions by default", async () => { const { findByTestId } = render( - + , ) @@ -40,7 +43,7 @@ describe("TransactionHistoryScreen", () => { it("filters only BTC transactions", async () => { const screen = render( - + , ) @@ -60,4 +63,19 @@ describe("TransactionHistoryScreen", () => { expect(screen.queryByText("user_usd")).toBeNull() }) }) + + it("filters only BTC by route param", async () => { + const screen = render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId("transaction-by-index-0")).toBeTruthy() + }) + + expect(screen.queryByText("user_btc")).toBeTruthy() + expect(screen.queryByText("user_usd")).toBeNull() + }) }) From d3c567eb856acfcd7e57971935b3590204d36048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOSE=20MOISES=20NU=C3=91EZ=20IGLESIAS?= Date: Tue, 2 Sep 2025 10:30:25 -0600 Subject: [PATCH 03/28] feat: extend pressable area to transaction filters --- .../wallet-overview/wallet-overview.tsx | 119 ++++++++---------- 1 file changed, 55 insertions(+), 64 deletions(-) diff --git a/app/components/wallet-overview/wallet-overview.tsx b/app/components/wallet-overview/wallet-overview.tsx index 5bff45ac0c..dff64ad6bd 100644 --- a/app/components/wallet-overview/wallet-overview.tsx +++ b/app/components/wallet-overview/wallet-overview.tsx @@ -1,6 +1,6 @@ import React from "react" import ContentLoader, { Rect } from "react-content-loader/native" -import { Pressable, StyleProp, View, ViewStyle } from "react-native" +import { Pressable, StyleProp, TouchableOpacity, View, ViewStyle } from "react-native" import { gql } from "@apollo/client" import { useWalletOverviewScreenQuery, WalletCurrency } from "@app/graphql/generated" @@ -110,12 +110,6 @@ const WalletOverview: React.FC = ({ const openTransactionHistory = (currencyFilter: WalletCurrency) => wallets && navigation.navigate("transactionHistory", { wallets, currencyFilter }) - const pressableStyle = ({ pressed }: { pressed: boolean }): StyleProp => { - if (pressed) { - return [styles.interactiveOpacity] - } - return [] - } return ( @@ -127,73 +121,73 @@ const WalletOverview: React.FC = ({ - - - openTransactionHistory(WalletCurrency.Btc)} - > + openTransactionHistory(WalletCurrency.Btc)} + > + + - - - {loading ? ( - - ) : hideAmount ? ( - **** - ) : ( - - - {btcInUnderlyingCurrency} - - {btcInDisplayCurrencyFormatted} - )} - + {loading ? ( + + ) : hideAmount ? ( + **** + ) : ( + + + {btcInUnderlyingCurrency} + + {btcInDisplayCurrencyFormatted} + + )} + + - - - openTransactionHistory(WalletCurrency.Usd)} - > + openTransactionHistory(WalletCurrency.Usd)} + > + + - - setIsStablesatModalVisible(true)}> - - - - {loading ? ( - - ) : ( - - {!hideAmount && ( - <> - {usdInUnderlyingCurrency ? ( - - {usdInUnderlyingCurrency} - - ) : null} - - {usdInDisplayCurrencyFormatted} - - - )} - {hideAmount && ****} + setIsStablesatModalVisible(true)}> + + - )} - + {loading ? ( + + ) : ( + + {!hideAmount && ( + <> + {usdInUnderlyingCurrency ? ( + + {usdInUnderlyingCurrency} + + ) : null} + + {usdInDisplayCurrencyFormatted} + + + )} + {hideAmount && ****} + + )} + + ) } @@ -253,7 +247,4 @@ const useStyles = makeStyles(({ colors }) => ({ height: 45, marginTop: 5, }, - interactiveOpacity: { - opacity: 0.5, - }, })) From 73092ae9719e0d6690b42160a392fceb969688e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOSE=20MOISES=20NU=C3=91EZ=20IGLESIAS?= Date: Tue, 2 Sep 2025 11:10:38 -0600 Subject: [PATCH 04/28] fix: never used vars --- app/components/wallet-overview/wallet-overview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/wallet-overview/wallet-overview.tsx b/app/components/wallet-overview/wallet-overview.tsx index dff64ad6bd..e775dc991d 100644 --- a/app/components/wallet-overview/wallet-overview.tsx +++ b/app/components/wallet-overview/wallet-overview.tsx @@ -1,6 +1,6 @@ import React from "react" import ContentLoader, { Rect } from "react-content-loader/native" -import { Pressable, StyleProp, TouchableOpacity, View, ViewStyle } from "react-native" +import { Pressable, TouchableOpacity, View } from "react-native" import { gql } from "@apollo/client" import { useWalletOverviewScreenQuery, WalletCurrency } from "@app/graphql/generated" From e04b38afa677bdc938e03adc0966503b4f49d278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOSE=20MOISES=20NU=C3=91EZ=20IGLESIAS?= Date: Mon, 8 Sep 2025 15:48:47 -0600 Subject: [PATCH 05/28] feat: transactio filter button sizes --- .../galoy-currency-bubble-text.tsx | 58 +++++++++++----- .../wallet-filter-dropdown.tsx | 69 +++++-------------- 2 files changed, 59 insertions(+), 68 deletions(-) diff --git a/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx index ed4534bfc3..3dad49fe55 100644 --- a/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx +++ b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx @@ -10,7 +10,7 @@ export const GaloyCurrencyBubbleText = ({ highlighted = true, containerSize = "small", }: { - currency: WalletCurrency + currency?: WalletCurrency | "ALL" textSize?: TextProps["type"] containerSize?: "small" | "medium" | "large" highlighted?: boolean @@ -19,22 +19,40 @@ export const GaloyCurrencyBubbleText = ({ theme: { colors }, } = useTheme() - return currency === WalletCurrency.Btc ? ( - - ) : ( + const getCurrencyProps = () => { + switch (currency) { + case WalletCurrency.Btc: + return { + text: "BTC", + color: highlighted ? colors.white : colors._white, + backgroundColor: highlighted ? colors.primary : colors.grey3, + } + case WalletCurrency.Usd: + return { + text: "USD", + color: highlighted ? colors._white : colors._white, + backgroundColor: highlighted ? colors._green : colors.grey3, + } + default: + return { + text: "ALL", + color: colors.primary, + backgroundColor: colors.transparent, + borderColor: colors.primary, + } + } + } + + const currencyProps = getCurrencyProps() + + return ( ) @@ -46,6 +64,7 @@ const ContainerBubble = ({ color, backgroundColor, containerSize = "small", + borderColor, }: { text: string textSize?: TextProps["type"] @@ -53,8 +72,9 @@ const ContainerBubble = ({ color?: string backgroundColor?: string containerSize?: "small" | "medium" | "large" + borderColor?: string }) => { - const styles = useStyles({ backgroundColor, containerSize, color }) + const styles = useStyles({ backgroundColor, containerSize, color, borderColor }) return ( @@ -72,20 +92,24 @@ const useStyles = makeStyles( backgroundColor, containerSize, color, + borderColor, }: { backgroundColor?: string containerSize: "small" | "medium" | "large" color?: string + borderColor?: string }, ) => ({ container: { backgroundColor, paddingHorizontal: - containerSize === "small" ? 8 : containerSize === "medium" ? 12 : 16, - paddingVertical: containerSize === "small" ? 4 : containerSize === "medium" ? 5 : 6, + containerSize === "small" ? 7 : containerSize === "medium" ? 11 : 15, + paddingVertical: containerSize === "small" ? 3 : containerSize === "medium" ? 3 : 5, borderRadius: 10, alignItems: "center", justifyContent: "center", + borderColor: borderColor ?? "transparent", + borderWidth: 1, }, text: { color, diff --git a/app/components/wallet-filter-dropdown/wallet-filter-dropdown.tsx b/app/components/wallet-filter-dropdown/wallet-filter-dropdown.tsx index 2f47837b51..a3109418e0 100644 --- a/app/components/wallet-filter-dropdown/wallet-filter-dropdown.tsx +++ b/app/components/wallet-filter-dropdown/wallet-filter-dropdown.tsx @@ -6,6 +6,7 @@ import { makeStyles, useTheme } from "@rn-vui/themed" import { useI18nContext } from "@app/i18n/i18n-react" import { WalletCurrency } from "@app/graphql/generated" +import { GaloyCurrencyBubbleText } from "../atomic/galoy-currency-bubble-text" export type WalletValues = WalletCurrency | "ALL" @@ -39,24 +40,18 @@ export const WalletFilterDropdown: React.FC<{ const walletOptions = [ { value: "ALL", - label: LL.common.all(), + label: LL.common.all() as WalletValues, description: LL.common.allAccounts(), - containerStyle: styles.walletSelectorTypeLabelAll, - textStyle: styles.walletSelectorTypeLabelAllText, }, { value: "BTC", - label: WalletCurrency.Btc, + label: WalletCurrency.Btc as WalletValues, description: LL.common.bitcoin(), - containerStyle: styles.walletSelectorTypeLabelBitcoin, - textStyle: styles.walletSelectorTypeLabelBtcText, }, { value: "USD", - label: WalletCurrency.Usd, + label: WalletCurrency.Usd as WalletValues, description: LL.common.dollar(), - containerStyle: styles.walletSelectorTypeLabelUsd, - textStyle: styles.walletSelectorTypeLabelUsdText, }, ] as const @@ -73,15 +68,15 @@ export const WalletFilterDropdown: React.FC<{ > - - {current.label} - + - - - {current.description} - + + {current.description} @@ -111,15 +106,13 @@ export const WalletFilterDropdown: React.FC<{ > - - {opt.label} - - - - - {opt.description} - + + {opt.description} ))} @@ -147,27 +140,8 @@ const useStyles = makeStyles(({ colors }) => ({ minHeight: 60, }, walletSelectorTypeContainer: { - justifyContent: "center", - alignItems: "flex-start", - width: 50, marginRight: 20, }, - walletSelectorTypeLabelBitcoin: { - height: 30, - width: 50, - borderRadius: 10, - backgroundColor: colors.primary, - justifyContent: "center", - alignItems: "center", - }, - walletSelectorTypeLabelUsd: { - height: 30, - width: 50, - backgroundColor: colors._green, - borderRadius: 10, - justifyContent: "center", - alignItems: "center", - }, walletSelectorTypeLabelAll: { height: 30, width: 50, @@ -178,10 +152,6 @@ const useStyles = makeStyles(({ colors }) => ({ justifyContent: "center", alignItems: "center", }, - walletSelectorTypeLabelUsdText: { - fontWeight: "bold", - color: colors.black, - }, walletSelectorTypeLabelBtcText: { fontWeight: "bold", color: colors.white, @@ -190,14 +160,11 @@ const useStyles = makeStyles(({ colors }) => ({ fontWeight: "bold", color: colors.primary3, }, - walletSelectorInfoContainer: { - flex: 1, - flexDirection: "column", - }, walletCurrencyText: { fontWeight: "bold", fontSize: 18, color: colors.black, + marginBottom: 1, }, walletSelectorTypeTextContainer: { flex: 1, From b558b2888f466acf119ac52cf2f554f45b71d789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 25 Sep 2025 10:22:37 -0600 Subject: [PATCH 06/28] feat: transaction last seen query and schema --- app/graphql/cache.ts | 9 +++++ app/graphql/client-only-query.ts | 63 +++++++++++++++++++++++++++++++- app/graphql/generated.gql | 8 ++++ app/graphql/generated.ts | 62 +++++++++++++++++++++++++++++++ app/graphql/local-schema.gql | 6 +++ 5 files changed, 147 insertions(+), 1 deletion(-) diff --git a/app/graphql/cache.ts b/app/graphql/cache.ts index f97d26d6a3..01983423e6 100644 --- a/app/graphql/cache.ts +++ b/app/graphql/cache.ts @@ -64,6 +64,9 @@ export const createCache = () => }, }, }, + TxLastSeen: { + keyFields: [], + }, Query: { fields: { // local only fields @@ -103,6 +106,12 @@ export const createCache = () => deviceSessionCount: { read: (value) => value ?? 0, }, + txLastSeen: { + read(existing) { + if (existing) return existing + return { btcId: "", usdId: "" } + }, + }, }, }, Wallet: { diff --git a/app/graphql/client-only-query.ts b/app/graphql/client-only-query.ts index 83254e8dd7..42c4e8d165 100644 --- a/app/graphql/client-only-query.ts +++ b/app/graphql/client-only-query.ts @@ -27,6 +27,9 @@ import { UpgradeModalLastShownAtQuery, DeviceSessionCountDocument, DeviceSessionCountQuery, + TxLastSeenDocument, + TxLastSeenQuery, + WalletCurrency, } from "./generated" export default gql` @@ -43,7 +46,7 @@ export default gql` } query colorScheme { - colorScheme @client # "system" | "light" | "dark" + colorScheme @client } query countryCode { @@ -82,6 +85,13 @@ export default gql` query deviceSessionCount { deviceSessionCount @client } + + query txLastSeen { + txLastSeen @client { + btcId + usdId + } + } ` export const saveHideBalance = ( @@ -287,3 +297,54 @@ export const updateDeviceSessionCount = ( return setDeviceSessionCount(client, prev + 1) } + +export const setTxLastSeen = ( + client: ApolloClient, + patch: { btcId?: string | null; usdId?: string | null }, +): { btcId: string; usdId: string } | null => { + try { + const prev = client.readQuery({ query: TxLastSeenDocument }) + + const data = { + __typename: "Query" as const, + txLastSeen: { + __typename: "TxLastSeen" as const, + btcId: patch.btcId === null ? "" : patch.btcId ?? prev?.txLastSeen?.btcId ?? "", + usdId: patch.usdId === null ? "" : patch.usdId ?? prev?.txLastSeen?.usdId ?? "", + }, + } + + client.writeQuery({ query: TxLastSeenDocument, data }) + return { btcId: data.txLastSeen.btcId, usdId: data.txLastSeen.usdId } + } catch { + return null + } +} + +export const markTxLastSeenId = ( + client: ApolloClient, + currency: WalletCurrency, + id: string, +): string | null => { + try { + if (!id) return null + + const prev = client.readQuery({ query: TxLastSeenDocument }) + + client.writeQuery({ + query: TxLastSeenDocument, + data: { + __typename: "Query", + txLastSeen: { + __typename: "TxLastSeen", + btcId: currency === WalletCurrency.Btc ? id : prev?.txLastSeen?.btcId ?? "", + usdId: currency === WalletCurrency.Usd ? id : prev?.txLastSeen?.usdId ?? "", + }, + }, + }) + + return id + } catch { + return null + } +} diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index ce8fcf8763..c31e52a285 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -1680,6 +1680,14 @@ query transactionListForDefaultAccount($first: Int, $after: String, $walletIds: } } +query txLastSeen { + txLastSeen @client { + btcId + usdId + __typename + } +} + query upgradeModalLastShownAt { upgradeModalLastShownAt @client } diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index 609272b778..bb559edd94 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -1769,6 +1769,7 @@ export type Query = { /** Returns 1 Sat and 1 Usd Cent price for the given currency in minor unit */ readonly realtimePrice: RealtimePrice; readonly region?: Maybe; + readonly txLastSeen: TxLastSeen; readonly upgradeModalLastShownAt?: Maybe; /** @deprecated will be migrated to AccountDefaultWalletId */ readonly userDefaultWalletId: Scalars['WalletId']['output']; @@ -2102,6 +2103,12 @@ export const TxDirection = { } as const; export type TxDirection = typeof TxDirection[keyof typeof TxDirection]; +export type TxLastSeen = { + readonly __typename: 'TxLastSeen'; + readonly btcId: Scalars['String']['output']; + readonly usdId: Scalars['String']['output']; +}; + export const TxNotificationType = { IntraLedgerPayment: 'IntraLedgerPayment', IntraLedgerReceipt: 'IntraLedgerReceipt', @@ -2655,6 +2662,11 @@ export type DeviceSessionCountQueryVariables = Exact<{ [key: string]: never; }>; export type DeviceSessionCountQuery = { readonly __typename: 'Query', readonly deviceSessionCount: number }; +export type TxLastSeenQueryVariables = Exact<{ [key: string]: never; }>; + + +export type TxLastSeenQuery = { readonly __typename: 'Query', readonly txLastSeen: { readonly __typename: 'TxLastSeen', readonly btcId: string, readonly usdId: string } }; + export type TransactionFragment = { readonly __typename: 'Transaction', readonly id: string, readonly status: TxStatus, readonly direction: TxDirection, readonly memo?: string | null, readonly createdAt: number, readonly settlementAmount: number, readonly settlementFee: number, readonly settlementDisplayFee: string, readonly settlementCurrency: WalletCurrency, readonly settlementDisplayAmount: string, readonly settlementDisplayCurrency: string, readonly settlementPrice: { readonly __typename: 'PriceOfOneSettlementMinorUnitInDisplayMinorUnit', readonly base: number, readonly offset: number, readonly currencyUnit: string, readonly formattedAmount: string }, readonly initiationVia: { readonly __typename: 'InitiationViaIntraLedger', readonly counterPartyWalletId?: string | null, readonly counterPartyUsername?: string | null } | { readonly __typename: 'InitiationViaLn', readonly paymentHash: string, readonly paymentRequest: string } | { readonly __typename: 'InitiationViaOnChain', readonly address: string }, readonly settlementVia: { readonly __typename: 'SettlementViaIntraLedger', readonly counterPartyWalletId?: string | null, readonly counterPartyUsername?: string | null, readonly preImage?: string | null } | { readonly __typename: 'SettlementViaLn', readonly preImage?: string | null } | { readonly __typename: 'SettlementViaOnChain', readonly transactionHash?: string | null, readonly arrivalInMempoolEstimatedAt?: number | null } }; export type TransactionListFragment = { readonly __typename: 'TransactionConnection', readonly pageInfo: { readonly __typename: 'PageInfo', readonly hasNextPage: boolean, readonly hasPreviousPage: boolean, readonly startCursor?: string | null, readonly endCursor?: string | null }, readonly edges?: ReadonlyArray<{ readonly __typename: 'TransactionEdge', readonly cursor: string, readonly node: { readonly __typename: 'Transaction', readonly id: string, readonly status: TxStatus, readonly direction: TxDirection, readonly memo?: string | null, readonly createdAt: number, readonly settlementAmount: number, readonly settlementFee: number, readonly settlementDisplayFee: string, readonly settlementCurrency: WalletCurrency, readonly settlementDisplayAmount: string, readonly settlementDisplayCurrency: string, readonly settlementPrice: { readonly __typename: 'PriceOfOneSettlementMinorUnitInDisplayMinorUnit', readonly base: number, readonly offset: number, readonly currencyUnit: string, readonly formattedAmount: string }, readonly initiationVia: { readonly __typename: 'InitiationViaIntraLedger', readonly counterPartyWalletId?: string | null, readonly counterPartyUsername?: string | null } | { readonly __typename: 'InitiationViaLn', readonly paymentHash: string, readonly paymentRequest: string } | { readonly __typename: 'InitiationViaOnChain', readonly address: string }, readonly settlementVia: { readonly __typename: 'SettlementViaIntraLedger', readonly counterPartyWalletId?: string | null, readonly counterPartyUsername?: string | null, readonly preImage?: string | null } | { readonly __typename: 'SettlementViaLn', readonly preImage?: string | null } | { readonly __typename: 'SettlementViaOnChain', readonly transactionHash?: string | null, readonly arrivalInMempoolEstimatedAt?: number | null } } }> | null }; @@ -4149,6 +4161,46 @@ export type DeviceSessionCountQueryHookResult = ReturnType; export type DeviceSessionCountSuspenseQueryHookResult = ReturnType; export type DeviceSessionCountQueryResult = Apollo.QueryResult; +export const TxLastSeenDocument = gql` + query txLastSeen { + txLastSeen @client { + btcId + usdId + } +} + `; + +/** + * __useTxLastSeenQuery__ + * + * To run a query within a React component, call `useTxLastSeenQuery` and pass it any options that fit your needs. + * When your component renders, `useTxLastSeenQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useTxLastSeenQuery({ + * variables: { + * }, + * }); + */ +export function useTxLastSeenQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(TxLastSeenDocument, options); + } +export function useTxLastSeenLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(TxLastSeenDocument, options); + } +export function useTxLastSeenSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(TxLastSeenDocument, options); + } +export type TxLastSeenQueryHookResult = ReturnType; +export type TxLastSeenLazyQueryHookResult = ReturnType; +export type TxLastSeenSuspenseQueryHookResult = ReturnType; +export type TxLastSeenQueryResult = Apollo.QueryResult; export const NetworkDocument = gql` query network { globals { @@ -8460,6 +8512,7 @@ export type ResolversTypes = { TransactionEdge: ResolverTypeWrapper; TxDirection: TxDirection; TxExternalId: ResolverTypeWrapper; + TxLastSeen: ResolverTypeWrapper; TxNotificationType: TxNotificationType; TxStatus: TxStatus; UpgradePayload: ResolverTypeWrapper; @@ -8691,6 +8744,7 @@ export type ResolversParentTypes = { TransactionConnection: TransactionConnection; TransactionEdge: TransactionEdge; TxExternalId: Scalars['TxExternalId']['output']; + TxLastSeen: TxLastSeen; UpgradePayload: UpgradePayload; UsdWallet: UsdWallet; User: User; @@ -9514,6 +9568,7 @@ export type QueryResolvers, ParentType, ContextType>; realtimePrice?: Resolver>; region?: Resolver, ParentType, ContextType>; + txLastSeen?: Resolver; upgradeModalLastShownAt?: Resolver, ParentType, ContextType>; userDefaultWalletId?: Resolver>; usernameAvailable?: Resolver, ParentType, ContextType, RequireFields>; @@ -9719,6 +9774,12 @@ export interface TxExternalIdScalarConfig extends GraphQLScalarTypeConfig = { + btcId?: Resolver; + usdId?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpgradePayloadResolvers = { authToken?: Resolver, ParentType, ContextType>; errors?: Resolver, ParentType, ContextType>; @@ -10023,6 +10084,7 @@ export type Resolvers = { TransactionConnection?: TransactionConnectionResolvers; TransactionEdge?: TransactionEdgeResolvers; TxExternalId?: GraphQLScalarType; + TxLastSeen?: TxLastSeenResolvers; UpgradePayload?: UpgradePayloadResolvers; UsdWallet?: UsdWalletResolvers; User?: UserResolvers; diff --git a/app/graphql/local-schema.gql b/app/graphql/local-schema.gql index 46dfea38b7..a1e99fcb78 100644 --- a/app/graphql/local-schema.gql +++ b/app/graphql/local-schema.gql @@ -16,6 +16,12 @@ extend type Query { innerCircleValue: Int! upgradeModalLastShownAt: String deviceSessionCount: Int! + txLastSeen: TxLastSeen! +} + +type TxLastSeen { + btcId: String! + usdId: String! } extend type Region { From c56ca5358cdcdab7dedf76df47e574041cbee7b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 25 Sep 2025 10:24:59 -0600 Subject: [PATCH 07/28] feat: useUnseenTransactions hooks to get last transactions --- app/hooks/index.ts | 1 + app/hooks/use-new-transactions-indicator.ts | 81 +++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 app/hooks/use-new-transactions-indicator.ts diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 0ccf5c239a..dc85f8bab6 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -3,3 +3,4 @@ export * from "./use-save-session-profile" export * from "./use-price-conversion" export * from "./use-app-config" export * from "./use-show-upgrade-modal" +export * from "./use-new-transactions-indicator" diff --git a/app/hooks/use-new-transactions-indicator.ts b/app/hooks/use-new-transactions-indicator.ts new file mode 100644 index 0000000000..966161f82c --- /dev/null +++ b/app/hooks/use-new-transactions-indicator.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from "react" +import { useApolloClient } from "@apollo/client" +import { + TransactionFragment, + TxLastSeenDocument, + TxLastSeenQuery, + WalletCurrency, +} from "@app/graphql/generated" +import { markTxLastSeenId } from "@app/graphql/client-only-query" + +type TxHints = { + transactions?: ReadonlyArray + latestBtcId?: string | null + latestUsdId?: string | null +} + +type TxSource = ReadonlyArray | TxHints + +const latestId = ( + transactions: ReadonlyArray, + currency: WalletCurrency, +) => + transactions + .filter((tx) => tx.settlementCurrency === currency) + .reduce( + (acc, tx) => + tx.createdAt > acc.createdAt ? { createdAt: tx.createdAt, id: tx.id } : acc, + { createdAt: 0, id: "" }, + ).id + +const isHints = (value: TxSource): value is TxHints => !Array.isArray(value) + +export const useUnseenTransactions = (txSource: TxSource) => { + const client = useApolloClient() + const [btc, setBtc] = useState(false) + const [usd, setUsd] = useState(false) + + const ids = useMemo(() => { + if (isHints(txSource)) { + return { + btcId: + txSource.latestBtcId ?? + latestId(txSource.transactions ?? [], WalletCurrency.Btc) ?? + "", + usdId: + txSource.latestUsdId ?? + latestId(txSource.transactions ?? [], WalletCurrency.Usd) ?? + "", + } + } + return { + btcId: latestId(txSource, WalletCurrency.Btc), + usdId: latestId(txSource, WalletCurrency.Usd), + } + }, [txSource]) + + useEffect(() => { + try { + const data = client.readQuery({ query: TxLastSeenDocument }) + setBtc(ids.btcId !== "" && ids.btcId !== (data?.txLastSeen?.btcId ?? "")) + setUsd(ids.usdId !== "" && ids.usdId !== (data?.txLastSeen?.usdId ?? "")) + } catch { + setBtc(false) + setUsd(false) + } + }, [client, ids]) + + const markSeen = (currency: WalletCurrency) => { + if (currency === WalletCurrency.Btc && ids.btcId) { + markTxLastSeenId(client, WalletCurrency.Btc, ids.btcId) + setBtc(false) + return + } + if (currency === WalletCurrency.Usd && ids.usdId) { + markTxLastSeenId(client, WalletCurrency.Usd, ids.usdId) + setUsd(false) + } + } + + return { showBtc: btc, showUsd: usd, markSeen } +} From 87c6ca8c4bd5ef5ddf1868b2b825e6eb24246964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 25 Sep 2025 10:28:41 -0600 Subject: [PATCH 08/28] feat: notification-badge component --- app/components/notification-badge/index.ts | 1 + .../notification-badge/notification-badge.tsx | 85 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 app/components/notification-badge/index.ts create mode 100644 app/components/notification-badge/notification-badge.tsx diff --git a/app/components/notification-badge/index.ts b/app/components/notification-badge/index.ts new file mode 100644 index 0000000000..c178318f11 --- /dev/null +++ b/app/components/notification-badge/index.ts @@ -0,0 +1 @@ +export * from "./notification-badge" diff --git a/app/components/notification-badge/notification-badge.tsx b/app/components/notification-badge/notification-badge.tsx new file mode 100644 index 0000000000..92d0e6a32d --- /dev/null +++ b/app/components/notification-badge/notification-badge.tsx @@ -0,0 +1,85 @@ +import React from "react" +import { View, Text as RNText, ViewStyle } from "react-native" +import { makeStyles } from "@rneui/themed" + +type NotificationProps = { + visible?: boolean + text?: string + size?: number + top?: number + right?: number + style?: ViewStyle + maxWidth?: number +} + +export const NotificationBadge: React.FC = ({ + visible = false, + text, + size = 18, + top = -6, + right = -8, + style, + maxWidth = 48, +}) => { + const styles = useStyles({ size, top, right, maxWidth }) + if (!visible) return null + + const hasText = typeof text === "string" && text.trim().length > 0 + + if (!hasText) { + return + } + + return ( + + + {text} + + + ) +} + +const useStyles = makeStyles( + ( + { colors }, + { + size, + top, + right, + maxWidth, + }: { size: number; top: number; right: number; maxWidth: number }, + ) => ({ + dot: { + position: "absolute", + top, + right, + width: size, + height: size, + borderRadius: size / 2, + backgroundColor: colors.black, + borderColor: colors.black, + borderWidth: 1, + }, + pill: { + position: "absolute", + top, + right, + minWidth: size, + height: size, + maxWidth, + paddingHorizontal: 6, + borderRadius: size / 2, + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.black, + borderColor: colors.black, + borderWidth: 1, + }, + pillText: { + color: colors.white, + fontSize: 11, + fontWeight: "700", + includeFontPadding: false, + }, + }), +) From a5f3abf3230d528f308f0da41bc1f462fd7ca725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 25 Sep 2025 10:30:52 -0600 Subject: [PATCH 09/28] feat: implement notification-badge transaction in home-screen --- .../wallet-overview/wallet-overview.tsx | 84 +++++++++++++------ app/graphql/client-only-query.ts | 2 +- app/screens/home-screen/home-screen.tsx | 21 +++-- 3 files changed, 73 insertions(+), 34 deletions(-) diff --git a/app/components/wallet-overview/wallet-overview.tsx b/app/components/wallet-overview/wallet-overview.tsx index e775dc991d..04c3d1a808 100644 --- a/app/components/wallet-overview/wallet-overview.tsx +++ b/app/components/wallet-overview/wallet-overview.tsx @@ -1,6 +1,6 @@ -import React from "react" +import React, { useState } from "react" import ContentLoader, { Rect } from "react-content-loader/native" -import { Pressable, TouchableOpacity, View } from "react-native" +import { Pressable, View } from "react-native" import { gql } from "@apollo/client" import { useWalletOverviewScreenQuery, WalletCurrency } from "@app/graphql/generated" @@ -16,6 +16,7 @@ import { makeStyles, Text, useTheme } from "@rn-vui/themed" import { GaloyIcon } from "../atomic/galoy-icon" import { useNavigation } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" +import { NotificationBadge } from "@app/components/notification-badge" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { GaloyCurrencyBubbleText } from "../atomic/galoy-currency-bubble-text" @@ -56,12 +57,18 @@ type Props = { loading: boolean setIsStablesatModalVisible: (value: boolean) => void wallets?: readonly WalletBalance[] + showBtcNotification?: boolean + showUsdNotification?: boolean + onWalletPress?: (currency: WalletCurrency) => void } const WalletOverview: React.FC = ({ loading, setIsStablesatModalVisible, wallets, + showBtcNotification = false, + showUsdNotification = false, + onWalletPress, }) => { const { hideAmount, switchMemoryHideAmount } = useHideAmount() @@ -110,6 +117,9 @@ const WalletOverview: React.FC = ({ const openTransactionHistory = (currencyFilter: WalletCurrency) => wallets && navigation.navigate("transactionHistory", { wallets, currencyFilter }) + const [pressedBtc, setPressedBtc] = useState(false) + const [pressedUsd, setPressedUsd] = useState(false) + return ( @@ -120,25 +130,36 @@ const WalletOverview: React.FC = ({ - - openTransactionHistory(WalletCurrency.Btc)} + + + + setPressedBtc(true)} + onPressOut={() => setPressedBtc(false)} + onPress={() => { + onWalletPress?.(WalletCurrency.Btc) + openTransactionHistory(WalletCurrency.Btc) + }} > - + + + + + + {loading ? ( ) : hideAmount ? ( **** ) : ( - + {btcInUnderlyingCurrency} @@ -146,19 +167,30 @@ const WalletOverview: React.FC = ({ )} - - - openTransactionHistory(WalletCurrency.Usd)} + + + + + setPressedUsd(true)} + onPressOut={() => setPressedUsd(false)} + onPress={() => { + onWalletPress?.(WalletCurrency.Usd) + openTransactionHistory(WalletCurrency.Usd) + }} > - + + + + + + setIsStablesatModalVisible(true)}> @@ -166,7 +198,7 @@ const WalletOverview: React.FC = ({ {loading ? ( ) : ( - + {!hideAmount && ( <> {usdInUnderlyingCurrency ? ( @@ -187,7 +219,7 @@ const WalletOverview: React.FC = ({ )} - + ) } @@ -237,6 +269,9 @@ const useStyles = makeStyles(({ colors }) => ({ alignItems: "center", columnGap: 10, }, + bubbleWrapper: { + position: "relative", + }, hideableArea: { alignItems: "flex-end", }, @@ -247,4 +282,5 @@ const useStyles = makeStyles(({ colors }) => ({ height: 45, marginTop: 5, }, + pressedOpacity: { opacity: 0.7 }, })) diff --git a/app/graphql/client-only-query.ts b/app/graphql/client-only-query.ts index 42c4e8d165..1453a514f9 100644 --- a/app/graphql/client-only-query.ts +++ b/app/graphql/client-only-query.ts @@ -46,7 +46,7 @@ export default gql` } query colorScheme { - colorScheme @client + colorScheme @client # "system" | "light" | "dark" } query countryCode { diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 2e962c729b..880a897689 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -29,7 +29,7 @@ import { getErrorMessages } from "@app/graphql/utils" import { useI18nContext } from "@app/i18n/i18n-react" import { testProps } from "@app/utils/testProps" import { isIos } from "@app/utils/helper" -import { useAppConfig, useAutoShowUpgradeModal } from "@app/hooks" +import { useAppConfig, useAutoShowUpgradeModal, useUnseenTransactions } from "@app/hooks" import { AccountLevel, TransactionFragment, @@ -221,20 +221,20 @@ export const HomeScreen: React.FC = () => { const transactionsEdges = dataAuthed?.me?.defaultAccount?.transactions?.edges const transactions = useMemo(() => { - const transactions: TransactionFragment[] = [] - if (pendingIncomingTransactions) { - transactions.push(...pendingIncomingTransactions) - } - const settledTransactions = + const txs: TransactionFragment[] = [] + if (pendingIncomingTransactions) txs.push(...pendingIncomingTransactions) + const settled = transactionsEdges - ?.map((edge) => edge.node) + ?.map((e) => e.node) .filter( (tx) => tx.status !== TxStatus.Pending || tx.direction === TxDirection.Send, ) ?? [] - transactions.push(...settledTransactions) - return transactions + txs.push(...settled) + return txs }, [pendingIncomingTransactions, transactionsEdges]) + const { showBtc, showUsd, markSeen } = useUnseenTransactions(transactions) + const { canShowUpgradeModal, markShownUpgradeModal } = useAutoShowUpgradeModal({ cooldownDays: upgradeModalCooldownDays, enabled: isAuthed && levelAccount === AccountLevel.Zero, @@ -449,6 +449,9 @@ export const HomeScreen: React.FC = () => { loading={loading} setIsStablesatModalVisible={setIsStablesatModalVisible} wallets={wallets} + showBtcNotification={showBtc} + showUsdNotification={showUsd} + onWalletPress={markSeen} /> {error && } From 8802955c48357cd8b7a2c871696b881893554438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Tue, 30 Sep 2025 11:01:08 -0600 Subject: [PATCH 10/28] chore: notification-badge size changed --- app/components/notification-badge/notification-badge.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/notification-badge/notification-badge.tsx b/app/components/notification-badge/notification-badge.tsx index 92d0e6a32d..c7c4bb0e6d 100644 --- a/app/components/notification-badge/notification-badge.tsx +++ b/app/components/notification-badge/notification-badge.tsx @@ -15,9 +15,9 @@ type NotificationProps = { export const NotificationBadge: React.FC = ({ visible = false, text, - size = 18, - top = -6, - right = -8, + size = 12, + top = -5, + right = -4, style, maxWidth = 48, }) => { From 0e102524667802752ee67db6429238e41d1261ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Tue, 30 Sep 2025 11:03:41 -0600 Subject: [PATCH 11/28] feat: bounce-in component --- .../bounce-in-animation.tsx | 75 +++++++++++++++++++ app/components/notification-badge/index.ts | 1 + 2 files changed, 76 insertions(+) create mode 100644 app/components/notification-badge/bounce-in-animation.tsx diff --git a/app/components/notification-badge/bounce-in-animation.tsx b/app/components/notification-badge/bounce-in-animation.tsx new file mode 100644 index 0000000000..c2dd7718c2 --- /dev/null +++ b/app/components/notification-badge/bounce-in-animation.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from "react" +import { + withSequence, + withTiming, + withSpring, + Easing, + type SharedValue, +} from "react-native-reanimated" + +export const bounceInAnimation = ({ + scale, + duration, +}: { + scale: SharedValue + duration: number +}) => { + scale.value = 0.88 + scale.value = withSequence( + withTiming(1.22, { duration, easing: Easing.out(Easing.quad) }), + withSpring(1, { damping: 12, stiffness: 200 }), + ) +} + +export const useBounceInAnimation = ({ + isFocused, + visible, + scale, + delay, + duration, +}: { + isFocused: boolean + visible: boolean + scale: SharedValue + delay: number + duration: number +}) => { + const [rendered, setRendered] = useState(false) + const timerRef = useRef | null>(null) + const prevFocused = useRef(false) + const prevVisible = useRef(false) + + useEffect(() => { + const screenJustFocused = !prevFocused.current && isFocused + const visibilityJustEnabled = !prevVisible.current && visible + const shouldStartBounce = + isFocused && visible && (screenJustFocused || visibilityJustEnabled) + + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + + if (!isFocused || !visible) { + setRendered(false) + scale.value = 1 + } + + if (shouldStartBounce) { + setRendered(false) + timerRef.current = setTimeout(() => { + setRendered(true) + bounceInAnimation({ scale, duration }) + }, delay) + } + + prevFocused.current = isFocused + prevVisible.current = visible + + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [delay, duration, isFocused, scale, visible]) + + return rendered +} diff --git a/app/components/notification-badge/index.ts b/app/components/notification-badge/index.ts index c178318f11..531a30713a 100644 --- a/app/components/notification-badge/index.ts +++ b/app/components/notification-badge/index.ts @@ -1 +1,2 @@ export * from "./notification-badge" +export * from "./bounce-in-animation" From 3a928c265e88f79529a624276d70b8f773d09f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Tue, 30 Sep 2025 11:06:16 -0600 Subject: [PATCH 12/28] feat: using bounce-in animation to show notification badge --- .../notification-badge/notification-badge.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/app/components/notification-badge/notification-badge.tsx b/app/components/notification-badge/notification-badge.tsx index c7c4bb0e6d..9dc66a0df7 100644 --- a/app/components/notification-badge/notification-badge.tsx +++ b/app/components/notification-badge/notification-badge.tsx @@ -1,6 +1,10 @@ import React from "react" -import { View, Text as RNText, ViewStyle } from "react-native" +import { Text as RNText, ViewStyle } from "react-native" +import Animated, { useSharedValue, useAnimatedStyle } from "react-native-reanimated" import { makeStyles } from "@rneui/themed" +import { useIsFocused } from "@react-navigation/native" + +import { useBounceInAnimation } from "./bounce-in-animation" type NotificationProps = { visible?: boolean @@ -12,6 +16,9 @@ type NotificationProps = { maxWidth?: number } +const BOUNCE_DELAY = 300 +const BOUNCE_DURATION = 120 + export const NotificationBadge: React.FC = ({ visible = false, text, @@ -22,20 +29,35 @@ export const NotificationBadge: React.FC = ({ maxWidth = 48, }) => { const styles = useStyles({ size, top, right, maxWidth }) - if (!visible) return null + const isFocused = useIsFocused() + const scale = useSharedValue(1) + const rendered = useBounceInAnimation({ + isFocused, + visible, + scale, + delay: BOUNCE_DELAY, + duration: BOUNCE_DURATION, + }) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })) + if (!rendered) return null const hasText = typeof text === "string" && text.trim().length > 0 if (!hasText) { - return + return ( + + ) } return ( - + {text} - + ) } From 7f5bf947024dcb5a9c9ee45767c03abcac68ab14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Wed, 1 Oct 2025 11:07:02 -0600 Subject: [PATCH 13/28] fix: replacing @rneui/themed by @rn-vui/themed --- .../galoy-currency-bubble-text/galoy-currency-bubble-text.tsx | 2 +- app/components/notification-badge/notification-badge.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx index 3dad49fe55..f81ffa491b 100644 --- a/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx +++ b/app/components/atomic/galoy-currency-bubble-text/galoy-currency-bubble-text.tsx @@ -1,6 +1,6 @@ import React from "react" import { View } from "react-native" -import { useTheme, TextProps, Text, makeStyles } from "@rneui/themed" +import { useTheme, TextProps, Text, makeStyles } from "@rn-vui/themed" import { WalletCurrency } from "@app/graphql/generated" diff --git a/app/components/notification-badge/notification-badge.tsx b/app/components/notification-badge/notification-badge.tsx index 9dc66a0df7..7dfcc98a36 100644 --- a/app/components/notification-badge/notification-badge.tsx +++ b/app/components/notification-badge/notification-badge.tsx @@ -1,7 +1,7 @@ import React from "react" import { Text as RNText, ViewStyle } from "react-native" import Animated, { useSharedValue, useAnimatedStyle } from "react-native-reanimated" -import { makeStyles } from "@rneui/themed" +import { makeStyles } from "@rn-vui/themed" import { useIsFocused } from "@react-navigation/native" import { useBounceInAnimation } from "./bounce-in-animation" From 57584549f36c16b3a5ea88eb60df014839bf800f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Wed, 1 Oct 2025 11:25:27 -0600 Subject: [PATCH 14/28] fix: Show transaction notification only for received transactions --- app/hooks/use-new-transactions-indicator.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/hooks/use-new-transactions-indicator.ts b/app/hooks/use-new-transactions-indicator.ts index 966161f82c..72b8c883a3 100644 --- a/app/hooks/use-new-transactions-indicator.ts +++ b/app/hooks/use-new-transactions-indicator.ts @@ -5,6 +5,7 @@ import { TxLastSeenDocument, TxLastSeenQuery, WalletCurrency, + TxDirection, } from "@app/graphql/generated" import { markTxLastSeenId } from "@app/graphql/client-only-query" @@ -21,7 +22,9 @@ const latestId = ( currency: WalletCurrency, ) => transactions - .filter((tx) => tx.settlementCurrency === currency) + .filter( + (tx) => tx.settlementCurrency === currency && tx.direction === TxDirection.Receive, + ) .reduce( (acc, tx) => tx.createdAt > acc.createdAt ? { createdAt: tx.createdAt, id: tx.id } : acc, From f4b4f93fef2a06a8e23c8ca720553a7ce6a92f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Wed, 1 Oct 2025 11:32:15 -0600 Subject: [PATCH 15/28] chore: chore: hide question icon when USD notification is visible --- app/components/wallet-overview/wallet-overview.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/components/wallet-overview/wallet-overview.tsx b/app/components/wallet-overview/wallet-overview.tsx index 04c3d1a808..32282b845a 100644 --- a/app/components/wallet-overview/wallet-overview.tsx +++ b/app/components/wallet-overview/wallet-overview.tsx @@ -191,9 +191,11 @@ const WalletOverview: React.FC = ({ - setIsStablesatModalVisible(true)}> - - + {!showUsdNotification && ( + setIsStablesatModalVisible(true)}> + + + )} {loading ? ( From df383c72d54038fe24a80874bddd96381742a786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 2 Oct 2025 09:49:26 -0600 Subject: [PATCH 16/28] feat: highlight latest unseen transaction and mark it as seen --- .../transaction-item/transaction-item.tsx | 119 +++++++++++------- .../wallet-overview/wallet-overview.tsx | 3 - app/graphql/client-only-query.ts | 23 ---- app/hooks/index.ts | 2 +- app/hooks/use-new-transactions-indicator.ts | 84 ------------- app/hooks/use-transactions-notification.ts | 95 ++++++++++++++ app/screens/home-screen/home-screen.tsx | 17 +-- .../transaction-history-screen.tsx | 62 ++++++++- 8 files changed, 234 insertions(+), 171 deletions(-) delete mode 100644 app/hooks/use-new-transactions-indicator.ts create mode 100644 app/hooks/use-transactions-notification.ts diff --git a/app/components/transaction-item/transaction-item.tsx b/app/components/transaction-item/transaction-item.tsx index 167930c59e..31af6b5acb 100644 --- a/app/components/transaction-item/transaction-item.tsx +++ b/app/components/transaction-item/transaction-item.tsx @@ -14,13 +14,15 @@ import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { toWalletAmount } from "@app/types/amounts" import { testProps } from "@app/utils/testProps" -import { useNavigation } from "@react-navigation/native" +import { useNavigation, useIsFocused } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" import { Text, makeStyles, ListItem } from "@rn-vui/themed" +import Animated, { useSharedValue, useAnimatedStyle } from "react-native-reanimated" import { IconTransaction } from "../icon-transactions" import { TransactionDate } from "../transaction-date" import { DeepPartialObject } from "./index.types" +import { useBounceInAnimation } from "@app/components/notification-badge/bounce-in-animation" // This should extend the Transaction directly from the cache export const useDescriptionDisplay = ({ @@ -64,6 +66,8 @@ type Props = { isLast?: boolean isOnHomeScreen?: boolean testId?: string + highlight?: boolean + onPressHighlight?: (txid: string) => void } const TransactionItem: React.FC = ({ @@ -73,11 +77,14 @@ const TransactionItem: React.FC = ({ isLast = false, isOnHomeScreen = false, testId = "transaction-item", + highlight = false, + onPressHighlight, }) => { const styles = useStyles({ isFirst, isLast, isOnHomeScreen, + highlight, }) const navigation = useNavigation>() @@ -103,6 +110,19 @@ const TransactionItem: React.FC = ({ bankName: galoyInstance.name, }) + const isFocused = useIsFocused() + const scale = useSharedValue(1) + useBounceInAnimation({ + isFocused, + visible: highlight, + scale, + delay: 300, + duration: 120, + }) + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })) + if (!tx || Object.keys(tx).length === 0) { return null } @@ -147,51 +167,55 @@ const TransactionItem: React.FC = ({ : formattedSettlementAmount return ( - - navigation.navigate("transactionDetail", { - txid, - }) - } - > - - - - {description} - - - {subtitle ? ( - - ) : undefined} - - - - {hideAmount ? ( - **** - ) : ( - - {formattedDisplayAmount} - {formattedSecondaryAmount && ( - {formattedSecondaryAmount} - )} - - )} - + + { + if (highlight && onPressHighlight) onPressHighlight(txid) + + navigation.navigate("transactionDetail", { + txid, + }) + }} + > + + + + {description} + + + {subtitle ? ( + + ) : undefined} + + + + {hideAmount ? ( + **** + ) : ( + + {formattedDisplayAmount} + {formattedSecondaryAmount && ( + {formattedSecondaryAmount} + )} + + )} + + ) } @@ -201,6 +225,7 @@ type UseStyleProps = { isFirst?: boolean isLast?: boolean isOnHomeScreen?: boolean + highlight?: boolean } const useStyles = makeStyles(({ colors }, props: UseStyleProps) => ({ @@ -209,7 +234,7 @@ const useStyles = makeStyles(({ colors }, props: UseStyleProps) => ({ paddingVertical: 9, borderColor: colors.grey4, overflow: "hidden", - backgroundColor: colors.grey5, + backgroundColor: props.highlight ? colors.grey4 : colors.grey5, borderTopWidth: (props.isFirst && props.isOnHomeScreen) || !props.isFirst ? 1 : 0, borderBottomLeftRadius: props.isLast && props.isOnHomeScreen ? 12 : 0, borderBottomRightRadius: props.isLast && props.isOnHomeScreen ? 12 : 0, diff --git a/app/components/wallet-overview/wallet-overview.tsx b/app/components/wallet-overview/wallet-overview.tsx index 32282b845a..7c0ac1ceb1 100644 --- a/app/components/wallet-overview/wallet-overview.tsx +++ b/app/components/wallet-overview/wallet-overview.tsx @@ -68,7 +68,6 @@ const WalletOverview: React.FC = ({ wallets, showBtcNotification = false, showUsdNotification = false, - onWalletPress, }) => { const { hideAmount, switchMemoryHideAmount } = useHideAmount() @@ -137,7 +136,6 @@ const WalletOverview: React.FC = ({ onPressIn={() => setPressedBtc(true)} onPressOut={() => setPressedBtc(false)} onPress={() => { - onWalletPress?.(WalletCurrency.Btc) openTransactionHistory(WalletCurrency.Btc) }} > @@ -175,7 +173,6 @@ const WalletOverview: React.FC = ({ onPressIn={() => setPressedUsd(true)} onPressOut={() => setPressedUsd(false)} onPress={() => { - onWalletPress?.(WalletCurrency.Usd) openTransactionHistory(WalletCurrency.Usd) }} > diff --git a/app/graphql/client-only-query.ts b/app/graphql/client-only-query.ts index 1453a514f9..3c6ef8bae9 100644 --- a/app/graphql/client-only-query.ts +++ b/app/graphql/client-only-query.ts @@ -298,29 +298,6 @@ export const updateDeviceSessionCount = ( return setDeviceSessionCount(client, prev + 1) } -export const setTxLastSeen = ( - client: ApolloClient, - patch: { btcId?: string | null; usdId?: string | null }, -): { btcId: string; usdId: string } | null => { - try { - const prev = client.readQuery({ query: TxLastSeenDocument }) - - const data = { - __typename: "Query" as const, - txLastSeen: { - __typename: "TxLastSeen" as const, - btcId: patch.btcId === null ? "" : patch.btcId ?? prev?.txLastSeen?.btcId ?? "", - usdId: patch.usdId === null ? "" : patch.usdId ?? prev?.txLastSeen?.usdId ?? "", - }, - } - - client.writeQuery({ query: TxLastSeenDocument, data }) - return { btcId: data.txLastSeen.btcId, usdId: data.txLastSeen.usdId } - } catch { - return null - } -} - export const markTxLastSeenId = ( client: ApolloClient, currency: WalletCurrency, diff --git a/app/hooks/index.ts b/app/hooks/index.ts index dc85f8bab6..970ab6b199 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -3,4 +3,4 @@ export * from "./use-save-session-profile" export * from "./use-price-conversion" export * from "./use-app-config" export * from "./use-show-upgrade-modal" -export * from "./use-new-transactions-indicator" +export * from "./use-transactions-notification" diff --git a/app/hooks/use-new-transactions-indicator.ts b/app/hooks/use-new-transactions-indicator.ts deleted file mode 100644 index 72b8c883a3..0000000000 --- a/app/hooks/use-new-transactions-indicator.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect, useMemo, useState } from "react" -import { useApolloClient } from "@apollo/client" -import { - TransactionFragment, - TxLastSeenDocument, - TxLastSeenQuery, - WalletCurrency, - TxDirection, -} from "@app/graphql/generated" -import { markTxLastSeenId } from "@app/graphql/client-only-query" - -type TxHints = { - transactions?: ReadonlyArray - latestBtcId?: string | null - latestUsdId?: string | null -} - -type TxSource = ReadonlyArray | TxHints - -const latestId = ( - transactions: ReadonlyArray, - currency: WalletCurrency, -) => - transactions - .filter( - (tx) => tx.settlementCurrency === currency && tx.direction === TxDirection.Receive, - ) - .reduce( - (acc, tx) => - tx.createdAt > acc.createdAt ? { createdAt: tx.createdAt, id: tx.id } : acc, - { createdAt: 0, id: "" }, - ).id - -const isHints = (value: TxSource): value is TxHints => !Array.isArray(value) - -export const useUnseenTransactions = (txSource: TxSource) => { - const client = useApolloClient() - const [btc, setBtc] = useState(false) - const [usd, setUsd] = useState(false) - - const ids = useMemo(() => { - if (isHints(txSource)) { - return { - btcId: - txSource.latestBtcId ?? - latestId(txSource.transactions ?? [], WalletCurrency.Btc) ?? - "", - usdId: - txSource.latestUsdId ?? - latestId(txSource.transactions ?? [], WalletCurrency.Usd) ?? - "", - } - } - return { - btcId: latestId(txSource, WalletCurrency.Btc), - usdId: latestId(txSource, WalletCurrency.Usd), - } - }, [txSource]) - - useEffect(() => { - try { - const data = client.readQuery({ query: TxLastSeenDocument }) - setBtc(ids.btcId !== "" && ids.btcId !== (data?.txLastSeen?.btcId ?? "")) - setUsd(ids.usdId !== "" && ids.usdId !== (data?.txLastSeen?.usdId ?? "")) - } catch { - setBtc(false) - setUsd(false) - } - }, [client, ids]) - - const markSeen = (currency: WalletCurrency) => { - if (currency === WalletCurrency.Btc && ids.btcId) { - markTxLastSeenId(client, WalletCurrency.Btc, ids.btcId) - setBtc(false) - return - } - if (currency === WalletCurrency.Usd && ids.usdId) { - markTxLastSeenId(client, WalletCurrency.Usd, ids.usdId) - setUsd(false) - } - } - - return { showBtc: btc, showUsd: usd, markSeen } -} diff --git a/app/hooks/use-transactions-notification.ts b/app/hooks/use-transactions-notification.ts new file mode 100644 index 0000000000..8ce398758e --- /dev/null +++ b/app/hooks/use-transactions-notification.ts @@ -0,0 +1,95 @@ +import { useMemo } from "react" +import { useApolloClient, useQuery } from "@apollo/client" +import { + TransactionFragment, + TxDirection, + TxLastSeenDocument, + TxLastSeenQuery, + WalletCurrency, +} from "@app/graphql/generated" +import { markTxLastSeenId } from "@app/graphql/client-only-query" + +type TxDigest = { + transactions?: ReadonlyArray + latestBtcTxId?: string | null + latestUsdTxId?: string | null +} + +type TxSource = ReadonlyArray | TxDigest + +const latestTxId = ( + transactions: ReadonlyArray, + currency: WalletCurrency, +) => + transactions + .filter( + (tx) => tx.settlementCurrency === currency && tx.direction === TxDirection.Receive, + ) + .reduce( + (acc, tx) => + tx.createdAt > acc.createdAt ? { createdAt: tx.createdAt, id: tx.id } : acc, + { createdAt: 0, id: "" }, + ).id + +const isTxDigest = (value: TxSource): value is TxDigest => !Array.isArray(value) + +export const useTransactionsNotification = (txSource: TxSource) => { + const client = useApolloClient() + + const latestTxIds = useMemo(() => { + if (isTxDigest(txSource)) { + return { + btcId: + txSource.latestBtcTxId ?? + latestTxId(txSource.transactions ?? [], WalletCurrency.Btc) ?? + "", + usdId: + txSource.latestUsdTxId ?? + latestTxId(txSource.transactions ?? [], WalletCurrency.Usd) ?? + "", + } + } + return { + btcId: latestTxId(txSource, WalletCurrency.Btc), + usdId: latestTxId(txSource, WalletCurrency.Usd), + } + }, [txSource]) + + const { data: lastSeenData } = useQuery(TxLastSeenDocument, { + fetchPolicy: "cache-only", + returnPartialData: true, + }) + + const seenBtc = lastSeenData?.txLastSeen?.btcId ?? "" + const seenUsd = lastSeenData?.txLastSeen?.usdId ?? "" + + const latestBtcTxId = latestTxIds.btcId + const latestUsdTxId = latestTxIds.usdId + + const hasUnseenBtcTx = useMemo( + () => latestBtcTxId !== "" && latestBtcTxId !== seenBtc, + [latestBtcTxId, seenBtc], + ) + const hasUnseenUsdTx = useMemo( + () => latestUsdTxId !== "" && latestUsdTxId !== seenUsd, + [latestUsdTxId, seenUsd], + ) + + const markTxSeen = (currency: WalletCurrency) => { + if (currency === WalletCurrency.Btc) { + const id = latestBtcTxId + if (id) { + markTxLastSeenId(client, WalletCurrency.Btc, id) + } + return + } + if (currency === WalletCurrency.Usd) { + const id = latestUsdTxId + if (id) { + markTxLastSeenId(client, WalletCurrency.Usd, id) + } + } + } + + return { hasUnseenBtcTx, hasUnseenUsdTx, latestBtcTxId, latestUsdTxId, markTxSeen } +} diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 880a897689..a5825fd8e8 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -29,7 +29,11 @@ import { getErrorMessages } from "@app/graphql/utils" import { useI18nContext } from "@app/i18n/i18n-react" import { testProps } from "@app/utils/testProps" import { isIos } from "@app/utils/helper" -import { useAppConfig, useAutoShowUpgradeModal, useUnseenTransactions } from "@app/hooks" +import { + useAppConfig, + useAutoShowUpgradeModal, + useTransactionsNotification, +} from "@app/hooks" import { AccountLevel, TransactionFragment, @@ -233,7 +237,7 @@ export const HomeScreen: React.FC = () => { return txs }, [pendingIncomingTransactions, transactionsEdges]) - const { showBtc, showUsd, markSeen } = useUnseenTransactions(transactions) + const { hasUnseenBtcTx, hasUnseenUsdTx } = useTransactionsNotification(transactions) const { canShowUpgradeModal, markShownUpgradeModal } = useAutoShowUpgradeModal({ cooldownDays: upgradeModalCooldownDays, @@ -440,8 +444,8 @@ export const HomeScreen: React.FC = () => { } > @@ -449,9 +453,8 @@ export const HomeScreen: React.FC = () => { loading={loading} setIsStablesatModalVisible={setIsStablesatModalVisible} wallets={wallets} - showBtcNotification={showBtc} - showUsdNotification={showUsd} - onWalletPress={markSeen} + showBtcNotification={hasUnseenBtcTx} + showUsdNotification={hasUnseenUsdTx} /> {error && } diff --git a/app/screens/transaction-history/transaction-history-screen.tsx b/app/screens/transaction-history/transaction-history-screen.tsx index 2ba8760c68..1a7a581528 100644 --- a/app/screens/transaction-history/transaction-history-screen.tsx +++ b/app/screens/transaction-history/transaction-history-screen.tsx @@ -6,7 +6,11 @@ import { gql } from "@apollo/client" import { RouteProp } from "@react-navigation/native" import { Screen } from "@app/components/screen" -import { useTransactionListForDefaultAccountQuery } from "@app/graphql/generated" +import { + TransactionFragment, + useTransactionListForDefaultAccountQuery, + WalletCurrency, +} from "@app/graphql/generated" import { useIsAuthed } from "@app/graphql/is-authed-context" import { groupTransactionsByDate } from "@app/graphql/transactions" import { useI18nContext } from "@app/i18n/i18n-react" @@ -15,6 +19,7 @@ import { WalletValues, } from "@app/components/wallet-filter-dropdown" import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { useTransactionsNotification } from "@app/hooks" import { MemoizedTransactionItem } from "../../components/transaction-item" import { toastShow } from "../../utils/toast" @@ -60,6 +65,7 @@ export const TransactionHistoryScreen: React.FC = route.params?.currencyFilter ?? "ALL", ) + const currencyFilter: WalletCurrency | undefined = route.params?.currencyFilter const walletIdsByCurrency = React.useMemo(() => { const wallets = route.params?.wallets ?? [] return wallets @@ -81,17 +87,59 @@ export const TransactionHistoryScreen: React.FC = data?.me?.defaultAccount?.pendingIncomingTransactions const transactions = data?.me?.defaultAccount?.transactions + const settledTxs = React.useMemo( + () => transactions?.edges?.map((e) => e.node) ?? [], + [transactions], + ) + + const pendingTxs = React.useMemo( + () => (pendingIncomingTransactions ? [...pendingIncomingTransactions] : []), + [pendingIncomingTransactions], + ) + const sections = React.useMemo( () => groupTransactionsByDate({ - pendingIncomingTxs: pendingIncomingTransactions - ? [...pendingIncomingTransactions] - : [], - txs: transactions?.edges?.map((edge) => edge.node) ?? [], + pendingIncomingTxs: pendingTxs, + txs: settledTxs, LL, locale, }), - [pendingIncomingTransactions, transactions, LL, locale], + [pendingTxs, settledTxs, LL, locale], + ) + + const allTransactions = React.useMemo(() => { + const transactions: TransactionFragment[] = [] + transactions.push(...pendingTxs) + transactions.push(...settledTxs) + return transactions + }, [pendingTxs, settledTxs]) + + const { latestBtcTxId, latestUsdTxId, hasUnseenBtcTx, hasUnseenUsdTx, markTxSeen } = + useTransactionsNotification({ transactions: allTransactions }) + + const newTxId = React.useMemo(() => { + if (!currencyFilter) return "" + if (currencyFilter === WalletCurrency.Btc) return latestBtcTxId + + return latestUsdTxId + }, [currencyFilter, latestBtcTxId, latestUsdTxId]) + + const hasUnseenTx = React.useMemo(() => { + if (!currencyFilter) return false + if (currencyFilter === WalletCurrency.Btc) return hasUnseenBtcTx + + return hasUnseenUsdTx + }, [currencyFilter, hasUnseenBtcTx, hasUnseenUsdTx]) + + const onPressTxAction = React.useCallback( + (txid: string) => { + if (!currencyFilter) return + if (txid === newTxId) { + markTxSeen(currencyFilter) + } + }, + [currencyFilter, newTxId, markTxSeen], ) if (error) { @@ -144,6 +192,8 @@ export const TransactionHistoryScreen: React.FC = txid={item.id} subtitle testId={`transaction-by-index-${index}`} + highlight={hasUnseenTx && item.id === newTxId} + onPressHighlight={onPressTxAction} /> )} renderSectionHeader={({ section: { title } }) => ( From 67094b38c78891abf5cec9a6d6eb2104c2f3c98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 2 Oct 2025 11:16:56 -0600 Subject: [PATCH 17/28] feat: separate scan button with left line --- app/screens/home-screen/home-screen.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index a5825fd8e8..04df2a98aa 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -459,7 +459,10 @@ export const HomeScreen: React.FC = () => { {error && } {buttons.map((item) => ( - + ({ container: { marginHorizontal: 20, }, + scanButton: { + borderLeftWidth: 1, + borderLeftColor: colors.grey4, + paddingLeft: 10, + }, })) From e9af5193020a4c45c9d5609c665314012b5d0510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 16 Oct 2025 14:39:05 -0600 Subject: [PATCH 18/28] fix(reanimated): add deps array to useAnimatedStyle to prevent Jest crash --- app/components/notification-badge/notification-badge.tsx | 7 ++++--- app/components/transaction-item/transaction-item.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/components/notification-badge/notification-badge.tsx b/app/components/notification-badge/notification-badge.tsx index 7dfcc98a36..e87d42f860 100644 --- a/app/components/notification-badge/notification-badge.tsx +++ b/app/components/notification-badge/notification-badge.tsx @@ -39,9 +39,10 @@ export const NotificationBadge: React.FC = ({ duration: BOUNCE_DURATION, }) - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) + const animatedStyle = useAnimatedStyle( + () => ({ transform: [{ scale: scale.value }] }), + [scale], + ) if (!rendered) return null const hasText = typeof text === "string" && text.trim().length > 0 diff --git a/app/components/transaction-item/transaction-item.tsx b/app/components/transaction-item/transaction-item.tsx index 31af6b5acb..961404ec6e 100644 --- a/app/components/transaction-item/transaction-item.tsx +++ b/app/components/transaction-item/transaction-item.tsx @@ -119,9 +119,10 @@ const TransactionItem: React.FC = ({ delay: 300, duration: 120, }) - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) + const animatedStyle = useAnimatedStyle( + () => ({ transform: [{ scale: scale.value }] }), + [scale], + ) if (!tx || Object.keys(tx).length === 0) { return null From 4bc1a2c13997069c1e88ed52b7dafa346b80facf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Thu, 16 Oct 2025 16:02:35 -0600 Subject: [PATCH 19/28] fix(test): filters only BTC by route param --- app/graphql/mocks.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/graphql/mocks.ts b/app/graphql/mocks.ts index 452aa1470c..7187d3d00c 100644 --- a/app/graphql/mocks.ts +++ b/app/graphql/mocks.ts @@ -1400,7 +1400,7 @@ const mocks = [ ], }, }, - result: { + newData: () => ({ data: { me: { __typename: "User", @@ -1472,7 +1472,7 @@ const mocks = [ }, }, }, - }, + }), }, { request: { @@ -1485,7 +1485,7 @@ const mocks = [ ], }, }, - result: { + newData: () => ({ data: { me: { __typename: "User", @@ -1557,7 +1557,7 @@ const mocks = [ }, }, }, - }, + }), }, { request: { @@ -1567,7 +1567,7 @@ const mocks = [ walletIds: ["e821e124-1c70-4aab-9416-074ee5be21f6"], }, }, - result: { + newData: () => ({ data: { me: { __typename: "User", @@ -1627,7 +1627,7 @@ const mocks = [ }, }, }, - }, + }), }, ] From 1d094ba45bf3ffbe0aee3854f8159314c60c8897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 09:59:31 -0600 Subject: [PATCH 20/28] chore(home-screen): QR scan button separator --- app/screens/home-screen/home-screen.tsx | 34 +++++++++++++------------ 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 04df2a98aa..c196da0a47 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -459,17 +459,17 @@ export const HomeScreen: React.FC = () => { {error && } {buttons.map((item) => ( - - onMenuClick(item.target)} - /> - + + {item.icon === "qr-code" && } + + onMenuClick(item.target)} + /> + + ))} @@ -499,8 +499,9 @@ const useStyles = makeStyles(({ colors }) => ({ backgroundColor: colors.grey5, display: "flex", flexDirection: "row", - justifyContent: "space-around", + justifyContent: "space-between", alignItems: "center", + columnGap: 12, }, noTransaction: { alignItems: "center", @@ -547,6 +548,7 @@ const useStyles = makeStyles(({ colors }) => ({ button: { maxWidth: "25%", flexGrow: 1, + alignItems: "center", }, header: { flexDirection: "row", @@ -560,9 +562,9 @@ const useStyles = makeStyles(({ colors }) => ({ container: { marginHorizontal: 20, }, - scanButton: { - borderLeftWidth: 1, - borderLeftColor: colors.grey4, - paddingLeft: 10, + actionsSeparator: { + width: 1, + alignSelf: "stretch", + backgroundColor: colors.grey4, }, })) From 96163036461a7757f066748066b81b888f421098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 10:15:46 -0600 Subject: [PATCH 21/28] feat: new qr-code icon --- app/assets/icons-redesign/qr-code.svg | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/assets/icons-redesign/qr-code.svg b/app/assets/icons-redesign/qr-code.svg index 1949d9f2b2..3ee07ef3fd 100644 --- a/app/assets/icons-redesign/qr-code.svg +++ b/app/assets/icons-redesign/qr-code.svg @@ -1,9 +1,13 @@ - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file From c586ea21bf832fd8c8941fe64367dc613d9268a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 14:02:05 -0600 Subject: [PATCH 22/28] feat(transaction-history): mark newest tx as seen on first appearance --- .../transaction-item/transaction-item.tsx | 4 --- .../transaction-history-screen.tsx | 34 ++++++++----------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/app/components/transaction-item/transaction-item.tsx b/app/components/transaction-item/transaction-item.tsx index 961404ec6e..5b492bc872 100644 --- a/app/components/transaction-item/transaction-item.tsx +++ b/app/components/transaction-item/transaction-item.tsx @@ -67,7 +67,6 @@ type Props = { isOnHomeScreen?: boolean testId?: string highlight?: boolean - onPressHighlight?: (txid: string) => void } const TransactionItem: React.FC = ({ @@ -78,7 +77,6 @@ const TransactionItem: React.FC = ({ isOnHomeScreen = false, testId = "transaction-item", highlight = false, - onPressHighlight, }) => { const styles = useStyles({ isFirst, @@ -173,8 +171,6 @@ const TransactionItem: React.FC = ({ {...testProps(testId)} containerStyle={styles.container} onPress={() => { - if (highlight && onPressHighlight) onPressHighlight(txid) - navigation.navigate("transactionDetail", { txid, }) diff --git a/app/screens/transaction-history/transaction-history-screen.tsx b/app/screens/transaction-history/transaction-history-screen.tsx index 1a7a581528..bdf81bdf0f 100644 --- a/app/screens/transaction-history/transaction-history-screen.tsx +++ b/app/screens/transaction-history/transaction-history-screen.tsx @@ -53,6 +53,8 @@ type TransactionHistoryScreenProps = { route: RouteProp } +const lastHighlightedByCurrency: Partial> = {} + export const TransactionHistoryScreen: React.FC = ({ route, }) => { @@ -115,8 +117,9 @@ export const TransactionHistoryScreen: React.FC = return transactions }, [pendingTxs, settledTxs]) - const { latestBtcTxId, latestUsdTxId, hasUnseenBtcTx, hasUnseenUsdTx, markTxSeen } = - useTransactionsNotification({ transactions: allTransactions }) + const { latestBtcTxId, latestUsdTxId, markTxSeen } = useTransactionsNotification({ + transactions: allTransactions, + }) const newTxId = React.useMemo(() => { if (!currencyFilter) return "" @@ -125,22 +128,16 @@ export const TransactionHistoryScreen: React.FC = return latestUsdTxId }, [currencyFilter, latestBtcTxId, latestUsdTxId]) - const hasUnseenTx = React.useMemo(() => { - if (!currencyFilter) return false - if (currencyFilter === WalletCurrency.Btc) return hasUnseenBtcTx - - return hasUnseenUsdTx - }, [currencyFilter, hasUnseenBtcTx, hasUnseenUsdTx]) + const [stickyHighlightId, setStickyHighlightId] = React.useState(null) - const onPressTxAction = React.useCallback( - (txid: string) => { - if (!currencyFilter) return - if (txid === newTxId) { - markTxSeen(currencyFilter) - } - }, - [currencyFilter, newTxId, markTxSeen], - ) + React.useEffect(() => { + if (!currencyFilter || !newTxId) return + if (lastHighlightedByCurrency[currencyFilter] !== newTxId) { + setStickyHighlightId(newTxId) + markTxSeen(currencyFilter) + lastHighlightedByCurrency[currencyFilter] = newTxId + } + }, [currencyFilter, newTxId, markTxSeen]) if (error) { console.error(error) @@ -192,8 +189,7 @@ export const TransactionHistoryScreen: React.FC = txid={item.id} subtitle testId={`transaction-by-index-${index}`} - highlight={hasUnseenTx && item.id === newTxId} - onPressHighlight={onPressTxAction} + highlight={item.id === stickyHighlightId} /> )} renderSectionHeader={({ section: { title } }) => ( From 9a3b23be98274ca527f0d6a0498cb82e016f03da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 16:04:16 -0600 Subject: [PATCH 23/28] refactor: animation separated into components --- .../bounce-in-animation.tsx | 0 app/components/animations/index.ts | 1 + app/components/notification-badge/index.ts | 2 +- .../notification-badge/notification-badge.tsx | 2 +- app/components/transaction-item/transaction-item.tsx | 12 ++++++------ 5 files changed, 9 insertions(+), 8 deletions(-) rename app/components/{notification-badge => animations}/bounce-in-animation.tsx (100%) create mode 100644 app/components/animations/index.ts diff --git a/app/components/notification-badge/bounce-in-animation.tsx b/app/components/animations/bounce-in-animation.tsx similarity index 100% rename from app/components/notification-badge/bounce-in-animation.tsx rename to app/components/animations/bounce-in-animation.tsx diff --git a/app/components/animations/index.ts b/app/components/animations/index.ts new file mode 100644 index 0000000000..ce6a8dc85e --- /dev/null +++ b/app/components/animations/index.ts @@ -0,0 +1 @@ +export * from "./bounce-in-animation" diff --git a/app/components/notification-badge/index.ts b/app/components/notification-badge/index.ts index 531a30713a..4e24e650fa 100644 --- a/app/components/notification-badge/index.ts +++ b/app/components/notification-badge/index.ts @@ -1,2 +1,2 @@ export * from "./notification-badge" -export * from "./bounce-in-animation" +export * from "../animations/bounce-in-animation" diff --git a/app/components/notification-badge/notification-badge.tsx b/app/components/notification-badge/notification-badge.tsx index e87d42f860..e6d9528dae 100644 --- a/app/components/notification-badge/notification-badge.tsx +++ b/app/components/notification-badge/notification-badge.tsx @@ -4,7 +4,7 @@ import Animated, { useSharedValue, useAnimatedStyle } from "react-native-reanima import { makeStyles } from "@rn-vui/themed" import { useIsFocused } from "@react-navigation/native" -import { useBounceInAnimation } from "./bounce-in-animation" +import { useBounceInAnimation } from "../animations/bounce-in-animation" type NotificationProps = { visible?: boolean diff --git a/app/components/transaction-item/transaction-item.tsx b/app/components/transaction-item/transaction-item.tsx index 5b492bc872..ef8eb81c37 100644 --- a/app/components/transaction-item/transaction-item.tsx +++ b/app/components/transaction-item/transaction-item.tsx @@ -1,7 +1,11 @@ import React from "react" import { View } from "react-native" - +import Animated, { useSharedValue, useAnimatedStyle } from "react-native-reanimated" +import { StackNavigationProp } from "@react-navigation/stack" +import { useNavigation, useIsFocused } from "@react-navigation/native" +import { Text, makeStyles, ListItem } from "@rn-vui/themed" import { useFragment } from "@apollo/client" + import { TransactionFragment, TransactionFragmentDoc, @@ -12,17 +16,13 @@ import { useAppConfig } from "@app/hooks" import { useDisplayCurrency } from "@app/hooks/use-display-currency" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { useBounceInAnimation } from "@app/components/animations" import { toWalletAmount } from "@app/types/amounts" import { testProps } from "@app/utils/testProps" -import { useNavigation, useIsFocused } from "@react-navigation/native" -import { StackNavigationProp } from "@react-navigation/stack" -import { Text, makeStyles, ListItem } from "@rn-vui/themed" -import Animated, { useSharedValue, useAnimatedStyle } from "react-native-reanimated" import { IconTransaction } from "../icon-transactions" import { TransactionDate } from "../transaction-date" import { DeepPartialObject } from "./index.types" -import { useBounceInAnimation } from "@app/components/notification-badge/bounce-in-animation" // This should extend the Transaction directly from the cache export const useDescriptionDisplay = ({ From a3dc4331323f5bc08d04c742089379f150ee874e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 18:39:34 -0600 Subject: [PATCH 24/28] feat: drop-in animation --- .../animations/drop-in-animation.ts | 84 +++++++++++++++++++ app/components/animations/index.ts | 1 + 2 files changed, 85 insertions(+) create mode 100644 app/components/animations/drop-in-animation.ts diff --git a/app/components/animations/drop-in-animation.ts b/app/components/animations/drop-in-animation.ts new file mode 100644 index 0000000000..7843b7414f --- /dev/null +++ b/app/components/animations/drop-in-animation.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from "react" +import { Animated, Easing } from "react-native" + +type DropInAnimationParams = { + visible?: boolean + delay?: number + distance?: number + durationIn?: number + overshoot?: number + springStiffness?: number + springDamping?: number + springVelocity?: number +} + +export const useDropInAnimation = ({ + visible = true, + delay = 0, + distance = 56, + durationIn = 180, + overshoot = 5, + springStiffness = 200, + springDamping = 18, + springVelocity = 0.4, +}: DropInAnimationParams = {}) => { + const opacity = useRef(new Animated.Value(0)).current + const translateY = useRef(new Animated.Value(-distance)).current + + useEffect(() => { + opacity.stopAnimation() + translateY.stopAnimation() + + if (!visible) { + opacity.setValue(0) + translateY.setValue(-distance) + return + } + + opacity.setValue(0) + translateY.setValue(-distance) + + const anim = Animated.parallel([ + Animated.timing(opacity, { + toValue: 1, + duration: Math.round(durationIn * 0.8), + delay, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.sequence([ + Animated.timing(translateY, { + toValue: overshoot, + duration: durationIn, + delay, + easing: Easing.in(Easing.quad), + useNativeDriver: true, + }), + Animated.spring(translateY, { + toValue: 0, + stiffness: springStiffness, + damping: springDamping, + mass: 0.6, + velocity: springVelocity, + useNativeDriver: true, + }), + ]), + ]) + + anim.start() + return () => anim.stop() + }, [ + visible, + delay, + distance, + durationIn, + overshoot, + springStiffness, + springDamping, + springVelocity, + opacity, + translateY, + ]) + + return { opacity, translateY } +} diff --git a/app/components/animations/index.ts b/app/components/animations/index.ts index ce6a8dc85e..e3bebfd9ab 100644 --- a/app/components/animations/index.ts +++ b/app/components/animations/index.ts @@ -1 +1,2 @@ export * from "./bounce-in-animation" +export * from "./drop-in-animation" From 94b32ec28b169b4d3c8e4440edd2001a8ab13022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 18:45:11 -0600 Subject: [PATCH 25/28] feat(home-screen): incoming badge --- .../balance-header/balance-header.tsx | 2 +- .../incoming-amount-badge.tsx | 72 +++++++++++++++ .../incoming-amount-badge/index.tsx | 2 + .../use-incoming-badge.ts | 88 +++++++++++++++++++ app/hooks/index.ts | 1 + app/screens/home-screen/home-screen.tsx | 24 ++++- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 app/components/incoming-amount-badge/incoming-amount-badge.tsx create mode 100644 app/components/incoming-amount-badge/index.tsx create mode 100644 app/components/incoming-amount-badge/use-incoming-badge.ts diff --git a/app/components/balance-header/balance-header.tsx b/app/components/balance-header/balance-header.tsx index 6a02dc843c..816444150d 100644 --- a/app/components/balance-header/balance-header.tsx +++ b/app/components/balance-header/balance-header.tsx @@ -77,7 +77,7 @@ const useStyles = makeStyles(({ colors }) => ({ textAlign: "center", }, marginBottom: { - marginBottom: 4, + marginBottom: 0, }, hiddenBalanceTouchableOpacity: { alignItems: "center", diff --git a/app/components/incoming-amount-badge/incoming-amount-badge.tsx b/app/components/incoming-amount-badge/incoming-amount-badge.tsx new file mode 100644 index 0000000000..7e65516aa2 --- /dev/null +++ b/app/components/incoming-amount-badge/incoming-amount-badge.tsx @@ -0,0 +1,72 @@ +import * as React from "react" +import { Animated, Pressable } from "react-native" +import { Text, makeStyles } from "@rn-vui/themed" + +import { useDropInAnimation } from "@app/components/animations" + +const INCOMING_BADGE_ANIMATION = { + delay: 300, + distance: 15, + durationIn: 180, +} +const HIDDEN_STYLE = { + opacity: 0, + transform: [{ translateY: 0 }], +} + +type IncomingBadgeProps = { + text: string + visible?: boolean + onPress?: () => void +} + +export const IncomingAmountBadge: React.FC = ({ + text, + visible = true, + onPress, +}) => { + const styles = useStyles() + const { opacity, translateY } = useDropInAnimation({ + visible, + delay: INCOMING_BADGE_ANIMATION.delay, + distance: INCOMING_BADGE_ANIMATION.distance, + durationIn: INCOMING_BADGE_ANIMATION.durationIn, + }) + + return ( + + + {visible ? {text} : null} + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + touch: { + alignSelf: "center", + }, + badge: { + borderRadius: 8, + paddingHorizontal: 20, + alignSelf: "center", + }, + text: { + fontSize: 20, + fontWeight: "600", + color: colors._green, + }, +})) diff --git a/app/components/incoming-amount-badge/index.tsx b/app/components/incoming-amount-badge/index.tsx new file mode 100644 index 0000000000..39f67e1e37 --- /dev/null +++ b/app/components/incoming-amount-badge/index.tsx @@ -0,0 +1,2 @@ +export * from "./incoming-amount-badge" +export * from "./use-incoming-badge" diff --git a/app/components/incoming-amount-badge/use-incoming-badge.ts b/app/components/incoming-amount-badge/use-incoming-badge.ts new file mode 100644 index 0000000000..6fd720b1d3 --- /dev/null +++ b/app/components/incoming-amount-badge/use-incoming-badge.ts @@ -0,0 +1,88 @@ +import { useCallback, useMemo } from "react" +import { StackNavigationProp } from "@react-navigation/stack" +import { useNavigation } from "@react-navigation/native" + +import { TransactionFragment, TxDirection, WalletCurrency } from "@app/graphql/generated" +import type { RootStackParamList } from "@app/navigation/stack-param-lists" +import { useDisplayCurrency } from "@app/hooks" +import { toWalletAmount } from "@app/types/amounts" + +type IncomingBadgeParams = { + transactions?: TransactionFragment[] | null + hasUnseenUsdTx: boolean + hasUnseenBtcTx: boolean +} + +export const useIncomingAmountBadge = ({ + transactions, + hasUnseenUsdTx, + hasUnseenBtcTx, +}: IncomingBadgeParams) => { + const navigation = useNavigation>() + const { formatCurrency, formatMoneyAmount } = useDisplayCurrency() + + const latestIncomingTx = useMemo(() => { + if (!transactions) return + + const wantedCurrency = hasUnseenUsdTx + ? WalletCurrency.Usd + : hasUnseenBtcTx + ? WalletCurrency.Btc + : null + if (!wantedCurrency) return + + return transactions.find( + (t) => + t.direction === TxDirection.Receive && t.settlementCurrency === wantedCurrency, + ) + }, [transactions, hasUnseenUsdTx, hasUnseenBtcTx]) + + const incomingAmountText = useMemo(() => { + if (!latestIncomingTx) return null + + const { + settlementDisplayAmount: displayAmount, + settlementDisplayCurrency: displayCurrency, + settlementAmount: rawAmount, + settlementCurrency: rawCurrency, + direction, + } = latestIncomingTx + + const hasDisplayAmount = + displayAmount !== null && displayAmount !== undefined && Boolean(displayCurrency) + const hasRawAmount = + rawAmount !== null && rawAmount !== undefined && Boolean(rawCurrency) + + const formattedFromDisplay = hasDisplayAmount + ? formatCurrency({ amountInMajorUnits: displayAmount, currency: displayCurrency }) + : null + + const formattedFromRaw = + !formattedFromDisplay && hasRawAmount + ? formatMoneyAmount({ + moneyAmount: toWalletAmount({ + amount: rawAmount, + currency: rawCurrency as WalletCurrency, + }), + }) + : null + + const formatted = formattedFromDisplay ?? formattedFromRaw + if (!formatted) return null + + const sign = direction === TxDirection.Receive ? "+" : "-" + return `${sign}${formatted}` + }, [latestIncomingTx, formatCurrency, formatMoneyAmount]) + + const handleIncomingBadgePress = useCallback(() => { + if (!latestIncomingTx?.id) return + + navigation.navigate("transactionDetail", { txid: latestIncomingTx.id }) + }, [navigation, latestIncomingTx?.id]) + + return { + latestIncomingTx, + incomingAmountText, + handleIncomingBadgePress, + } +} diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 970ab6b199..81d293ba32 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -1,6 +1,7 @@ export * from "./use-geetest-captcha" export * from "./use-save-session-profile" export * from "./use-price-conversion" +export * from "./use-display-currency" export * from "./use-app-config" export * from "./use-show-upgrade-modal" export * from "./use-transactions-notification" diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index c196da0a47..df4a7c1cc1 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -21,6 +21,10 @@ import WalletOverview from "@app/components/wallet-overview/wallet-overview" import { BalanceHeader, useTotalBalance } from "@app/components/balance-header" import { TrialAccountLimitsModal } from "@app/components/upgrade-account-modal" import { Screen } from "@app/components/screen" +import { + IncomingAmountBadge, + useIncomingAmountBadge, +} from "@app/components/incoming-amount-badge" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { useRemoteConfig } from "@app/config/feature-flags-context" @@ -244,6 +248,12 @@ export const HomeScreen: React.FC = () => { enabled: isAuthed && levelAccount === AccountLevel.Zero, }) + const { incomingAmountText, handleIncomingBadgePress } = useIncomingAmountBadge({ + transactions, + hasUnseenBtcTx, + hasUnseenUsdTx, + }) + const [modalVisible, setModalVisible] = React.useState(false) const [isStablesatModalVisible, setIsStablesatModalVisible] = React.useState(false) const [isUpgradeModalVisible, setIsUpgradeModalVisible] = React.useState(false) @@ -437,6 +447,13 @@ export const HomeScreen: React.FC = () => { iconOnly={true} /> + + + ({ header: { flexDirection: "row", alignItems: "center", - height: 120, + marginTop: 40, }, error: { alignSelf: "center", @@ -567,4 +584,9 @@ const useStyles = makeStyles(({ colors }) => ({ alignSelf: "stretch", backgroundColor: colors.grey4, }, + badgeSlot: { + height: 65, + justifyContent: "center", + alignItems: "center", + }, })) From 3140851c3857f4895a038e5e040eac8307c50bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 19:38:27 -0600 Subject: [PATCH 26/28] feat: slide up component implmentation --- app/components/slide-up-handle/index.tsx | 123 +++++++++++++++++++++++ app/screens/home-screen/home-screen.tsx | 5 + 2 files changed, 128 insertions(+) create mode 100644 app/components/slide-up-handle/index.tsx diff --git a/app/components/slide-up-handle/index.tsx b/app/components/slide-up-handle/index.tsx new file mode 100644 index 0000000000..546aaff24c --- /dev/null +++ b/app/components/slide-up-handle/index.tsx @@ -0,0 +1,123 @@ +import React, { useCallback } from "react" +import { Pressable, View } from "react-native" +import { Gesture, GestureDetector } from "react-native-gesture-handler" +import Icon from "react-native-vector-icons/Ionicons" +import { makeStyles, useTheme } from "@rn-vui/themed" +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated" + +type PullUpHandleProps = { + onPullUp: () => void + bottomOffset?: number +} + +type UseStylesProps = { + bottomOffset: number +} + +const DRAG_THRESHOLD = -120 +const SPRING_BACK_MS = 120 + +export const SlideUpHandle: React.FC = ({ + onPullUp, + bottomOffset = 20, +}) => { + const { + theme: { colors }, + } = useTheme() + const styles = useStyles({ bottomOffset }) + + const y = useSharedValue(0) + const active = useSharedValue(0) + const trigger = useCallback(() => onPullUp(), [onPullUp]) + + const pan = Gesture.Pan() + .hitSlop({ top: 12, bottom: 24, left: 40, right: 40 }) + .onBegin(() => { + active.value = withTiming(1, { duration: 100 }) + }) + .onUpdate((e) => { + y.value = Math.min(0, e.translationY) + }) + .onEnd((e) => { + const shouldOpen = y.value < DRAG_THRESHOLD || e.velocityY < -500 + y.value = withTiming(0, { duration: SPRING_BACK_MS }) + if (shouldOpen) runOnJS(trigger)() + }) + .onFinalize(() => { + active.value = withTiming(0, { duration: 120 }) + }) + + const aTranslate = useAnimatedStyle(() => ({ + transform: [{ translateY: y.value * 0.35 }], + })) + + const aHighlight = useAnimatedStyle(() => ({ + opacity: active.value, + transform: [{ scale: 1 + active.value * 0.06 }], + })) + + return ( + + + + + (active.value = withTiming(1, { duration: 80 }))} + onPressOut={() => (active.value = withTiming(0, { duration: 120 }))} + style={styles.press} + > + + + + + + ) +} + +const useStyles = makeStyles(({ colors }, { bottomOffset }: UseStylesProps) => ({ + overlay: { + position: "absolute", + left: 0, + right: 0, + bottom: bottomOffset, + alignItems: "center", + justifyContent: "center", + }, + pill: { + width: 42, + height: 24, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + position: "relative", + }, + highlightBg: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + borderRadius: 12, + backgroundColor: colors.grey5, + borderColor: colors.grey4, + borderWidth: 1, + shadowColor: colors.black, + shadowOpacity: 0.08, + shadowRadius: 6, + shadowOffset: { width: 0, height: 2 }, + }, + press: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, +})) + +export default SlideUpHandle diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index df4a7c1cc1..08f9d4c444 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -20,6 +20,7 @@ import { StableSatsModal } from "@app/components/stablesats-modal" import WalletOverview from "@app/components/wallet-overview/wallet-overview" import { BalanceHeader, useTotalBalance } from "@app/components/balance-header" import { TrialAccountLimitsModal } from "@app/components/upgrade-account-modal" +import SlideUpHandle from "@app/components/slide-up-handle" import { Screen } from "@app/components/screen" import { IncomingAmountBadge, @@ -499,6 +500,10 @@ export const HomeScreen: React.FC = () => { }} /> + navigation.navigate("transactionHistory")} + /> ) } From cbec7d325c2deb973b8de146aa9389c69a9b3d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Esa=C3=BA=20G=C3=B3mez?= Date: Mon, 20 Oct 2025 20:57:57 -0600 Subject: [PATCH 27/28] feat: outgoing badge style --- .../incoming-amount-badge/incoming-amount-badge.tsx | 9 +++++---- .../incoming-amount-badge/use-incoming-badge.ts | 9 +++------ app/hooks/use-transactions-notification.ts | 5 +---- app/screens/home-screen/home-screen.tsx | 12 +++++++----- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/components/incoming-amount-badge/incoming-amount-badge.tsx b/app/components/incoming-amount-badge/incoming-amount-badge.tsx index 7e65516aa2..cbf66fcc7f 100644 --- a/app/components/incoming-amount-badge/incoming-amount-badge.tsx +++ b/app/components/incoming-amount-badge/incoming-amount-badge.tsx @@ -18,14 +18,16 @@ type IncomingBadgeProps = { text: string visible?: boolean onPress?: () => void + outgoing?: boolean } export const IncomingAmountBadge: React.FC = ({ text, visible = true, onPress, + outgoing, }) => { - const styles = useStyles() + const styles = useStyles({ outgoing }) const { opacity, translateY } = useDropInAnimation({ visible, delay: INCOMING_BADGE_ANIMATION.delay, @@ -55,7 +57,7 @@ export const IncomingAmountBadge: React.FC = ({ ) } -const useStyles = makeStyles(({ colors }) => ({ +const useStyles = makeStyles(({ colors }, { outgoing }: { outgoing?: boolean }) => ({ touch: { alignSelf: "center", }, @@ -66,7 +68,6 @@ const useStyles = makeStyles(({ colors }) => ({ }, text: { fontSize: 20, - fontWeight: "600", - color: colors._green, + color: outgoing ? colors.grey2 : colors._green, }, })) diff --git a/app/components/incoming-amount-badge/use-incoming-badge.ts b/app/components/incoming-amount-badge/use-incoming-badge.ts index 6fd720b1d3..9b75ee6aa9 100644 --- a/app/components/incoming-amount-badge/use-incoming-badge.ts +++ b/app/components/incoming-amount-badge/use-incoming-badge.ts @@ -31,10 +31,7 @@ export const useIncomingAmountBadge = ({ : null if (!wantedCurrency) return - return transactions.find( - (t) => - t.direction === TxDirection.Receive && t.settlementCurrency === wantedCurrency, - ) + return transactions.find((t) => t.settlementCurrency === wantedCurrency) }, [transactions, hasUnseenUsdTx, hasUnseenBtcTx]) const incomingAmountText = useMemo(() => { @@ -70,8 +67,7 @@ export const useIncomingAmountBadge = ({ const formatted = formattedFromDisplay ?? formattedFromRaw if (!formatted) return null - const sign = direction === TxDirection.Receive ? "+" : "-" - return `${sign}${formatted}` + return direction === TxDirection.Receive ? `+${formatted}` : formatted }, [latestIncomingTx, formatCurrency, formatMoneyAmount]) const handleIncomingBadgePress = useCallback(() => { @@ -84,5 +80,6 @@ export const useIncomingAmountBadge = ({ latestIncomingTx, incomingAmountText, handleIncomingBadgePress, + isOutgoing: latestIncomingTx?.direction === TxDirection.Send, } } diff --git a/app/hooks/use-transactions-notification.ts b/app/hooks/use-transactions-notification.ts index 8ce398758e..1fa141aaf3 100644 --- a/app/hooks/use-transactions-notification.ts +++ b/app/hooks/use-transactions-notification.ts @@ -2,7 +2,6 @@ import { useMemo } from "react" import { useApolloClient, useQuery } from "@apollo/client" import { TransactionFragment, - TxDirection, TxLastSeenDocument, TxLastSeenQuery, WalletCurrency, @@ -22,9 +21,7 @@ const latestTxId = ( currency: WalletCurrency, ) => transactions - .filter( - (tx) => tx.settlementCurrency === currency && tx.direction === TxDirection.Receive, - ) + .filter((tx) => tx.settlementCurrency === currency) .reduce( (acc, tx) => tx.createdAt > acc.createdAt ? { createdAt: tx.createdAt, id: tx.id } : acc, diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 08f9d4c444..f8c4bfd65f 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -249,11 +249,12 @@ export const HomeScreen: React.FC = () => { enabled: isAuthed && levelAccount === AccountLevel.Zero, }) - const { incomingAmountText, handleIncomingBadgePress } = useIncomingAmountBadge({ - transactions, - hasUnseenBtcTx, - hasUnseenUsdTx, - }) + const { incomingAmountText, handleIncomingBadgePress, isOutgoing } = + useIncomingAmountBadge({ + transactions, + hasUnseenBtcTx, + hasUnseenUsdTx, + }) const [modalVisible, setModalVisible] = React.useState(false) const [isStablesatModalVisible, setIsStablesatModalVisible] = React.useState(false) @@ -453,6 +454,7 @@ export const HomeScreen: React.FC = () => { text={incomingAmountText ?? ""} visible={Boolean(incomingAmountText)} onPress={handleIncomingBadgePress} + outgoing={isOutgoing} /> Date: Mon, 20 Oct 2025 21:39:03 -0600 Subject: [PATCH 28/28] fix(tx-history): reset sticky highlight on focus --- .../transaction-history/transaction-history-screen.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/screens/transaction-history/transaction-history-screen.tsx b/app/screens/transaction-history/transaction-history-screen.tsx index bdf81bdf0f..6d752abe5c 100644 --- a/app/screens/transaction-history/transaction-history-screen.tsx +++ b/app/screens/transaction-history/transaction-history-screen.tsx @@ -3,7 +3,7 @@ import { ActivityIndicator, SectionList, Text, View } from "react-native" import crashlytics from "@react-native-firebase/crashlytics" import { makeStyles, useTheme } from "@rn-vui/themed" import { gql } from "@apollo/client" -import { RouteProp } from "@react-navigation/native" +import { RouteProp, useFocusEffect } from "@react-navigation/native" import { Screen } from "@app/components/screen" import { @@ -130,6 +130,12 @@ export const TransactionHistoryScreen: React.FC = const [stickyHighlightId, setStickyHighlightId] = React.useState(null) + useFocusEffect( + React.useCallback(() => { + setStickyHighlightId(null) + }, []), + ) + React.useEffect(() => { if (!currencyFilter || !newTxId) return if (lastHighlightedByCurrency[currencyFilter] !== newTxId) {