paginating wallet logs (#1459)

* paginating wallet logs

* refine
This commit is contained in:
Keyan 2024-10-04 06:59:32 -05:00 committed by GitHub
parent 00bcd8c992
commit 5543a0755a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 478 additions and 210 deletions

View File

@ -351,20 +351,58 @@ const resolvers = {
facts: history
}
},
walletLogs: async (parent, args, { me, models }) => {
walletLogs: async (parent, { type, from, to, cursor }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.walletLog.findMany({
where: {
userId: me.id
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
]
})
// we cursoring with the wallet logs on the client
// if we have from, don't use cursor
// regardless, store the state of the cursor for the next call
const decodedCursor = cursor ? decodeCursor(cursor) : { offset: 0, time: to ?? new Date() }
let logs = []
let nextCursor
if (from) {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
gte: from ? new Date(Number(from)) : undefined,
lte: to ? new Date(Number(to)) : undefined
}
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
]
})
nextCursor = nextCursorEncoded(decodedCursor, logs.length)
} else {
logs = await models.walletLog.findMany({
where: {
userId: me.id,
wallet: type ?? undefined,
createdAt: {
lte: decodedCursor.time
}
},
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }
],
take: LIMIT,
skip: decodedCursor.offset
})
nextCursor = logs.length === LIMIT ? nextCursorEncoded(decodedCursor, logs.length) : null
}
return {
cursor: nextCursor,
entries: logs
}
}
},
Wallet: {

View File

@ -66,7 +66,7 @@ const typeDefs = `
wallets: [Wallet!]!
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs: [WalletLog]!
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
}
extend type Mutation {
@ -154,6 +154,11 @@ const typeDefs = `
}
type WalletLog {
entries: [WalletLogEntry!]!
cursor: String
}
type WalletLogEntry {
id: ID!
createdAt: Date!
wallet: ID!

273
components/use-indexeddb.js Normal file
View File

@ -0,0 +1,273 @@
import { useState, useEffect, useCallback, useRef } from 'react'
function useIndexedDB (dbName, storeName, version = 1, indices = []) {
const [db, setDb] = useState(null)
const [error, setError] = useState(null)
const operationQueue = useRef([])
const handleError = useCallback((error) => {
console.error('IndexedDB error:', error)
setError(error)
}, [])
const processQueue = useCallback((db) => {
if (!db) return
try {
// try to run a noop to see if the db is ready
db.transaction(storeName)
while (operationQueue.current.length > 0) {
const operation = operationQueue.current.shift()
operation(db)
}
} catch (error) {
handleError(error)
}
}, [storeName, handleError])
useEffect(() => {
let isMounted = true
const request = window.indexedDB.open(dbName, version)
request.onerror = (event) => {
handleError(new Error('Error opening database'))
}
request.onsuccess = (event) => {
if (isMounted) {
const database = event.target.result
database.onversionchange = () => {
database.close()
setDb(null)
handleError(new Error('Database is outdated, please reload the page'))
}
setDb(database)
processQueue(database)
}
}
request.onupgradeneeded = (event) => {
const database = event.target.result
try {
const store = database.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true })
indices.forEach(index => {
store.createIndex(index.name, index.keyPath, index.options)
})
} catch (error) {
handleError(new Error('Error upgrading database: ' + error.message))
}
}
return () => {
isMounted = false
if (db) {
db.close()
}
}
}, [dbName, storeName, version, indices, handleError, processQueue])
const queueOperation = useCallback((operation) => {
return new Promise((resolve, reject) => {
const wrappedOperation = (db) => {
try {
const result = operation(db)
resolve(result)
} catch (error) {
reject(error)
}
}
operationQueue.current.push(wrappedOperation)
processQueue(db)
})
}, [processQueue, db])
const add = useCallback((value) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.add(value)
request.onerror = () => reject(new Error('Error adding data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const get = useCallback((key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(new Error('Error getting data'))
request.onsuccess = () => resolve(request.result ? request.result : undefined)
})
})
}, [queueOperation, storeName])
const getAll = useCallback(() => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onerror = () => reject(new Error('Error getting all data'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const update = useCallback((key, value) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(new Error('Error updating data'))
request.onsuccess = () => {
const updatedValue = { ...request.result, ...value }
const updateRequest = store.put(updatedValue)
updateRequest.onerror = () => reject(new Error('Error updating data'))
updateRequest.onsuccess = () => resolve(updateRequest.result)
}
})
})
}, [queueOperation, storeName])
const remove = useCallback((key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.delete(key)
request.onerror = () => reject(new Error('Error removing data'))
request.onsuccess = () => resolve()
})
})
}, [queueOperation, storeName])
const clear = useCallback((indexName = null, query = null) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
if (!query) {
// Clear all data if no query is provided
const request = store.clear()
request.onerror = () => reject(new Error('Error clearing all data'))
request.onsuccess = () => resolve()
} else {
// Clear data based on the query
const index = indexName ? store.index(indexName) : store
const request = index.openCursor(query)
let deletedCount = 0
request.onerror = () => reject(new Error('Error clearing data based on query'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
const deleteRequest = cursor.delete()
deleteRequest.onerror = () => reject(new Error('Error deleting item'))
deleteRequest.onsuccess = () => {
deletedCount++
cursor.continue()
}
} else {
resolve(deletedCount)
}
}
}
})
})
}, [queueOperation, storeName])
const getByIndex = useCallback((indexName, key) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.get(key)
request.onerror = () => reject(new Error('Error getting data by index'))
request.onsuccess = () => resolve(request.result)
})
})
}, [queueOperation, storeName])
const getAllByIndex = useCallback((indexName, query, direction = 'next', limit = Infinity) => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.openCursor(query, direction)
const results = []
request.onerror = () => reject(new Error('Error getting data by index'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor && results.length < limit) {
results.push(cursor.value)
cursor.continue()
} else {
resolve(results)
}
}
})
})
}, [queueOperation, storeName])
const getPage = useCallback((page = 1, pageSize = 10, indexName = null, query = null, direction = 'next') => {
return queueOperation((db) => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const target = indexName ? store.index(indexName) : store
const request = target.openCursor(query, direction)
const results = []
let skipped = 0
let hasMore = false
request.onerror = () => reject(new Error('Error getting page'))
request.onsuccess = (event) => {
const cursor = event.target.result
if (cursor) {
if (skipped < (page - 1) * pageSize) {
skipped++
cursor.continue()
} else if (results.length < pageSize) {
results.push(cursor.value)
cursor.continue()
} else {
hasMore = true
}
}
if (hasMore || !cursor) {
const countRequest = target.count()
countRequest.onsuccess = () => {
resolve({
data: results,
total: countRequest.result,
hasMore
})
}
countRequest.onerror = () => reject(new Error('Error counting items'))
}
}
})
})
}, [queueOperation, storeName])
return { add, get, getAll, update, remove, clear, getByIndex, getAllByIndex, getPage, error }
}
export default useIndexedDB

