diff --git a/globals.d.ts b/globals.d.ts index ba2a17c2f89..66188fa9164 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -59,6 +59,7 @@ declare module 'react-native-dotenv' { export const CLOUDINARY_API_KEY: string; export const CLOUDINARY_API_SECRET: string; export const CLOUDINARY_CLOUD_NAME: string; + export const NOTIFICATIONS_API_KEY: string; export const PINATA_API_KEY: string; export const PINATA_API_SECRET: string; export const PINATA_API_URL: string; diff --git a/src/notifications/settings/firebase.ts b/src/notifications/settings/firebase.ts index cb4014baefc..e9ff46aac70 100644 --- a/src/notifications/settings/firebase.ts +++ b/src/notifications/settings/firebase.ts @@ -1,11 +1,97 @@ +import { NOTIFICATIONS_API_KEY } from 'react-native-dotenv'; +import { logger, RainbowError } from '@/logger'; +import { RainbowNetworks } from '@/networks'; import { NotificationTopicType, + NotificationSubscriptionWalletsType, WalletNotificationSettings, } from '@/notifications/settings/types'; +import { getFCMToken, saveFCMToken } from '@/notifications/tokens'; import messaging from '@react-native-firebase/messaging'; import { trackChangedNotificationSettings } from '@/notifications/analytics'; import { NotificationTopic } from '@/notifications/settings/constants'; -import { logger } from '@/logger'; +import { rainbowFetch } from '@/rainbow-fetch'; + +const NOTIFICATION_SUBSCRIPTIONS_URL = + 'https://notifications.p.rainbow.me/api/v1/subscriptions'; + +const INVALID_FCM_TOKEN_ERROR = + 'failed to validate FCM token: invalid or expired FCM token'; + +type NotificationsSubscriptionResponse = { + error: boolean; + shouldRetry: boolean; +}; + +const updateNotificationSubscription = async ( + firebaseToken: string, + wallets: NotificationSubscriptionWalletsType[] +): Promise => { + try { + const options = { + firebase_token: firebaseToken, + wallets: wallets, + }; + await rainbowFetch(NOTIFICATION_SUBSCRIPTIONS_URL, { + method: 'put', + body: JSON.stringify(options), + headers: { + Authorization: `Bearer ${NOTIFICATIONS_API_KEY}`, + }, + }); + + // success + return { + error: false, + shouldRetry: false, + }; + } catch (error: any) { + // if INVALID_FCM_TOKEN_ERROR message, retry with updated FCM token + if (error.message === INVALID_FCM_TOKEN_ERROR) { + return { + error: true, + shouldRetry: true, + }; + } + + logger.error(new RainbowError('Failed to subscribe to notifications'), { + message: (error as Error).message, + }); + + return { + error: true, + shouldRetry: false, + }; + } +}; + +const updateNotificationSubscriptionWithRetry = async ( + firebaseToken: string, + wallets: NotificationSubscriptionWalletsType[] +): Promise => { + const subscriptionResponse = await updateNotificationSubscription( + firebaseToken, + wallets + ); + + if (!subscriptionResponse.error) { + // success + return true; + } else if (subscriptionResponse.shouldRetry) { + // retry with an updated FCM token + await saveFCMToken(); + const refreshedFirebaseToken = await getFCMToken(); + if (!refreshedFirebaseToken) return false; + + const subscriptionRetryResponse = await updateNotificationSubscription( + refreshedFirebaseToken, + wallets + ); + return !subscriptionRetryResponse.error; + } else { + return false; + } +}; /** Firebase functions for subscribing/unsubscribing to topics. @@ -30,6 +116,69 @@ export const subscribeWalletToAllEnabledTopics = ( ); }; +// returns updated wallet settings on success, undefined otherwise +export const publishWalletSettings = async ( + walletSettings: WalletNotificationSettings[] +): Promise => { + try { + const subscriptionPayload = parseWalletSettings(walletSettings); + let firebaseToken = await getFCMToken(); + + // refresh the FCM token if not found + if (!firebaseToken) { + await saveFCMToken(); + firebaseToken = await getFCMToken(); + if (!firebaseToken) return; + } + + const success = await updateNotificationSubscriptionWithRetry( + firebaseToken, + subscriptionPayload + ); + if (success) { + return walletSettings.map(setting => { + return { + ...setting, + successfullyFinishedInitialSubscription: true, + }; + }); + } else { + return; + } + } catch (e: any) { + logger.error( + new RainbowError('Failed to publish wallet notification settings'), + { + message: (e as Error).message, + } + ); + + return; + } +}; + +const parseWalletSettings = ( + walletSettings: WalletNotificationSettings[] +): NotificationSubscriptionWalletsType[] => { + return walletSettings.flatMap(setting => { + const topics = Object.keys(setting.topics).filter( + topic => !!setting.topics[topic] + ); + const notificationChainIds = RainbowNetworks.filter( + network => network.enabled && network.features.notifications + ).map(network => network.id); + + return notificationChainIds.map(chainId => { + return { + type: setting.type, + chain_id: chainId, + address: setting.address.toLowerCase(), + transaction_action_types: topics, + }; + }); + }); +}; + export const unsubscribeWalletFromAllNotificationTopics = ( type: string, chainId: number, diff --git a/src/notifications/settings/settings.ts b/src/notifications/settings/settings.ts index db91da2cc3a..af7fc9b7b64 100644 --- a/src/notifications/settings/settings.ts +++ b/src/notifications/settings/settings.ts @@ -10,12 +10,14 @@ import { import { getAllNotificationSettingsFromStorage, notificationSettingsStorage, + setAllNotificationSettingsToStorage, } from '@/notifications/settings/storage'; import { subscribeWalletToSingleNotificationTopic, unsubscribeWalletFromAllNotificationTopics, unsubscribeWalletFromSingleNotificationTopic, } from '@/notifications/settings/firebase'; +import { publishWalletSettings } from '@/notifications/settings/firebase'; /** 1. Reads notification settings for all wallets from storage. @@ -121,3 +123,12 @@ export function toggleTopicForWallet( ); } } + +export const publishAndSaveWalletSettings = async ( + proposedSettings: WalletNotificationSettings[] +): Promise => { + const finalizedSettings = await publishWalletSettings(proposedSettings); + if (finalizedSettings) { + setAllNotificationSettingsToStorage(finalizedSettings); + } +}; diff --git a/src/notifications/settings/types.ts b/src/notifications/settings/types.ts index 672e8748ef1..4f08c881025 100644 --- a/src/notifications/settings/types.ts +++ b/src/notifications/settings/types.ts @@ -11,6 +11,18 @@ export type WalletNotificationTopics = { [key: NotificationTopicType]: boolean; }; +export type NotificationSubscriptionWalletsType = { + type: NotificationRelationshipType; + chain_id: number; + address: string; + transaction_action_types: NotificationTopicType[]; +}; + +export type NotificationSubscriptionType = { + firebase_token: string; + wallets: NotificationSubscriptionWalletsType[]; +}; + export type WalletNotificationSettings = { address: string; topics: WalletNotificationTopics; diff --git a/src/rainbow-fetch/index.ts b/src/rainbow-fetch/index.ts index 33dfe2d8b6f..60cdb4dbe83 100644 --- a/src/rainbow-fetch/index.ts +++ b/src/rainbow-fetch/index.ts @@ -91,6 +91,7 @@ function generateError({ }) { const message = responseBody?.error || + responseBody?.message || response?.statusText || 'There was an error with the request.';