feat(nwc): notify on failed pay_invoice and deep-link to activity
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -2175,6 +2175,12 @@ export default class NostrWalletConnectStore {
|
||||
|
||||
this.findAndUpdateConnection(connection);
|
||||
});
|
||||
NostrConnectUtils.notifyOutgoingNwcPaymentFailed(
|
||||
amountSats,
|
||||
connection.name,
|
||||
connection.id,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// STORAGE OPERATIONS
|
||||
|
||||
@@ -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({
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user