View File

@ -1,18 +1,22 @@
import LogMessage from './log-message'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import styles from '@/styles/log.module.css'
import { Button } from 'react-bootstrap'
import { useToast } from './toast'
import { useShowModal } from './modal'
import { WALLET_LOGS } from '@/fragments/wallet'
import { getWalletByType } from 'wallets'
import { gql, useMutation, useQuery } from '@apollo/client'
import { gql, useLazyQuery, useMutation } from '@apollo/client'
import { useMe } from './me'
import useIndexedDB from './use-indexeddb'
import { SSR } from '@/lib/constants'
export function WalletLogs ({ wallet, embedded }) {
const logs = useWalletLogs(wallet)
const { logs, setLogs, hasMore, loadMore, loadLogs, loading } = useWalletLogs(wallet)
useEffect(() => {
loadLogs()
}, [wallet])
const tableRef = useRef()
const showModal = useShowModal()
return (
@ -21,13 +25,12 @@ export function WalletLogs ({ wallet, embedded }) {
<span
style={{ cursor: 'pointer' }}
className='text-muted fw-bold nav-link ms-auto' onClick={() => {
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} setLogs={setLogs} onClose={onClose} />)
}}
>clear logs
</span>
</div>
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
<div className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
<table>
<tbody>
{logs.map((log, i) => (
@ -39,15 +42,20 @@ export function WalletLogs ({ wallet, embedded }) {
))}
</tbody>
</table>
<div className='w-100 text-center'>------ start of logs ------</div>
{loading
? <div className='w-100 text-center'>loading...</div>
: logs.length === 0 && <div className='w-100 text-center'>empty</div>}
{hasMore
? <Button onClick={loadMore} size='sm' className='mt-3'>Load More</Button>
: <div className='w-100 text-center'>------ start of logs ------</div>}
</div>
</>
)
}
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
const { deleteLogs } = useWalletLogger(wallet, setLogs)
const toaster = useToast()
const { deleteLogs } = useWalletLogger(wallet)
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
return (
@ -60,7 +68,7 @@ function DeleteWalletLogsObstacle ({ wallet, onClose }) {
onClick={
async () => {
try {
await deleteLogs()
await deleteLogs(wallet)
onClose()
toaster.success('deleted wallet logs')
} catch (err) {
@ -76,72 +84,31 @@ function DeleteWalletLogsObstacle ({ wallet, onClose }) {
)
}
const WalletLoggerContext = createContext()
const WalletLogsContext = createContext()
const INDICES = [
{ name: 'ts', keyPath: 'ts' },
{ name: 'wallet_ts', keyPath: ['wallet', 'ts'] }
]
const initIndexedDB = async (dbName, storeName) => {
return new Promise((resolve, reject) => {
if (!window.indexedDB) {
return reject(new Error('IndexedDB not supported'))
}
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
const request = window.indexedDB.open(dbName, 1)
let db
request.onupgradeneeded = () => {
// this only runs if version was changed during open
db = request.result
if (!db.objectStoreNames.contains(storeName)) {
const objectStore = db.createObjectStore(storeName, { autoIncrement: true })
objectStore.createIndex('ts', 'ts')
objectStore.createIndex('wallet_ts', ['wallet', 'ts'])
}
}
request.onsuccess = () => {
// this gets called after onupgradeneeded finished
db = request.result
resolve(db)
}
request.onerror = () => {
reject(new Error('failed to open IndexedDB'))
}
})
function useWalletLogDB () {
const { me } = useMe()
const dbName = `app:storage${me ? `:${me.id}` : ''}`
const idbStoreName = 'wallet_logs'
const { add, getPage, clear, error: idbError } = useIndexedDB(dbName, idbStoreName, 1, INDICES)
return { add, getPage, clear, error: idbError }
}
export const WalletLoggerProvider = ({ children }) => {
const { me } = useMe()
const [logs, setLogs] = useState([])
let dbName = 'app:storage'
if (me) {
dbName = `${dbName}:${me.id}`
}
const idbStoreName = 'wallet_logs'
const idb = useRef()
const logQueue = useRef([])
export function useWalletLogger (wallet, setLogs) {
const { add, clear } = useWalletLogDB()
useQuery(WALLET_LOGS, {
fetchPolicy: 'network-only',
// required to trigger onCompleted on refetches
notifyOnNetworkStatusChange: true,
onCompleted: ({ walletLogs }) => {
setLogs((prevLogs) => {
const existingIds = prevLogs.map(({ id }) => id)
const logs = walletLogs
.filter(({ id }) => !existingIds.includes(id))
.map(({ createdAt, wallet: walletType, ...log }) => {
return {
ts: +new Date(createdAt),
wallet: tag(getWalletByType(walletType)),
...log
}
})
return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
})
const appendLog = useCallback(async (wallet, level, message) => {
const log = { wallet: tag(wallet), level, message, ts: +new Date() }
try {
await add(log)
setLogs?.(prevLogs => [log, ...prevLogs])
} catch (error) {
console.error('Failed to append log:', error)
}
})
}, [add])
const [deleteServerWalletLogs] = useMutation(
gql`
@ -151,105 +118,25 @@ export const WalletLoggerProvider = ({ children }) => {
`,
{
onCompleted: (_, { variables: { wallet: walletType } }) => {
setLogs((logs) => {
return logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false)
})
setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false))
}
}
)
const saveLog = useCallback((log) => {
if (!idb.current) {
// IDB may not be ready yet
return logQueue.current.push(log)
}
try {
const tx = idb.current.transaction(idbStoreName, 'readwrite')
const request = tx.objectStore(idbStoreName).add(log)
request.onerror = () => console.error('failed to save log:', log)
} catch (e) {
console.error('failed to save log:', log, e)
}
}, [])
useEffect(() => {
initIndexedDB(dbName, idbStoreName)
.then(db => {
idb.current = db
// load all logs from IDB
const tx = idb.current.transaction(idbStoreName, 'readonly')
const store = tx.objectStore(idbStoreName)
const index = store.index('ts')
const request = index.getAll()
request.onsuccess = () => {
let logs = request.result
setLogs((prevLogs) => {
if (process.env.NODE_ENV !== 'production') {
// in dev mode, useEffect runs twice, so we filter out duplicates here
const existingIds = prevLogs.map(({ id }) => id)
logs = logs.filter(({ id }) => !existingIds.includes(id))
}
// sort oldest first to keep same order as logs are appended
return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
})
}
// flush queued logs to IDB
logQueue.current.forEach(q => {
const isLog = !!q.wallet
if (isLog) saveLog(q)
})
logQueue.current = []
})
.catch(console.error)
return () => idb.current?.close()
}, [])
const appendLog = useCallback((wallet, level, message) => {
const log = { wallet: tag(wallet), level, message, ts: +new Date() }
saveLog(log)
setLogs((prevLogs) => [log, ...prevLogs])
}, [saveLog])
const deleteLogs = useCallback(async (wallet, options) => {
if ((!wallet || wallet.walletType) && !options?.clientOnly) {
await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } })
}
if (!wallet || wallet.sendPayment) {
try {
const tx = idb.current.transaction(idbStoreName, 'readwrite')
const objectStore = tx.objectStore(idbStoreName)
const idx = objectStore.index('wallet_ts')
const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity])) : idx.openCursor()
request.onsuccess = function (event) {
const cursor = event.target.result
if (cursor) {
cursor.delete()
cursor.continue()
} else {
// finished
setLogs((logs) => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
}
}
const walletTag = wallet ? tag(wallet) : null
await clear('wallet_ts', walletTag ? window.IDBKeyRange.bound([walletTag, 0], [walletTag, Infinity]) : null)
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
} catch (e) {
console.error('failed to delete logs', e)
}
}
}, [me, setLogs])
return (
<WalletLogsContext.Provider value={logs}>
<WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}>
{children}
</WalletLoggerContext.Provider>
</WalletLogsContext.Provider>
)
}
export function useWalletLogger (wallet) {
const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext)
}, [clear, deleteServerWalletLogs, setLogs])
const log = useCallback(level => message => {
if (!wallet) {
@ -257,9 +144,6 @@ export function useWalletLogger (wallet) {
return
}
// TODO:
// also send this to us if diagnostics was enabled,
// very similar to how the service worker logger works.
appendLog(wallet, level, message)
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
}, [appendLog, wallet])
@ -270,10 +154,6 @@ export function useWalletLogger (wallet) {
error: (...message) => log('error')(message.join(' '))
}), [log, wallet?.name])
const deleteLogs = useCallback((options) => {
return innerDeleteLogs(wallet, options)
}, [innerDeleteLogs, wallet])
return { logger, deleteLogs }
}
@ -281,7 +161,79 @@ function tag (wallet) {
return wallet?.shortName || wallet?.name
}
export function useWalletLogs (wallet) {
const logs = useContext(WalletLogsContext)
return logs.filter(l => !wallet || l.wallet === tag(wallet))
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
const [logs, setLogs] = useState([])
const [page, setPage] = useState(initialPage)
const [hasMore, setHasMore] = useState(true)
const [total, setTotal] = useState(0)
const [cursor, setCursor] = useState(null)
const [loading, setLoading] = useState(true)
const { getPage, error: idbError } = useWalletLogDB()
const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' })
const loadLogsPage = useCallback(async (page, pageSize, wallet) => {
try {
let result = { data: [], hasMore: false }
const indexName = wallet ? 'wallet_ts' : 'ts'
const query = wallet ? window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity]) : null
result = await getPage(page, pageSize, indexName, query, 'prev')
// no walletType means we're using the local IDB
if (wallet && !wallet.walletType) {
return result
}
const { data } = await getWalletLogs({
variables: {
type: wallet?.walletType,
// if it client logs has more, page based on it's range
from: result?.data[result.data.length - 1]?.ts && result.hasMore ? String(result.data[result.data.length - 1].ts) : null,
// if we have a cursor (this isn't the first page), page based on it's range
to: result?.data[0]?.ts && cursor ? String(result.data[0].ts) : null,
cursor
}
})
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
ts: +new Date(createdAt),
wallet: tag(getWalletByType(walletType)),
...log
}))
const combinedLogs = Array.from(new Set([...result.data, ...newLogs].map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts)
setCursor(data.walletLogs.cursor)
return { ...result, data: combinedLogs, hasMore: result.hasMore || !!data.walletLogs.cursor }
} catch (error) {
console.error('Error loading logs from IndexedDB:', error)
return { data: [], total: 0, hasMore: false }
}
}, [getPage, setCursor, cursor])
if (idbError) {
console.error('IndexedDB error:', idbError)
}
const loadMore = useCallback(async () => {
if (hasMore) {
setLoading(true)
const result = await loadLogsPage(page, logsPerPage, wallet)
setLogs(prevLogs => [...prevLogs, ...result.data])
setHasMore(result.hasMore)
setTotal(result.total)
setPage(prevPage => prevPage + 1)
setLoading(false)
}
}, [loadLogsPage, page, logsPerPage, wallet, hasMore])
const loadLogs = useCallback(async () => {
setLoading(true)
const result = await loadLogsPage(1, logsPerPage, wallet)
setLogs(result.data)
setHasMore(result.hasMore)
setTotal(result.total)
setPage(1)
setLoading(false)
}, [wallet, loadLogsPage])
return { logs, hasMore, total, loadMore, loadLogs, setLogs, loading }
}

