diff --git a/ReactotronConfig.js b/ReactotronConfig.js index f7c826d9c..82dcaa1b4 100644 --- a/ReactotronConfig.js +++ b/ReactotronConfig.js @@ -1,7 +1,7 @@ import Reactotron from 'reactotron-react-native'; import { reactotronRedux } from 'reactotron-redux'; import mmkvPlugin from 'reactotron-react-native-mmkv'; -import { storage } from './src/store/mmkv-storage'; +import { storage } from './src/storage'; const reactotron = Reactotron.configure() .use(reactotronRedux()) diff --git a/src/components/SlashtagsProvider.tsx b/src/components/SlashtagsProvider.tsx index e035d8072..02963675c 100644 --- a/src/components/SlashtagsProvider.tsx +++ b/src/components/SlashtagsProvider.tsx @@ -7,7 +7,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { createContext } from 'react'; import { useAppSelector } from '../hooks/redux'; -import { WebRelayCache } from '../store/mmkv-storage'; +import { WebRelayCache } from '../storage'; import { webRelaySelector } from '../store/reselect/settings'; import { seedHashSelector } from '../store/reselect/wallet'; import i18n from '../utils/i18n'; diff --git a/src/hooks/useBlocksWidget.ts b/src/hooks/useBlocksWidget.ts index dc9b17730..1160a496a 100644 --- a/src/hooks/useBlocksWidget.ts +++ b/src/hooks/useBlocksWidget.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { __E2E__ } from '../constants/env'; +import { widgetsCache } from '../storage/widgets-cache'; import { i18nTime } from '../utils/i18n'; -type TBlocksWidgetData = { +type TWidgetData = { height: string; time: string; date: string; @@ -32,15 +33,24 @@ type ErrorState = { type ReadyState = { status: EWidgetStatus.Ready; - data: TBlocksWidgetData; + data: TWidgetData; }; type TWidgetState = LoadingState | ErrorState | ReadyState; const BASE_URL = 'https://mempool.space/api'; const REFRESH_INTERVAL = 1000 * 60 * 2; // 2 minutes +const CACHE_KEY = 'blocks'; -const formatBlockInfo = (blockInfo): TBlocksWidgetData => { +const cacheData = (data: TWidgetData) => { + widgetsCache.set(CACHE_KEY, data); +}; + +const getCachedData = (): TWidgetData | null => { + return widgetsCache.get(CACHE_KEY); +}; + +const formatBlockInfo = (blockInfo): TWidgetData => { const { format } = new Intl.NumberFormat('en-US'); const difficulty = (blockInfo.difficulty / 1000000000000).toFixed(2); @@ -88,9 +98,11 @@ const formatBlockInfo = (blockInfo): TBlocksWidgetData => { }; const useBlocksWidget = (): TWidgetState => { - const [state, setState] = useState({ - status: EWidgetStatus.Loading, - data: null, + const [state, setState] = useState(() => { + const cached = getCachedData(); + return cached + ? { status: EWidgetStatus.Ready, data: cached } + : { status: EWidgetStatus.Loading, data: null }; }); useEffect(() => { @@ -122,9 +134,10 @@ const useBlocksWidget = (): TWidgetState => { try { const hash = await fetchTipHash(); const blockInfo = await fetchBlockInfo(hash); - const formatted = formatBlockInfo(blockInfo); + const data = formatBlockInfo(blockInfo); - setState({ status: EWidgetStatus.Ready, data: formatted }); + cacheData(data); + setState({ status: EWidgetStatus.Ready, data }); } catch (error) { console.error('Failed to fetch block data:', error); setState({ status: EWidgetStatus.Error, data: null }); diff --git a/src/hooks/useNewsWidget.ts b/src/hooks/useNewsWidget.ts index 6f053b352..2c985d45d 100644 --- a/src/hooks/useNewsWidget.ts +++ b/src/hooks/useNewsWidget.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { __E2E__ } from '../constants/env'; +import { widgetsCache } from '../storage/widgets-cache'; import { timeAgo } from '../utils/helpers'; type TArticle = { @@ -18,7 +19,7 @@ type TArticle = { }; }; -type TWidgetArticle = { +type TWidgetData = { title: string; timeAgo: string; link: string; @@ -43,18 +44,29 @@ type ErrorState = { type ReadyState = { status: EWidgetStatus.Ready; - data: TWidgetArticle; + data: TWidgetData; }; type TWidgetState = LoadingState | ErrorState | ReadyState; const BASE_URL = 'https://feeds.synonym.to/news-feed/api'; const REFRESH_INTERVAL = 1000 * 60 * 2; // 2 minutes +const CACHE_KEY = 'news'; + +const cacheData = (data: TWidgetData) => { + widgetsCache.set(CACHE_KEY, data); +}; + +const getCachedData = (): TWidgetData | null => { + return widgetsCache.get(CACHE_KEY); +}; const useNewsWidget = (): TWidgetState => { - const [state, setState] = useState({ - status: EWidgetStatus.Loading, - data: null, + const [state, setState] = useState(() => { + const cached = getCachedData(); + return cached + ? { status: EWidgetStatus.Ready, data: cached } + : { status: EWidgetStatus.Loading, data: null }; }); useEffect(() => { @@ -71,7 +83,6 @@ const useNewsWidget = (): TWidgetState => { }; const fetchData = async (): Promise => { - setState({ status: EWidgetStatus.Loading, data: null }); try { const articles = await fetchArticles(); @@ -87,6 +98,7 @@ const useNewsWidget = (): TWidgetState => { publisher: article.publisher.title, }; + cacheData(data); setState({ status: EWidgetStatus.Ready, data }); } catch (error) { console.error('Failed to fetch news data:', error); diff --git a/src/hooks/usePriceWidget.ts b/src/hooks/usePriceWidget.ts index 742ac9133..c0250977c 100644 --- a/src/hooks/usePriceWidget.ts +++ b/src/hooks/usePriceWidget.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { __E2E__ } from '../constants/env'; import { tradingPairs } from '../constants/widgets'; +import { widgetsCache } from '../storage/widgets-cache'; import { TGraphPeriod } from '../store/types/widgets'; import { IThemeColors } from '../styles/themes'; @@ -94,13 +95,42 @@ export const formatPrice = (pair: TradingPair, price: number): string => { } }; +const cacheData = ( + pairName: string, + period: TGraphPeriod, + data: TWidgetData, +) => { + const cacheKey = `${pairName}_${period}`; + widgetsCache.set(cacheKey, data); +}; + +const getCachedData = ( + pairs: string[], + period: TGraphPeriod, +): TWidgetData[] | null => { + const data = pairs.map((pairName) => { + const cacheKey = `${pairName}_${period}`; + const cached = widgetsCache.get(cacheKey); + return cached; + }); + + const allCached = data.every((d) => d !== null); + if (allCached) { + return data; + } + + return null; +}; + const usePriceWidget = ( pairs: string[], period: TGraphPeriod, ): TWidgetState => { - const [state, setState] = useState({ - status: EWidgetStatus.Loading, - data: null, + const [state, setState] = useState(() => { + const cached = getCachedData(pairs, period); + return cached + ? { status: EWidgetStatus.Ready, data: cached } + : { status: EWidgetStatus.Loading, data: null }; }); // biome-ignore lint/correctness/useExhaustiveDependencies: pairs is an array so deep check it @@ -146,12 +176,16 @@ const usePriceWidget = ( const change = getChange(updatedPastValues); const price = formatPrice(pair, latestPrice); - return { + const data = { name: pairName, price, change, pastValues: updatedPastValues, }; + + cacheData(pairName, period, data); + + return data; }); const data = await Promise.all(promises); setState({ status: EWidgetStatus.Ready, data }); @@ -179,12 +213,16 @@ const usePriceWidget = ( const change = getChange(newPastValues); const price = formatPrice(pair, latestPrice); - return { + const data = { ...pairData, price, change, pastValues: newPastValues, }; + + cacheData(pairData.name, period, data); + + return data; }), ); setState({ status: EWidgetStatus.Ready, data: updatedData }); diff --git a/src/hooks/useWeatherWidget.ts b/src/hooks/useWeatherWidget.ts index 699b06616..828459b3b 100644 --- a/src/hooks/useWeatherWidget.ts +++ b/src/hooks/useWeatherWidget.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { __E2E__ } from '../constants/env'; +import { widgetsCache } from '../storage/widgets-cache'; import { refreshOnchainFeeEstimates } from '../store/utils/fees'; import { getDisplayValues, getFiatDisplayValues } from '../utils/displayValues'; @@ -56,6 +57,15 @@ const VBYTES_SIZE = 140; // average native segwit transaction size const USD_GOOD_THRESHOLD = 1; // $1 USD threshold for good condition const PERCENTILE_LOW = 0.33; const PERCENTILE_HIGH = 0.66; +const CACHE_KEY = 'weather'; + +const cacheData = (data: TWidgetData) => { + widgetsCache.set(CACHE_KEY, data); +}; + +const getCachedData = (): TWidgetData | null => { + return widgetsCache.get(CACHE_KEY); +}; const calculateCondition = ( currentFeeRate: number, @@ -97,9 +107,11 @@ const calculateCondition = ( }; const useWeatherWidget = (): TWidgetState => { - const [state, setState] = useState({ - status: EWidgetStatus.Loading, - data: null, + const [state, setState] = useState(() => { + const cached = getCachedData(); + return cached + ? { status: EWidgetStatus.Ready, data: cached } + : { status: EWidgetStatus.Loading, data: null }; }); useEffect(() => { @@ -137,6 +149,7 @@ const useWeatherWidget = (): TWidgetState => { const currentFee = `${dv.fiatSymbol} ${dv.fiatFormatted}`; const data = { condition, currentFee, nextBlockFee: fees.fast }; + cacheData(data); setState({ status: EWidgetStatus.Ready, data }); } catch (error) { console.error('Failed to fetch fee data:', error); diff --git a/src/screens/Settings/DevSettings/index.tsx b/src/screens/Settings/DevSettings/index.tsx index d9de34316..22675c443 100644 --- a/src/screens/Settings/DevSettings/index.tsx +++ b/src/screens/Settings/DevSettings/index.tsx @@ -8,13 +8,14 @@ import { EItemType, IListData } from '../../../components/List'; import { __E2E__ } from '../../../constants/env'; import { useAppDispatch, useAppSelector } from '../../../hooks/redux'; import type { SettingsScreenProps } from '../../../navigation/types'; +import { widgetsCache } from '../../../storage'; +import { storage } from '../../../storage'; import actions from '../../../store/actions/actions'; import { clearUtxos, injectFakeTransaction, } from '../../../store/actions/wallet'; import { getStore, getWalletStore } from '../../../store/helpers'; -import { storage } from '../../../store/mmkv-storage'; import { warningsSelector } from '../../../store/reselect/checks'; import { settingsSelector } from '../../../store/reselect/settings'; import { @@ -171,6 +172,11 @@ const DevSettings = ({ type: EItemType.button, onPress: clearWebRelayCache, }, + { + title: 'Clear Widgets Cache', + type: EItemType.button, + onPress: widgetsCache.clear, + }, { title: "Clear UTXO's", type: EItemType.button, diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 000000000..2cbe9053c --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,9 @@ +import { MMKV } from 'react-native-mmkv'; +import { receivedTxIds } from './received-tx-cache'; +import { reduxStorage } from './redux-storage'; +import { WebRelayCache } from './webrelay-cache'; +import { widgetsCache } from './widgets-cache'; + +export const storage = new MMKV(); + +export { reduxStorage, receivedTxIds, WebRelayCache, widgetsCache }; diff --git a/src/storage/received-tx-cache.ts b/src/storage/received-tx-cache.ts new file mode 100644 index 000000000..159756401 --- /dev/null +++ b/src/storage/received-tx-cache.ts @@ -0,0 +1,32 @@ +// Used to prevent duplicate notifications for the same txId that seems to occur when: +// - when Bitkit is brought from background to foreground + +import { storage } from '.'; + +// - connection to electrum server is lost and then re-established +export const receivedTxIds = { + STORAGE_KEY: 'receivedTxIds', + + // Get stored txIds + get: (): string[] => { + return JSON.parse(storage.getString(receivedTxIds.STORAGE_KEY) || '[]'); + }, + + // Save txIds to storage + save: (txIds: string[]): void => { + storage.set(receivedTxIds.STORAGE_KEY, JSON.stringify(txIds)); + }, + + // Add a new txId + add: (txId: string): void => { + const txIds = receivedTxIds.get(); + txIds.push(txId); + receivedTxIds.save(txIds); + }, + + // Check if txId exists + has: (txId: string): boolean => { + const txIds = receivedTxIds.get(); + return txIds.includes(txId); + }, +}; diff --git a/src/storage/redux-storage.ts b/src/storage/redux-storage.ts new file mode 100644 index 000000000..086e27c30 --- /dev/null +++ b/src/storage/redux-storage.ts @@ -0,0 +1,17 @@ +import { Storage } from 'redux-persist'; +import { storage } from '.'; + +export const reduxStorage: Storage = { + setItem: (key, value): Promise => { + storage.set(key, value); + return Promise.resolve(true); + }, + getItem: (key): Promise => { + const value = storage.getString(key); + return Promise.resolve(value); + }, + removeItem: (key): Promise => { + storage.delete(key); + return Promise.resolve(); + }, +}; diff --git a/src/store/mmkv-storage.ts b/src/storage/webrelay-cache.ts similarity index 51% rename from src/store/mmkv-storage.ts rename to src/storage/webrelay-cache.ts index c0ab17cdc..217ff5db3 100644 --- a/src/store/mmkv-storage.ts +++ b/src/storage/webrelay-cache.ts @@ -1,53 +1,5 @@ import type { IteratorOptions } from 'level'; -import { MMKV } from 'react-native-mmkv'; -import { Storage } from 'redux-persist'; - -export const storage = new MMKV(); - -export const reduxStorage: Storage = { - setItem: (key, value) => { - storage.set(key, value); - return Promise.resolve(true); - }, - getItem: (key) => { - const value = storage.getString(key); - return Promise.resolve(value); - }, - removeItem: (key) => { - storage.delete(key); - return Promise.resolve(); - }, -}; - -// Used to prevent duplicate notifications for the same txId that seems to occur when: -// - when Bitkit is brought from background to foreground -// - connection to electrum server is lost and then re-established -export const receivedTxIds = { - STORAGE_KEY: 'receivedTxIds', - - // Get stored txIds - get: (): string[] => { - return JSON.parse(storage.getString(receivedTxIds.STORAGE_KEY) || '[]'); - }, - - // Save txIds to storage - save: (txIds: string[]): void => { - storage.set(receivedTxIds.STORAGE_KEY, JSON.stringify(txIds)); - }, - - // Add a new txId - add: (txId: string): void => { - const txIds = receivedTxIds.get(); - txIds.push(txId); - receivedTxIds.save(txIds); - }, - - // Check if txId exists - has: (txId: string): boolean => { - const txIds = receivedTxIds.get(); - return txIds.includes(txId); - }, -}; +import { storage } from '.'; export class WebRelayCache { location: string; @@ -110,5 +62,14 @@ export class WebRelayCache { return this; } + clear(): void { + const keys = storage.getAllKeys(); + keys.forEach((key) => { + if (key.includes('WEB-RELAY-CLIENT')) { + storage.delete(key); + } + }); + } + async close(): Promise {} } diff --git a/src/storage/widgets-cache.ts b/src/storage/widgets-cache.ts new file mode 100644 index 000000000..7f3c8f45e --- /dev/null +++ b/src/storage/widgets-cache.ts @@ -0,0 +1,29 @@ +import { storage } from '.'; + +const PREFIX = 'widget_'; + +export const widgetsCache = { + set(key: string, data: T): void { + storage.set(PREFIX + key, JSON.stringify(data)); + }, + + get(key: string): T | null { + const stored = storage.getString(PREFIX + key); + if (!stored) return null; + try { + return JSON.parse(stored); + } catch { + return null; + } + }, + + clear(): void { + const keys = storage.getAllKeys(); + console.log({ keys }); + for (const key of keys) { + if (key.startsWith(PREFIX)) { + storage.delete(key); + } + } + }, +}; diff --git a/src/store/index.ts b/src/store/index.ts index bae1a4945..1d3b54c70 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -18,8 +18,8 @@ import { __ENABLE_REDUX_LOGGER__, __JEST__, } from '../constants/env'; +import { reduxStorage } from '../storage'; import migrations from './migrations'; -import { reduxStorage } from './mmkv-storage'; import rootReducer, { RootReducer } from './reducers'; const devMiddleware: Middleware[] = []; diff --git a/src/store/migrations/index.ts b/src/store/migrations/index.ts index a1d60a2e9..00c837990 100644 --- a/src/store/migrations/index.ts +++ b/src/store/migrations/index.ts @@ -1,6 +1,6 @@ // Add migrations for every persisted store version change import { PersistedState } from 'redux-persist'; -import { storage as mmkv } from '../../store/mmkv-storage'; +import { storage as mmkv } from '../../storage'; import { getDefaultOptions } from '../../utils/widgets'; import { getDefaultGapLimitOptions } from '../shapes/wallet'; diff --git a/src/store/reducers/index.ts b/src/store/reducers/index.ts index 95299fa90..71482253c 100644 --- a/src/store/reducers/index.ts +++ b/src/store/reducers/index.ts @@ -1,7 +1,7 @@ import { UnknownAction, combineReducers } from 'redux'; +import { storage } from '../../storage'; import actions from '../actions/actions'; -import { storage } from '../mmkv-storage'; import activity from '../slices/activity'; import backup from '../slices/backup'; import blocktank from '../slices/blocktank'; diff --git a/src/utils/wallet/index.ts b/src/utils/wallet/index.ts index 0633af347..6df9654ff 100644 --- a/src/utils/wallet/index.ts +++ b/src/utils/wallet/index.ts @@ -38,6 +38,7 @@ import { BIP32Factory } from 'bip32'; import * as bip39 from 'bip39'; import * as bitcoin from 'bitcoinjs-lib'; +import { receivedTxIds } from '../../storage'; import { generateNewReceiveAddress, getWalletData, @@ -51,7 +52,6 @@ import { getStore, getWalletStore, } from '../../store/helpers'; -import { receivedTxIds } from '../../store/mmkv-storage'; import { getDefaultGapLimitOptions, getDefaultWalletShape,