diff --git a/assets/ios/NotificationService.m b/assets/ios/NotificationService.m index 33e6066..a877cf1 100644 --- a/assets/ios/NotificationService.m +++ b/assets/ios/NotificationService.m @@ -125,14 +125,26 @@ - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withConte return; } - NSString *transactionJSON = [[NSString alloc] initWithData:transactionData encoding:NSUTF8StringEncoding]; - NSString *encodedTransaction = [transactionJSON stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; - if (!encodedTransaction) { + NSString *transactionJSON = [[NSString alloc] initWithData:transactionData encoding:NSUTF8StringEncoding]; + if (!transactionJSON) { + self.contentHandler(nil); + return; + } + + NSURLComponents *components = [[NSURLComponents alloc] init]; + components.scheme = @"alby"; + components.host = @"payment_notification"; + components.queryItems = @[ + [NSURLQueryItem queryItemWithName:@"transaction" value:transactionJSON], + [NSURLQueryItem queryItemWithName:@"app_pubkey" value:appPubkey], + ]; + + NSString *deepLink = components.string; + if (!deepLink) { self.contentHandler(nil); return; } - NSString *deepLink = [NSString stringWithFormat:@"alby://payment_notification?transaction=%@&app_pubkey=%@", encodedTransaction, appPubkey]; NSMutableDictionary *newUserInfo = [self.bestAttemptContent.userInfo mutableCopy] ?: [NSMutableDictionary dictionary]; NSMutableDictionary *newBodyDict = [newUserInfo[@"body"] mutableCopy] ?: [NSMutableDictionary dictionary]; diff --git a/hooks/__tests__/useHandleLinking.ts b/hooks/__tests__/useHandleLinking.ts index 96d9bb9..9158e03 100644 --- a/hooks/__tests__/useHandleLinking.ts +++ b/hooks/__tests__/useHandleLinking.ts @@ -136,6 +136,44 @@ describe("handleLink", () => { expect(router.push).not.toHaveBeenCalled(); }); + it("should preserve decoded callback and app icon params", async () => { + await handleLink( + "nostrnwc://connect?appname=Test%20App&callback=myapp%3A%2F%2Fopen%3Fredirect%3Dhttps%253A%252F%252Fdev.example.com%252Fdone&appicon=https%3A%2F%2Fcdn.example.com%2Ficon.png", + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.push).toHaveBeenCalledWith({ + pathname: "/settings/wallets/connect", + params: { + options: JSON.stringify({ + icon: "https://cdn.example.com/icon.png", + name: "Test App", + returnTo: + "myapp://open?redirect=https%3A%2F%2Fdev.example.com%2Fdone", + }), + flow: "deeplink", + }, + }); + }); + + it("should open payment notifications without decoding nested payloads twice", async () => { + await handleLink( + "alby://payment_notification?app_pubkey=abc&transaction=%7B%22type%22%3A%22incoming%22%2C%22state%22%3A%22settled%22%2C%22invoice%22%3A%22lnbc123%22%2C%22description%22%3A%22myapp%3A%2F%2Fopen%3Fredirect%3Dhttps%253A%252F%252Fdev.example.com%252Fdone%26payload%3D%257B%2522screen%2522%253A%2522payment%2522%257D%22%2C%22description_hash%22%3A%22%22%2C%22preimage%22%3A%22abc%22%2C%22payment_hash%22%3A%22def%22%2C%22amount%22%3A21000%2C%22fees_paid%22%3A0%2C%22created_at%22%3A1753275708%2C%22expires_at%22%3A1753362108%2C%22settled_at%22%3A1753275741%2C%22settle_deadline%22%3Anull%2C%22metadata%22%3Anull%7D", + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.push).toHaveBeenCalledWith({ + pathname: "/transaction", + params: { + appPubkey: "abc", + transactionJSON: + '{"type":"incoming","state":"settled","invoice":"lnbc123","description":"myapp://open?redirect=https%3A%2F%2Fdev.example.com%2Fdone&payload=%7B%22screen%22%3A%22payment%22%7D","description_hash":"","preimage":"abc","payment_hash":"def","amount":21000,"fees_paid":0,"created_at":1753275708,"expires_at":1753362108,"settled_at":1753275741,"settle_deadline":null,"metadata":null}', + }, + }); + }); + describe("Expo links", () => { test.each(Object.entries(testVectors))( "should parse the URL '%s' and navigate correctly", diff --git a/lib/link.ts b/lib/link.ts index a0ad94b..7185c68 100644 --- a/lib/link.ts +++ b/lib/link.ts @@ -85,15 +85,12 @@ export const handleLink = async (url: string) => { if (parsedUrl.protocol.startsWith("nostrnwc")) { const params = new URLSearchParams(search); const appname = params.get("appname"); - const rawCallback = params.get("callback"); - const rawAppIcon = params.get("appicon"); - if (!appname || !rawCallback || !rawAppIcon) { + const callback = params.get("callback"); + const appicon = params.get("appicon"); + if (!appname || !callback || !appicon) { return; } - const appicon = decodeURIComponent(rawAppIcon); - const callback = decodeURIComponent(rawCallback); - console.info("Navigating to NWA flow"); safeRouterPush({ pathname: "/settings/wallets/connect", @@ -148,11 +145,10 @@ export const handleLink = async (url: string) => { if (hostname === "payment_notification") { const urlParams = new URLSearchParams(search); const appPubkey = urlParams.get("app_pubkey"); - const transaction = urlParams.get("transaction"); - if (!transaction || !appPubkey) { + const transactionJSON = urlParams.get("transaction"); + if (!transactionJSON || !appPubkey) { return; } - const transactionJSON = decodeURIComponent(transaction); safeRouterPush({ pathname: "/transaction", params: { transactionJSON, appPubkey }, diff --git a/pages/Transaction.tsx b/pages/Transaction.tsx index 23f5cc9..eb57043 100644 --- a/pages/Transaction.tsx +++ b/pages/Transaction.tsx @@ -1,7 +1,7 @@ import type { Nip47Transaction, Nip47TransactionMetadata } from "@getalby/sdk"; import { hexToBytes } from "@noble/hashes/utils.js"; import dayjs from "dayjs"; -import { Link, useLocalSearchParams } from "expo-router"; +import { Link, router, useLocalSearchParams } from "expo-router"; import { nip19 } from "nostr-tools"; import React from "react"; import { @@ -20,6 +20,7 @@ import SentTransactionIcon from "~/components/icons/SentTransaction"; import Screen from "~/components/Screen"; import { Text } from "~/components/ui/text"; import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; +import { errorToast } from "~/lib/errorToast"; import { BitcoinDisplayFormat, useAppStore } from "~/lib/state/appStore"; import { cn, @@ -28,6 +29,11 @@ import { safeNpubEncode, } from "~/lib/utils"; +type TransactionRouteParams = { + transactionJSON: string; + appPubkey?: string; +}; + type TLVRecord = { type: number; value: string; @@ -50,69 +56,46 @@ type Boostagram = { }; export function Transaction() { - const { transactionJSON, appPubkey } = useLocalSearchParams() as { - transactionJSON: string; - appPubkey?: string; // only specified when opening from push notification - }; - const transaction: Nip47Transaction = JSON.parse(transactionJSON); - const getFiatAmount = useGetFiatAmount(); - const bitcoinDisplayFormat = useAppStore( - (store) => store.bitcoinDisplayFormat, - ); + const { transactionJSON, appPubkey } = + useLocalSearchParams(); - React.useEffect(() => { - if (appPubkey) { - useAppStore.getState().setSelectedWallet(appPubkey); + const transaction = React.useMemo(() => { + try { + return JSON.parse(transactionJSON) as Nip47Transaction; + } catch (error) { + console.error("Failed to parse transaction", error); + return null; } - }, [appPubkey]); + }, [transactionJSON]); - const TransactionIcon = React.useMemo(() => { - if (transaction.type === "incoming") { - return ReceivedTransactionIcon; - } - if (transaction.state === "settled") { - return SentTransactionIcon; - } - if (transaction.state === "pending") { - return PendingTransactionIcon; - } - if (transaction.state === "accepted") { - return AcceptedTransactionIcon; + React.useEffect(() => { + if (transaction) { + return; } - return FailedTransactionIcon; - }, [transaction.state, transaction.type]); - const boostagram = React.useMemo(() => { - let parsedBoostagram; - try { - const tlvRecord = ( - transaction.metadata?.tlv_records as TLVRecord[] - )?.find((record) => record.type === 7629169); - if (tlvRecord) { - parsedBoostagram = JSON.parse( - new TextDecoder().decode(hexToBytes(tlvRecord.value)), - ); - } - } catch (e) { - console.error(e); - } - return parsedBoostagram; - }, [transaction.metadata]); + errorToast( + new Error("Invalid transaction data"), + "Failed to open transaction", + ); + router.replace("/"); + }, [transaction]); - const eventId = transaction.metadata?.nostr?.tags?.find( - (t) => t[0] === "e", - )?.[1]; + React.useEffect(() => { + if (transaction && appPubkey) { + useAppStore.getState().setSelectedWallet(appPubkey); + } + }, [appPubkey, transaction]); - const pubkey = transaction.metadata?.nostr?.pubkey; - const npub = pubkey ? safeNpubEncode(pubkey) : undefined; + if (!transaction) { + return null; + } - const metadata = transaction.metadata as Nip47TransactionMetadata; + return ; +} - const displayCharacterCount = React.useMemo( - () => - new Intl.NumberFormat().format(Math.floor(transaction.amount / 1000)) - .length + (bitcoinDisplayFormat === "bip177" ? 1 : 4), - [transaction.amount, bitcoinDisplayFormat], +function TransactionScreen({ transaction }: { transaction: Nip47Transaction }) { + const bitcoinDisplayFormat = useAppStore( + (store) => store.bitcoinDisplayFormat, ); return ( @@ -121,179 +104,14 @@ export function Transaction() { - - - - - - - {transaction.type === "incoming" - ? transaction.state === "settled" - ? "Received" - : "Receiving" - : transaction.state === "failed" - ? "Failed" - : transaction.state === "pending" - ? "Sending" - : "Sent"} - - - - 11 - ? "ios:text-4xl" - : "ios:text-5xl", - displayCharacterCount <= 14 && - displayCharacterCount >= 11 && - "ios:sm:text-5xl", - ), - android: cn( - displayCharacterCount > 11 - ? "android:text-3xl" - : "android:text-[42px]", - displayCharacterCount <= 14 && - displayCharacterCount >= 11 && - "sm:android:text-[42px]", - ), - }), - "gap-2 font-semibold2", - transaction.type === "incoming" && - transaction.state === "settled" && - "text-receive", - )} - > - {transaction.type === "incoming" ? "+" : "-"} - {bitcoinDisplayFormat === "bip177" && " ₿"}{" "} - {Math.floor(transaction.amount / 1000)} - {bitcoinDisplayFormat === "sats" && ( - - {" "} - sats - - )} - - {getFiatAmount && ( - - {getFiatAmount(Math.floor(transaction.amount / 1000))} - - )} - - - - {metadata?.recipient_data?.identifier && ( - - )} - {metadata?.payer_data?.name && ( - - )} - - - {metadata?.comment && ( - - )} - {/* for Alby lightning addresses the content of the zap request is - automatically extracted and already displayed above as description */} - {transaction.metadata?.nostr && eventId && npub && ( - - - Nostr Zap - - - - - From {npub} - - - - - - )} - {boostagram && ( - - )} - {transaction.state === "settled" && - transaction.type === "outgoing" && ( - - )} - - {transaction.state === "settled" && ( - - )} - {metadata && ( - - )} - + + @@ -301,6 +119,250 @@ export function Transaction() { ); } +function TransactionSummary({ + transaction, + bitcoinDisplayFormat, +}: { + transaction: Nip47Transaction; + bitcoinDisplayFormat: BitcoinDisplayFormat; +}) { + const getFiatAmount = useGetFiatAmount(); + const TransactionIcon = getTransactionIcon(transaction); + const displayCharacterCount = + new Intl.NumberFormat().format(Math.floor(transaction.amount / 1000)) + .length + (bitcoinDisplayFormat === "bip177" ? 1 : 4); + + return ( + + + + + + + {getTransactionStatus(transaction)} + + + + 11 ? "ios:text-4xl" : "ios:text-5xl", + displayCharacterCount <= 14 && + displayCharacterCount >= 11 && + "ios:sm:text-5xl", + ), + android: cn( + displayCharacterCount > 11 + ? "android:text-3xl" + : "android:text-[42px]", + displayCharacterCount <= 14 && + displayCharacterCount >= 11 && + "sm:android:text-[42px]", + ), + }), + "gap-2 font-semibold2", + transaction.type === "incoming" && + transaction.state === "settled" && + "text-receive", + )} + > + {transaction.type === "incoming" ? "+" : "-"} + {bitcoinDisplayFormat === "bip177" && " ₿"}{" "} + {Math.floor(transaction.amount / 1000)} + {bitcoinDisplayFormat === "sats" && ( + + {" "} + sats + + )} + + {getFiatAmount && ( + + {getFiatAmount(Math.floor(transaction.amount / 1000))} + + )} + + + ); +} + +function TransactionDetails({ + transaction, + bitcoinDisplayFormat, +}: { + transaction: Nip47Transaction; + bitcoinDisplayFormat: BitcoinDisplayFormat; +}) { + const encodedEventId = getEncodedEventId(transaction); + const boostagram = React.useMemo( + () => getBoostagram(transaction.metadata), + [transaction.metadata], + ); + const pubkey = transaction.metadata?.nostr?.pubkey; + const npub = pubkey ? safeNpubEncode(pubkey) : undefined; + const metadata = transaction.metadata as Nip47TransactionMetadata; + + return ( + + {metadata?.recipient_data?.identifier && ( + + )} + {metadata?.payer_data?.name && ( + + )} + + + {metadata?.comment && ( + + )} + {/* for Alby lightning addresses the content of the zap request is + automatically extracted and already displayed above as description */} + {transaction.metadata?.nostr && encodedEventId && npub && ( + + + Nostr Zap + + + + + From {npub} + + + + + + )} + {boostagram && ( + + )} + {transaction.state === "settled" && transaction.type === "outgoing" && ( + + )} + + {transaction.state === "settled" && ( + + )} + {metadata && ( + + )} + + ); +} + +function getTransactionIcon(transaction: Nip47Transaction) { + if (transaction.type === "incoming") { + return ReceivedTransactionIcon; + } + if (transaction.state === "settled") { + return SentTransactionIcon; + } + if (transaction.state === "pending") { + return PendingTransactionIcon; + } + if (transaction.state === "accepted") { + return AcceptedTransactionIcon; + } + return FailedTransactionIcon; +} + +function getTransactionStatus(transaction: Nip47Transaction) { + if (transaction.type === "incoming") { + return transaction.state === "settled" ? "Received" : "Receiving"; + } + if (transaction.state === "failed") { + return "Failed"; + } + if (transaction.state === "pending") { + return "Sending"; + } + return "Sent"; +} + +function getEncodedEventId(transaction: Nip47Transaction) { + const eventId = transaction.metadata?.nostr?.tags?.find( + (tag) => tag[0] === "e", + )?.[1]; + + if (!eventId) { + return undefined; + } + + try { + return nip19.neventEncode({ id: eventId }); + } catch (error) { + console.error("Failed to encode nostr event id", error); + return undefined; + } +} + +function getBoostagram( + metadata?: Nip47TransactionMetadata, +): Boostagram | undefined { + try { + const tlvRecord = (metadata?.tlv_records as TLVRecord[])?.find( + (record) => record.type === 7629169, + ); + + if (!tlvRecord) { + return undefined; + } + + return JSON.parse( + new TextDecoder().decode(hexToBytes(tlvRecord.value)), + ) as Boostagram; + } catch (error) { + console.error(error); + return undefined; + } +} + function TransactionDetailRow(props: { title: string; content: string; @@ -336,6 +398,7 @@ function PodcastingInfo({ } return null; }; + return ( <> {renderDetail("Message", boost.message)}