Skip to content

Commit

Permalink
feat(wallet-mobile): Add PT price changed notification (#3711)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeljscript authored Oct 25, 2024
1 parent 5a8dcc7 commit bc9338d
Show file tree
Hide file tree
Showing 10 changed files with 214 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {PermissionsAndroid} from 'react-native'

import {notificationManager} from './notification-manager'
import {parseNotificationId} from './notifications'
import {usePrimaryTokenPriceChangedNotification} from './primary-token-price-changed-notification'
import {useTransactionReceivedNotifications} from './transaction-received-notification'

let initialized = false
Expand Down Expand Up @@ -39,4 +40,5 @@ const init = () => {
export const useInitNotifications = ({enabled}: {enabled: boolean}) => {
React.useEffect(() => (enabled ? init() : undefined), [enabled])
useTransactionReceivedNotifications({enabled})
usePrimaryTokenPriceChangedNotification({enabled})
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {notificationManagerMaker} from '@yoroi/notifications'
import {Notifications} from '@yoroi/types'

import {displayNotificationEvent} from './notifications'
import {primaryTokenPriceChangedSubject} from './primary-token-price-changed-notification'
import {transactionReceivedSubject} from './transaction-received-notification'

const appStorage = mountAsyncStorage({path: '/'})
Expand All @@ -14,5 +15,6 @@ export const notificationManager = notificationManagerMaker({
display: displayNotificationEvent,
subscriptions: {
[Notifications.Trigger.TransactionReceived]: transactionReceivedSubject,
[Notifications.Trigger.PrimaryTokenPriceChanged]: primaryTokenPriceChangedSubject,
},
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {Notification, Notifications} from '@jamsinclair/react-native-notifications'
import {mountAsyncStorage} from '@yoroi/common'
import {Notifications as NotificationTypes} from '@yoroi/types'

import {formatCurrency, getCurrencySymbol} from '../../../Settings/useCases/changeAppSettings/Currency/CurrencyContext'

export const generateNotificationId = (): number => {
return generateRandomInteger(0, Number.MAX_SAFE_INTEGER)
}
Expand All @@ -13,14 +16,26 @@ const generateRandomInteger = (min: number, max: number): number => {
return Math.floor(Math.random() * (max - min + 1)) + min
}

export const displayNotificationEvent = (notificationEvent: NotificationTypes.Event) => {
export const displayNotificationEvent = async (notificationEvent: NotificationTypes.Event) => {
if (notificationEvent.trigger === NotificationTypes.Trigger.TransactionReceived) {
sendNotification({
title: 'Transaction received',
body: 'You have received a new transaction',
id: notificationEvent.id,
})
}

if (notificationEvent.trigger === NotificationTypes.Trigger.PrimaryTokenPriceChanged) {
const appStorage = mountAsyncStorage({path: '/'})
const currencyCode = await getCurrencySymbol(appStorage)
const newPrice = formatCurrency(notificationEvent.metadata.nextPrice, currencyCode)

sendNotification({
title: 'Primary token price changed',
body: `The price of the primary token has changed to ${newPrice}.`,
id: notificationEvent.id,
})
}
}

const sendNotification = (options: {title: string; body: string; id: number}) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {isRight, useAsyncStorage} from '@yoroi/common'
import {mountAsyncStorage} from '@yoroi/common/src'
import {App, Notifications as NotificationTypes} from '@yoroi/types'
import * as BackgroundFetch from 'expo-background-fetch'
import * as TaskManager from 'expo-task-manager'
import * as React from 'react'
import {Subject} from 'rxjs'

import {time} from '../../../../kernel/constants'
import {fetchPtPriceActivity} from '../../../../yoroi-wallets/cardano/usePrimaryTokenActivity'
import {getCurrencySymbol} from '../../../Settings/useCases/changeAppSettings/Currency/CurrencyContext'
import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider'
import {notificationManager} from './notification-manager'
import {generateNotificationId} from './notifications'
import {buildProcessedNotificationsStorage} from './storage'

const backgroundTaskId = 'yoroi-primary-token-price-changed-background-fetch'
const refetchIntervalInSeconds = 60 * 10
const refetchIntervalInMilliseconds = refetchIntervalInSeconds * 1000
const storageKey = 'notifications/primary-token-price-changed/'

// Check is needed for hot reloading, as task can not be defined twice
if (!TaskManager.isTaskDefined(backgroundTaskId)) {
const appStorage = mountAsyncStorage({path: '/'})
TaskManager.defineTask(backgroundTaskId, async () => {
const notifications = await buildNotifications(appStorage)
notifications.forEach((notification) => notificationManager.events.push(notification))

const hasNewData = notifications.length > 0
return hasNewData ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.NoData
})
}

const buildNotifications = async (
appStorage: App.Storage,
): Promise<NotificationTypes.PrimaryTokenPriceChangedEvent[]> => {
const notifications: NotificationTypes.PrimaryTokenPriceChangedEvent[] = []
const storage = buildProcessedNotificationsStorage(appStorage.join(storageKey))
const date = new Date()
const dateString = date.toDateString()

if (await storage.includes(dateString)) {
return []
}

const response = await fetchPtPriceActivity([Date.now(), Date.now() - time.oneDay])
const currency = await getCurrencySymbol(appStorage)
const notificationsConfig = await notificationManager.config.read()
const primaryTokenChangeNotificationConfig = notificationsConfig[NotificationTypes.Trigger.PrimaryTokenPriceChanged]

if (isRight(response)) {
const tickers = response.value.data.tickers
const close = tickers[0]?.prices[currency] ?? 1
const open = tickers[1]?.prices[currency] ?? 1
const changeInPercent = (Math.abs(close - open) / open) * 100

if (changeInPercent >= primaryTokenChangeNotificationConfig.thresholdInPercent) {
const event = createPrimaryTokenPriceChangedNotification({previousPrice: open, nextPrice: close})
notifications.push(event)
await storage.addValues([dateString])
}
}

return notifications
}

export const primaryTokenPriceChangedSubject = new Subject<NotificationTypes.PrimaryTokenPriceChangedEvent>()

export const usePrimaryTokenPriceChangedNotification = ({enabled}: {enabled: boolean}) => {
const {walletManager} = useWalletManager()
const asyncStorage = useAsyncStorage()

React.useEffect(() => {
if (!enabled) return
registerBackgroundFetchAsync()
return () => {
unregisterBackgroundFetchAsync()
}
}, [enabled])

React.useEffect(() => {
if (!enabled) return

const interval = setInterval(async () => {
const notifications = await buildNotifications(asyncStorage)
notifications.forEach((notification) => primaryTokenPriceChangedSubject.next(notification))
}, refetchIntervalInMilliseconds)

return () => clearInterval(interval)
}, [walletManager, asyncStorage, enabled])
}

const registerBackgroundFetchAsync = () => {
return BackgroundFetch.registerTaskAsync(backgroundTaskId, {
minimumInterval: refetchIntervalInSeconds,
stopOnTerminate: false,
startOnBoot: true,
})
}

const unregisterBackgroundFetchAsync = () => {
return BackgroundFetch.unregisterTaskAsync(backgroundTaskId)
}

const createPrimaryTokenPriceChangedNotification = (
metadata: NotificationTypes.PrimaryTokenPriceChangedEvent['metadata'],
): NotificationTypes.PrimaryTokenPriceChangedEvent => {
return {
trigger: NotificationTypes.Trigger.PrimaryTokenPriceChanged,
id: generateNotificationId(),
date: new Date().toISOString(),
isRead: false,
metadata,
} as const
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {App} from '@yoroi/types'

export const buildProcessedNotificationsStorage = (storage: App.Storage) => {
const getValues = async () => {
return (await storage.getItem<string[]>('processed')) || []
}

const addValues = async (values: string[]) => {
const processed = await getValues()
const newProcessed = [...processed, ...values]
await storage.setItem('processed', newProcessed)
}

const includes = async (value: string) => {
const processed = await getValues()
return processed.includes(value)
}

const clear = async () => {
await storage.setItem('processed', [])
}

return {
getValues,
addValues,
includes,
clear,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,39 @@ import {Subject} from 'rxjs'
import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types'
import {TRANSACTION_DIRECTION} from '../../../../yoroi-wallets/types/other'
import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider'
import {WalletManager, walletManager} from '../../../WalletManager/wallet-manager'
import {walletManager} from '../../../WalletManager/wallet-manager'
import {notificationManager} from './notification-manager'
import {generateNotificationId} from './notifications'
import {buildProcessedNotificationsStorage} from './storage'

const BACKGROUND_FETCH_TASK = 'yoroi-transaction-received-notifications-background-fetch'
const backgroundTaskId = 'yoroi-transaction-received-notifications-background-fetch'
const storageKey = 'transaction-received-notification-history'

// Check is needed for hot reloading, as task can not be defined twice
if (!TaskManager.isTaskDefined(BACKGROUND_FETCH_TASK)) {
if (!TaskManager.isTaskDefined(backgroundTaskId)) {
const appStorage = mountAsyncStorage({path: '/'})
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
await syncAllWallets(walletManager)
const notifications = await checkForNewTransactions(walletManager, appStorage)
TaskManager.defineTask(backgroundTaskId, async () => {
await syncAllWallets()
const notifications = await buildNotifications(appStorage)
notifications.forEach((notification) => notificationManager.events.push(notification))
const hasNewData = notifications.length > 0
return hasNewData ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.NoData
})
}

const registerBackgroundFetchAsync = () => {
return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
return BackgroundFetch.registerTaskAsync(backgroundTaskId, {
minimumInterval: 60 * 10,
stopOnTerminate: false,
startOnBoot: true,
})
}

const unregisterBackgroundFetchAsync = () => {
return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK)
return BackgroundFetch.unregisterTaskAsync(backgroundTaskId)
}

const syncAllWallets = async (walletManager: WalletManager) => {
const syncAllWallets = async () => {
const ids = [...walletManager.walletMetas.keys()]
for (const id of ids) {
const wallet = walletManager.getWalletById(id)
Expand All @@ -48,19 +50,19 @@ const syncAllWallets = async (walletManager: WalletManager) => {
}
}

const checkForNewTransactions = async (walletManager: WalletManager, appStorage: App.Storage) => {
const buildNotifications = async (appStorage: App.Storage) => {
const walletIds = [...walletManager.walletMetas.keys()]
const notifications: NotificationTypes.TransactionReceivedEvent[] = []

for (const walletId of walletIds) {
const wallet = walletManager.getWalletById(walletId)
if (!wallet) continue
const storage = buildStorage(appStorage, walletId)
const processed = await storage.getProcessedTransactions()
const storage = buildProcessedNotificationsStorage(appStorage.join(`wallet/${walletId}/${storageKey}/`))
const processed = await storage.getValues()
const allTxIds = getTxIds(wallet)

if (processed.length === 0) {
await storage.addProcessedTransactions(allTxIds)
await storage.addValues(allTxIds)
continue
}

Expand All @@ -70,7 +72,7 @@ const checkForNewTransactions = async (walletManager: WalletManager, appStorage:
continue
}

await storage.addProcessedTransactions(newTxIds)
await storage.addValues(newTxIds)

newTxIds.forEach((id) => {
const metadata: NotificationTypes.TransactionReceivedEvent['metadata'] = {
Expand Down Expand Up @@ -125,7 +127,7 @@ export const useTransactionReceivedNotifications = ({enabled}: {enabled: boolean
const areAllDone = walletsDoneSyncing.length === walletInfos.length
if (!areAllDone) return

const notifications = await checkForNewTransactions(walletManager, asyncStorage)
const notifications = await buildNotifications(asyncStorage)
notifications.forEach((notification) => transactionReceivedSubject.next(notification))
})

Expand All @@ -134,22 +136,3 @@ export const useTransactionReceivedNotifications = ({enabled}: {enabled: boolean
}
}, [walletManager, asyncStorage, enabled])
}

const buildStorage = (appStorage: App.Storage, walletId: string) => {
const storage = appStorage.join(`wallet/${walletId}/transaction-received-notification-history/`)

const getProcessedTransactions = async () => {
return (await storage.getItem<string[]>('processed')) || []
}

const addProcessedTransactions = async (txIds: string[]) => {
const processed = await getProcessedTransactions()
const newProcessed = [...processed, ...txIds]
await storage.setItem('processed', newProcessed)
}

return {
getProcessedTransactions,
addProcessedTransactions,
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {parseSafe, useAsyncStorage} from '@yoroi/common'
import {App} from '@yoroi/types'
import React from 'react'
import {useMutation, UseMutationOptions, useQuery, useQueryClient} from 'react-query'

Expand Down Expand Up @@ -37,21 +38,27 @@ const useCurrency = () => {
const storage = useAsyncStorage()
const query = useQuery<CurrencySymbol, Error>({
queryKey: ['currencySymbol'],
queryFn: async () => {
const currencySymbol = await storage.join('appSettings/').getItem('currencySymbol', parseCurrencySymbol)

if (currencySymbol != null) {
const stillSupported = Object.values(supportedCurrencies).includes(currencySymbol)
if (stillSupported) return currencySymbol
}

return defaultCurrency
},
queryFn: () => getCurrencySymbol(storage),
})

return query.data ?? defaultCurrency
}

export const getCurrencySymbol = async (storage: App.Storage) => {
const currencySymbol = await storage.join('appSettings/').getItem('currencySymbol', parseCurrencySymbol)

if (currencySymbol != null) {
const stillSupported = Object.values(supportedCurrencies).includes(currencySymbol)
if (stillSupported) return currencySymbol
}

return defaultCurrency
}

export const formatCurrency = (value: number, currency: CurrencySymbol) => {
return `${value.toFixed(configCurrencies[currency].decimals)} ${currency}`
}

const useSaveCurrency = ({onSuccess, ...options}: UseMutationOptions<void, Error, CurrencySymbol> = {}) => {
const queryClient = useQueryClient()
const storage = useAsyncStorage()
Expand Down
3 changes: 2 additions & 1 deletion packages/notifications/src/notification-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ describe('NotificationManager', () => {

const event = createTransactionReceivedEvent()

const notificationSubscription = new BehaviorSubject(event)
const notificationSubscription =
new BehaviorSubject<Notifications.TransactionReceivedEvent>(event)

const manager = notificationManagerMaker({
eventsStorage,
Expand Down
Loading

0 comments on commit bc9338d

Please sign in to comment.