diff --git a/NavigationService.ts b/NavigationService.ts index bd35371ce..91573ef15 100644 --- a/NavigationService.ts +++ b/NavigationService.ts @@ -18,6 +18,22 @@ function navigate(routeName: string, params?: any) { ); } +const NAVIGATE_WHEN_READY_MAX_ATTEMPTS = 50; +const NAVIGATE_WHEN_READY_INTERVAL_MS = 100; + +function navigateWhenReady(routeName: string, params?: any, attempt = 0): void { + if (!_navigator || !_navigator.isReady()) { + if (attempt < NAVIGATE_WHEN_READY_MAX_ATTEMPTS) { + setTimeout( + () => navigateWhenReady(routeName, params, attempt + 1), + NAVIGATE_WHEN_READY_INTERVAL_MS + ); + } + return; + } + navigate(routeName, params); +} + export function getRouteStack() { if (_navigator.isReady()) { return _navigator.getRootState().routes; @@ -29,6 +45,7 @@ export function getRouteStack() { export default { navigate, + navigateWhenReady, getRouteStack, setTopLevelNavigator }; diff --git a/PushNotificationManager.tsx b/PushNotificationManager.tsx index d48164c5a..180335a4d 100644 --- a/PushNotificationManager.tsx +++ b/PushNotificationManager.tsx @@ -4,6 +4,8 @@ import { Notifications } from 'react-native-notifications'; import DeviceInfo from 'react-native-device-info'; import { lightningAddressStore, settingsStore } from './stores/Stores'; +import NavigationService from './NavigationService'; +import { parseNwcActivityNotif } from './utils/NostrConnectUtils'; export default class PushNotificationManager extends React.Component { async componentDidMount() { @@ -33,6 +35,20 @@ export default class PushNotificationManager extends React.Component { Notifications.registerRemoteNotifications(); }; + /** NWC local notifications may deep-link to connection activity (optional failed row). */ + private goToNwcActivityFromNotif( + payload: Record | undefined + ): void { + const link = parseNwcActivityNotif(payload); + if (!link) return; + NavigationService.navigateWhenReady('NWCConnectionActivity', { + connectionId: link.connectionId, + ...(link.failedActivityId && { + failedActivityId: link.failedActivityId + }) + }); + } + registerNotificationEvents = () => { Notifications.events().registerNotificationReceivedForeground( (notification, completion) => { @@ -71,6 +87,7 @@ export default class PushNotificationManager extends React.Component { console.log( `Notification opened with an action identifier: ${notification.identifier}` ); + this.goToNwcActivityFromNotif(notification.payload); completion(); } ); @@ -86,6 +103,7 @@ export default class PushNotificationManager extends React.Component { Notifications.getInitialNotification() .then((notification) => { console.log('Initial notification was:', notification || 'N/A'); + this.goToNwcActivityFromNotif(notification?.payload); }) .catch((err) => console.error('getInitialNotifiation() failed', err) diff --git a/locales/en.json b/locales/en.json index 26a5b445c..4b7d10ac5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1707,6 +1707,8 @@ "stores.NostrWalletConnectStore.sentCashuToken": "Sent Cashu token", "stores.NostrWalletConnectStore.paymentSentNotificationTitle": "Lightning payment sent", "stores.NostrWalletConnectStore.paymentSentNotificationBody": "{{amount}} {{unit}} sent via {{connectionName}}.", + "stores.NostrWalletConnectStore.paymentFailedNotificationTitle": "Lightning payment failed", + "stores.NostrWalletConnectStore.paymentFailedNotificationBody": "{{amount}} {{unit}} could not be sent via {{connectionName}}.", "stores.NostrWalletConnectStore.invoiceCreatedNotificationTitle": "Lightning invoice created", "stores.NostrWalletConnectStore.invoiceCreatedNotificationBody": "{{amount}} {{unit}} Lightning invoice created through {{connectionName}}.", "stores.NostrWalletConnectStore.invoiceCreatedNotificationBodyWithDescription": "{{amount}} {{unit}} Lightning invoice through {{connectionName}} ยท {{description}}", diff --git a/stores/NostrWalletConnectStore.ts b/stores/NostrWalletConnectStore.ts index 0b6ede483..79a68fddd 100644 --- a/stores/NostrWalletConnectStore.ts +++ b/stores/NostrWalletConnectStore.ts @@ -2175,6 +2175,12 @@ export default class NostrWalletConnectStore { this.findAndUpdateConnection(connection); }); + NostrConnectUtils.notifyOutgoingNwcPaymentFailed( + amountSats, + connection.name, + connection.id, + id + ); } // STORAGE OPERATIONS diff --git a/utils/NostrConnectUtils.ts b/utils/NostrConnectUtils.ts index e0db01230..8bb73c6b3 100644 --- a/utils/NostrConnectUtils.ts +++ b/utils/NostrConnectUtils.ts @@ -59,6 +59,10 @@ const NWC_TRAY_NOTIFICATION_KEYS = { outgoingTitle: 'stores.NostrWalletConnectStore.paymentSentNotificationTitle', outgoingBody: 'stores.NostrWalletConnectStore.paymentSentNotificationBody', + outgoingFailedTitle: + 'stores.NostrWalletConnectStore.paymentFailedNotificationTitle', + outgoingFailedBody: + 'stores.NostrWalletConnectStore.paymentFailedNotificationBody', invoiceReadyTitle: 'stores.NostrWalletConnectStore.invoiceCreatedNotificationTitle', invoiceReadyBody: @@ -67,6 +71,40 @@ const NWC_TRAY_NOTIFICATION_KEYS = { 'stores.NostrWalletConnectStore.invoiceCreatedNotificationBodyWithDescription' } as const; +export const NWC_ACTIVITY_NOTIF_KEYS = { + action: 'zeusNwcAction', + connectionId: 'zeusNwcConnectionId', + failedActivityId: 'zeusNwcFailedActivityId', + openActivity: 'connection_activity' +} as const; + +export type NwcActivityNotif = { + connectionId: string; + failedActivityId?: string; +}; + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} +export function parseNwcActivityNotif( + payload: unknown +): NwcActivityNotif | null { + if (!payload || typeof payload !== 'object') return null; + + const p = payload as Record; + const k = NWC_ACTIVITY_NOTIF_KEYS; + + if (p[k.action] !== k.openActivity) return null; + + const connectionId = nonEmptyString(p[k.connectionId]); + if (!connectionId) return null; + + const failedActivityId = nonEmptyString(p[k.failedActivityId]); + return failedActivityId + ? { connectionId, failedActivityId } + : { connectionId }; +} + export const DEFAULT_INVOICE_EXPIRY_SECONDS = 3600; export enum Nip47ErrorCode { @@ -1366,6 +1404,34 @@ export default class NostrConnectUtils { NostrConnectUtils.emitOsNotification(title, body); } + static notifyOutgoingNwcPaymentFailed( + amountSats: number, + connectionDisplayName: string, + connectionId: string, + failedActivityId: string + ): void { + const amountLabel = numberWithCommas(amountSats.toString()); + const unit = localeString('general.sats'); + const title = localeString( + NWC_TRAY_NOTIFICATION_KEYS.outgoingFailedTitle + ); + const body = localeString( + NWC_TRAY_NOTIFICATION_KEYS.outgoingFailedBody, + { + amount: amountLabel, + unit, + connectionName: connectionDisplayName + } + ); + const k = NWC_ACTIVITY_NOTIF_KEYS; + const extras: Record = { + [k.action]: k.openActivity, + [k.connectionId]: connectionId, + [k.failedActivityId]: failedActivityId + }; + NostrConnectUtils.emitOsNotification(title, body, extras); + } + static notifyNwcInvoiceReady( amountSats: number, connectionDisplayName: string, @@ -1397,18 +1463,19 @@ export default class NostrConnectUtils { NostrConnectUtils.emitOsNotification(title, body); } - private static emitOsNotification(title: string, body: string): void { + private static emitOsNotification( + title: string, + body: string, + extraPayload?: Record + ): void { + const base = { title, body, ...extraPayload }; if (Platform.OS === 'android') { // @ts-ignore:next-line - Notifications.postLocalNotification({ - title, - body - }); + Notifications.postLocalNotification(base); } else if (Platform.OS === 'ios') { // @ts-ignore:next-line Notifications.postLocalNotification({ - title, - body, + ...base, sound: 'chime.aiff' }); } diff --git a/views/Settings/NostrWalletConnect/NWCConnectionActivity.tsx b/views/Settings/NostrWalletConnect/NWCConnectionActivity.tsx index 0a9b50a95..fb33c1da9 100644 --- a/views/Settings/NostrWalletConnect/NWCConnectionActivity.tsx +++ b/views/Settings/NostrWalletConnect/NWCConnectionActivity.tsx @@ -48,7 +48,10 @@ export const NWC_DEFAULT_FILTERS: NWCFilterState = { interface ConnectionActivityProps { navigation: NativeStackNavigationProp; - route: Route<'NWCConnectionActivity', { connectionId: string }>; + route: Route< + 'NWCConnectionActivity', + { connectionId: string; failedActivityId?: string } + >; NostrWalletConnectStore: NostrWalletConnectStore; SettingsStore: SettingsStore; ModalStore: ModalStore; @@ -99,12 +102,15 @@ export default class NWCConnectionActivity extends React.Component< this.state.activeFilters ); - this.setState({ - activity, - filteredActivity, - connectionName: name, - loading: false - }); + this.setState( + { + activity, + filteredActivity, + connectionName: name, + loading: false + }, + () => this.tryOpenFailedPaymentModalFromRoute() + ); } catch (e: any) { this.setState({ error: @@ -188,6 +194,45 @@ export default class NWCConnectionActivity extends React.Component< } }; + componentDidUpdate(prevProps: ConnectionActivityProps) { + const prevFailed = prevProps.route.params?.failedActivityId; + const nextFailed = this.props.route.params?.failedActivityId; + if ( + nextFailed && + nextFailed !== prevFailed && + !this.state.loading && + this.state.activity.length > 0 + ) { + this.tryOpenFailedPaymentModalFromRoute(); + } + } + + openFailedPaymentModal = (item: ConnectionActivity) => { + const { ModalStore } = this.props; + ModalStore.toggleInfoModal({ + title: localeString('views.Payment.failedPayment'), + text: item.error + ? [item.error] + : [localeString('error.paymentFailed')] + }); + }; + + tryOpenFailedPaymentModalFromRoute = () => { + const failedActivityId = this.props.route.params?.failedActivityId; + if (!failedActivityId) return; + + const item = this.state.activity.find( + (a) => a.id === failedActivityId && a.status === 'failed' + ); + if (item) { + this.openFailedPaymentModal(item); + } + + this.props.navigation.setParams({ + failedActivityId: undefined + }); + }; + getAmountColor = (item: ConnectionActivity) => { if (item.status === 'success') { return item.type === 'make_invoice' ? 'success' : 'warning'; @@ -316,15 +361,8 @@ export default class NWCConnectionActivity extends React.Component< }; handleActivityPress = (item: ConnectionActivity) => { - const { ModalStore } = this.props; - if (item.status === 'failed') { - ModalStore.toggleInfoModal({ - title: localeString('views.Payment.failedPayment'), - text: item.error - ? [item.error] - : [localeString('error.paymentFailed')] - }); + this.openFailedPaymentModal(item); return; }