feat(nwc): notify on failed pay_invoice and deep-link to activity

This commit is contained in:
ajaysehwal
2026-06-04 10:52:40 -04:00
committed by Evan Kaloudis
parent 6faee403fe
commit dcf098526e
6 changed files with 170 additions and 22 deletions
+17
View File
@@ -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
};
+18
View File
@@ -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<any, any> {
async componentDidMount() {
@@ -33,6 +35,20 @@ export default class PushNotificationManager extends React.Component<any, any> {
Notifications.registerRemoteNotifications();
};
/** NWC local notifications may deep-link to connection activity (optional failed row). */
private goToNwcActivityFromNotif(
payload: Record<string, unknown> | 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<any, any> {
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<any, any> {
Notifications.getInitialNotification()
.then((notification) => {
console.log('Initial notification was:', notification || 'N/A');
this.goToNwcActivityFromNotif(notification?.payload);
})
.catch((err) =>
console.error('getInitialNotifiation() failed', err)
+2
View File
@@ -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}}",
+6
View File
@@ -2175,6 +2175,12 @@ export default class NostrWalletConnectStore {
this.findAndUpdateConnection(connection);
});
NostrConnectUtils.notifyOutgoingNwcPaymentFailed(
amountSats,
connection.name,
connection.id,
id
);
}
// STORAGE OPERATIONS
+74 -7
View File
@@ -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<string, unknown>;
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<string, string> = {
[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<string, string>
): 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'
});
}
@@ -48,7 +48,10 @@ export const NWC_DEFAULT_FILTERS: NWCFilterState = {
interface ConnectionActivityProps {
navigation: NativeStackNavigationProp<any, any>;
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;
}