-
Notifications
You must be signed in to change notification settings - Fork 7
CP-9990: Implement Price Alerts for Watchlist Favorites #3067
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
base: main
Are you sure you want to change the base?
Changes from all commits
21b0422
1ce32ae
f6843c0
7025a12
20d7cdd
c2d24fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -138,7 +138,7 @@ class FCMService { | |
} | ||
} else if (fcmData.type === NotificationTypes.NEWS) { | ||
return { | ||
url: fcmData.url | ||
url: fcmData.urlV2 || fcmData.url // Prioritize urlV2 over url for backward compatibility | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's remove |
||
} | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -36,7 +36,8 @@ export const NewsDataSchema = object({ | |||
event: nativeEnum(NewsEvents), | ||||
title: string(), | ||||
body: string(), | ||||
url: string() | ||||
url: string(), | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
urlV2: string().optional() // New field for notifications with internalId | ||||
}) | ||||
|
||||
export const NotificationPayloadSchema = object({ | ||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import { TokenSubscriptionPayload } from 'services/notifications/priceAlert/types' | ||
import Logger from 'utils/Logger' | ||
import fetchWithAppCheck from 'utils/httpClient' | ||
import Config from 'react-native-config' | ||
|
||
export async function setPriceAlertSubscriptions( | ||
payload: TokenSubscriptionPayload | ||
): Promise<void> { | ||
Logger.info( | ||
'[setPriceAlertSubscriptions] Setting token subscriptions:', | ||
payload.tokens.map(t => t.internalId) | ||
) | ||
|
||
try { | ||
const response = await fetchWithAppCheck( | ||
Config.NOTIFICATION_SENDER_API_URL + | ||
'/v1/push/price-alerts/custom/subscribe', | ||
JSON.stringify(payload) | ||
) | ||
|
||
if (response.ok) { | ||
const result = await response.json() | ||
Logger.info( | ||
'[setPriceAlertSubscriptions] Successfully subscribed to token price alerts:', | ||
result | ||
) | ||
} else { | ||
throw new Error(`${response.status}:${response.statusText}`) | ||
} | ||
} catch (error) { | ||
Logger.error( | ||
`[setPriceAlertSubscriptions] Failed to set token subscriptions:`, | ||
error | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
export interface TokenSubscriptionItem { | ||
internalId: string // EIP-155 format: eip155:43114-0x<contract_address> | ||
} | ||
|
||
export interface TokenSubscriptionPayload { | ||
tokens: TokenSubscriptionItem[] | ||
deviceArn: string | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import { setPriceAlertSubscriptions } from 'services/notifications/priceAlert/setPriceAlertSubscriptions' | ||
import FCMService from 'services/fcm/FCMService' | ||
import { registerDeviceToNotificationSender } from 'services/notifications/registerDeviceToNotificationSender' | ||
|
||
export async function unsubscribeForPriceAlert(): Promise<void> { | ||
const fcmToken = await FCMService.getFCMToken() | ||
const deviceArn = await registerDeviceToNotificationSender(fcmToken) | ||
await setPriceAlertSubscriptions({ tokens: [], deviceArn }) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import Logger from 'utils/Logger' | ||
import { AnyAction } from '@reduxjs/toolkit' | ||
import { AppListenerEffectAPI } from 'store/types' | ||
import { selectWatchlistFavoriteIds } from 'store/watchlist' | ||
import FCMService from 'services/fcm/FCMService' | ||
import { registerDeviceToNotificationSender } from 'services/notifications/registerDeviceToNotificationSender' | ||
import NotificationsService from 'services/notifications/NotificationsService' | ||
import { selectNotificationSubscription } from 'store/notifications' | ||
import { ChannelId } from 'services/notifications/channels' | ||
import { unsubscribeForPriceAlert } from 'services/notifications/priceAlert/unsubscribeForPriceAlert' | ||
import { setPriceAlertSubscriptions } from 'services/notifications/priceAlert/setPriceAlertSubscriptions' | ||
|
||
export const setPriceAlertNotifications = async ( | ||
_: AnyAction, | ||
listenerApi: AppListenerEffectAPI | ||
): Promise<void> => { | ||
const state = listenerApi.getState() | ||
|
||
const userHasEnabledPriceAlertNotifications = selectNotificationSubscription( | ||
ChannelId.PRICE_ALERTS | ||
)(state) | ||
|
||
if (!userHasEnabledPriceAlertNotifications) { | ||
return | ||
} | ||
|
||
const favoriteTokensIds = selectWatchlistFavoriteIds(state) | ||
Logger.info( | ||
'[services/notifications/tokenChange/store/listeners.ts] Setting token subscriptions for favorites:', | ||
favoriteTokensIds | ||
) | ||
|
||
try { | ||
// Get deviceArn following the same pattern as other notification services | ||
const fcmToken = await FCMService.getFCMToken() | ||
const deviceArn = await registerDeviceToNotificationSender(fcmToken) | ||
|
||
//check if only PRICE_ALERTS notifications are denied | ||
const blockedNotifications = | ||
await NotificationsService.getBlockedNotifications() | ||
if (blockedNotifications.has(ChannelId.PRICE_ALERTS)) { | ||
await unsubscribeForPriceAlert() | ||
return | ||
} | ||
|
||
const tokens = favoriteTokensIds.map(id => ({ | ||
internalId: id | ||
})) | ||
Comment on lines
+46
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was under the impression that the backend API requires internal IDs. This list looks like it contains CoinGecko IDs rather than the actual internal ones. is this correct? |
||
|
||
// Only call the API if we have tokens to subscribe to | ||
if (tokens.length > 0) { | ||
await setPriceAlertSubscriptions({ | ||
tokens, | ||
deviceArn | ||
}) | ||
Logger.info( | ||
`[TokenChange] Successfully subscribed to token price alerts for ${tokens.length} tokens` | ||
) | ||
} else { | ||
Logger.info( | ||
'[TokenChange] No valid internalIds found for favorite tokens, skipping subscription' | ||
) | ||
} | ||
} catch (error) { | ||
// Handle specific APNS/FCM token errors gracefully | ||
Logger.error(`[setTokenSubscriptionsForFavorites]${error}`) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ import { unSubscribeForBalanceChange } from 'services/notifications/balanceChang | |
import { unSubscribeForNews } from 'services/notifications/news/unsubscribeForNews' | ||
import messaging from '@react-native-firebase/messaging' | ||
import Logger from 'utils/Logger' | ||
import { unsubscribeForPriceAlert } from 'services/notifications/priceAlert/unsubscribeForPriceAlert' | ||
|
||
export async function unsubscribeAllNotifications(): Promise<void> { | ||
const fcmToken = await FCMService.getFCMToken() | ||
|
@@ -13,7 +14,8 @@ export async function unsubscribeAllNotifications(): Promise<void> { | |
unSubscribeForNews({ | ||
deviceArn, | ||
channelIds: [] | ||
}) | ||
}), | ||
unsubscribeForPriceAlert() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it's probably better to use promise all settled so that each unsubscribe is independent. |
||
]).catch(error => { | ||
//as fallback invalidate token so user doesn't get notifications | ||
messaging().deleteToken() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,7 +5,7 @@ import { | |
BITCOIN_COINGECKO_ID, | ||
ETHEREUM_COINGECKO_ID | ||
} from 'consts/coingecko' | ||
import { initialState } from './types' | ||
import { CoingeckoId, initialState } from './types' | ||
|
||
const DEFAULT_WATCHLIST_FAVORITES = [ | ||
ETHEREUM_COINGECKO_ID, | ||
|
@@ -48,7 +48,7 @@ export const selectIsWatchlistFavorite = | |
(coingeckoId: string) => (state: RootState) => | ||
state.watchlist.favorites.includes(coingeckoId) | ||
|
||
export const selectWatchlistFavoriteIds = (state: RootState): string[] => { | ||
export const selectWatchlistFavoriteIds = (state: RootState): CoingeckoId[] => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. everything should be driven by internal id now. can we migrate coingecko ids to internal ids for favorites? |
||
return state.watchlist.favorites | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we are supporting solana pretty soon so we can't hard code this to eip155. there should be no need to support MarketType.SEARCH anymore since we are gonna use internal id only from now on.