diff --git a/packages/core-mobile/app/contexts/DeeplinkContext/utils/handleDeeplink.ts b/packages/core-mobile/app/contexts/DeeplinkContext/utils/handleDeeplink.ts index b1160ebbba..3a9f580c62 100644 --- a/packages/core-mobile/app/contexts/DeeplinkContext/utils/handleDeeplink.ts +++ b/packages/core-mobile/app/contexts/DeeplinkContext/utils/handleDeeplink.ts @@ -69,10 +69,17 @@ export const handleDeeplink = ({ deeplink.callback?.() navigateFromDeeplinkUrl('/claimStakeReward') } else if (action === ACTIONS.WatchList) { - const coingeckoId = pathname.split('/')[1] - navigateFromDeeplinkUrl( - `/trackTokenDetail?tokenId=${coingeckoId}&marketType=${MarketType.SEARCH}` - ) + const tokenId = pathname.split('/')[1] + if (tokenId) { + // Detect if this is an internalId (EIP-155 format) or coingeckoId + const isInternalId = tokenId.startsWith('eip155:') + const marketType = isInternalId + ? MarketType.TRENDING + : MarketType.SEARCH + navigateFromDeeplinkUrl( + `/trackTokenDetail?tokenId=${tokenId}&marketType=${marketType}` + ) + } } else if (action === ACTIONS.OfframpCompleted) { dispatch(offrampSend({ searchParams })) } else if (action === ACTIONS.OnrampCompleted) { diff --git a/packages/core-mobile/app/services/fcm/FCMService.ts b/packages/core-mobile/app/services/fcm/FCMService.ts index b0e80abc95..5239774aef 100644 --- a/packages/core-mobile/app/services/fcm/FCMService.ts +++ b/packages/core-mobile/app/services/fcm/FCMService.ts @@ -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 } } } diff --git a/packages/core-mobile/app/services/fcm/types.ts b/packages/core-mobile/app/services/fcm/types.ts index 4a2b3aab31..40927d2ac9 100644 --- a/packages/core-mobile/app/services/fcm/types.ts +++ b/packages/core-mobile/app/services/fcm/types.ts @@ -36,7 +36,8 @@ export const NewsDataSchema = object({ event: nativeEnum(NewsEvents), title: string(), body: string(), - url: string() + url: string(), + urlV2: string().optional() // New field for notifications with internalId }) export const NotificationPayloadSchema = object({ diff --git a/packages/core-mobile/app/services/notifications/priceAlert/setPriceAlertSubscriptions.ts b/packages/core-mobile/app/services/notifications/priceAlert/setPriceAlertSubscriptions.ts new file mode 100644 index 0000000000..fa907fb537 --- /dev/null +++ b/packages/core-mobile/app/services/notifications/priceAlert/setPriceAlertSubscriptions.ts @@ -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 { + 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 + ) + } +} diff --git a/packages/core-mobile/app/services/notifications/priceAlert/types.ts b/packages/core-mobile/app/services/notifications/priceAlert/types.ts new file mode 100644 index 0000000000..d96d47c51e --- /dev/null +++ b/packages/core-mobile/app/services/notifications/priceAlert/types.ts @@ -0,0 +1,8 @@ +export interface TokenSubscriptionItem { + internalId: string // EIP-155 format: eip155:43114-0x +} + +export interface TokenSubscriptionPayload { + tokens: TokenSubscriptionItem[] + deviceArn: string +} diff --git a/packages/core-mobile/app/services/notifications/priceAlert/unsubscribeForPriceAlert.ts b/packages/core-mobile/app/services/notifications/priceAlert/unsubscribeForPriceAlert.ts new file mode 100644 index 0000000000..fb3dc85cd0 --- /dev/null +++ b/packages/core-mobile/app/services/notifications/priceAlert/unsubscribeForPriceAlert.ts @@ -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 { + const fcmToken = await FCMService.getFCMToken() + const deviceArn = await registerDeviceToNotificationSender(fcmToken) + await setPriceAlertSubscriptions({ tokens: [], deviceArn }) +} diff --git a/packages/core-mobile/app/store/notifications/listeners/listeners.ts b/packages/core-mobile/app/store/notifications/listeners/listeners.ts index 0bf9b8040a..968f507196 100644 --- a/packages/core-mobile/app/store/notifications/listeners/listeners.ts +++ b/packages/core-mobile/app/store/notifications/listeners/listeners.ts @@ -12,6 +12,9 @@ import type { Action } from 'redux' import { ChannelId, NewsChannelId } from 'services/notifications/channels' import { handleProcessNotificationData } from 'store/notifications/listeners/handleProcessNotificationData' import { promptEnableNotifications } from 'store/notifications' +import { toggleWatchListFavorite } from 'store/watchlist' +import { setPriceAlertNotifications } from 'store/notifications/listeners/setPriceAlertNotifications' +import { unsubscribeForPriceAlert } from 'services/notifications/priceAlert/unsubscribeForPriceAlert' import { onFcmTokenChange, processNotificationData, @@ -189,6 +192,20 @@ export const addNotificationsListeners = ( ) }) }) + + startListening({ + actionCreator: toggleWatchListFavorite, + effect: setPriceAlertNotifications + }) + + startListening({ + matcher: isAnyOf(onNotificationsTurnedOffForNews), + effect: async () => { + await unsubscribeForPriceAlert().catch(reason => { + Logger.error(`[listeners.ts][unsubscribeForPriceAlert]${reason}`) + }) + } + }) } const onNotificationsTurnedOnForBalanceChange = { diff --git a/packages/core-mobile/app/store/notifications/listeners/setPriceAlertNotifications.ts b/packages/core-mobile/app/store/notifications/listeners/setPriceAlertNotifications.ts new file mode 100644 index 0000000000..270a9e8430 --- /dev/null +++ b/packages/core-mobile/app/store/notifications/listeners/setPriceAlertNotifications.ts @@ -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 => { + 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 + })) + + // 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}`) + } +} diff --git a/packages/core-mobile/app/store/notifications/listeners/unsubscribeAllNotifications.ts b/packages/core-mobile/app/store/notifications/listeners/unsubscribeAllNotifications.ts index 77a69e42fd..3e8a7266d7 100644 --- a/packages/core-mobile/app/store/notifications/listeners/unsubscribeAllNotifications.ts +++ b/packages/core-mobile/app/store/notifications/listeners/unsubscribeAllNotifications.ts @@ -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 { const fcmToken = await FCMService.getFCMToken() @@ -13,7 +14,8 @@ export async function unsubscribeAllNotifications(): Promise { unSubscribeForNews({ deviceArn, channelIds: [] - }) + }), + unsubscribeForPriceAlert() ]).catch(error => { //as fallback invalidate token so user doesn't get notifications messaging().deleteToken() diff --git a/packages/core-mobile/app/store/watchlist/slice.ts b/packages/core-mobile/app/store/watchlist/slice.ts index 7209fc9036..68850e4183 100644 --- a/packages/core-mobile/app/store/watchlist/slice.ts +++ b/packages/core-mobile/app/store/watchlist/slice.ts @@ -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[] => { return state.watchlist.favorites } diff --git a/packages/core-mobile/app/store/watchlist/types.ts b/packages/core-mobile/app/store/watchlist/types.ts index f8c2c47fee..5b19972fcb 100644 --- a/packages/core-mobile/app/store/watchlist/types.ts +++ b/packages/core-mobile/app/store/watchlist/types.ts @@ -30,7 +30,7 @@ export enum MarketType { } type InternalId = string -type CoingeckoId = string +export type CoingeckoId = string export type MarketToken = | { @@ -76,5 +76,5 @@ export type TokensAndCharts = { } export type WatchListFavoriteState = { - favorites: string[] + favorites: CoingeckoId[] }