Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notification subscription endpoints (pt2) #5208

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was merged in rainbow env here: https://github.com/rainbow-me/rainbow-env/pull/52

export const PINATA_API_KEY: string;
export const PINATA_API_SECRET: string;
export const PINATA_API_URL: string;
Expand Down
151 changes: 150 additions & 1 deletion src/notifications/settings/firebase.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationsSubscriptionResponse> => {
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<boolean> => {
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.
Expand All @@ -30,6 +116,69 @@ export const subscribeWalletToAllEnabledTopics = (
);
};

// returns updated wallet settings on success, undefined otherwise
export const publishWalletSettings = async (
walletSettings: WalletNotificationSettings[]
): Promise<WalletNotificationSettings[] | undefined> => {
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,
Expand Down
11 changes: 11 additions & 0 deletions src/notifications/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -121,3 +123,12 @@ export function toggleTopicForWallet(
);
}
}

export const publishAndSaveWalletSettings = async (
proposedSettings: WalletNotificationSettings[]
): Promise<void> => {
const finalizedSettings = await publishWalletSettings(proposedSettings);
if (finalizedSettings) {
setAllNotificationSettingsToStorage(finalizedSettings);
}
};
12 changes: 12 additions & 0 deletions src/notifications/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/rainbow-fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function generateError({
}) {
const message =
responseBody?.error ||
responseBody?.message ||
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we often use "message" in our RainbowErrors and without this, it was often falling back to the generic "There was an error with the request"

response?.statusText ||
'There was an error with the request.';

Expand Down
Loading