View File

@ -197,13 +197,16 @@ export const WALLETS = gql`
`
export const WALLET_LOGS = gql`
query WalletLogs {
walletLogs {
id
createdAt
wallet
level
message
query WalletLogs($type: String, $from: String, $to: String, $cursor: String) {
walletLogs(type: $type, from: $from, to: $to, cursor: $cursor) {
cursor
entries {
id
createdAt
wallet
level
message
}
}
}
`

View File

@ -17,7 +17,6 @@ import { SSR } from '@/lib/constants'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { LoggerProvider } from '@/components/logger'
import { WalletLoggerProvider } from '@/components/wallet-logger'
import { ChainFeeProvider } from '@/components/chain-fee.js'
import dynamic from 'next/dynamic'
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
@ -107,30 +106,28 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<MeProvider me={me}>
<HasNewNotesProvider>
<LoggerProvider>
<WalletLoggerProvider>
<WebLnProvider>
<ServiceWorkerProvider>
<AccountProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</AccountProvider>
</ServiceWorkerProvider>
</WebLnProvider>
</WalletLoggerProvider>
<WebLnProvider>
<ServiceWorkerProvider>
<AccountProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<ChainFeeProvider chainFee={chainFee}>
<ErrorBoundary>
<Component ssrData={ssrData} {...otherProps} />
{!router?.query?.disablePrompt && <PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />}
</ErrorBoundary>
</ChainFeeProvider>
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</AccountProvider>
</ServiceWorkerProvider>
</WebLnProvider>
</LoggerProvider>
</HasNewNotesProvider>
</MeProvider>